You are reading the third part of this article series about architecture documentation as code. In this article, we will implement the Structurizr-related part of the workflow highlighted in the following figure.
The motivation behind this workflow, the main tools used, and their core concepts are explained in part one of this series.
How to use AsciiDoc to write architecture documents based on the arc42 template as well as how to automate the generation of HTML documents from these documents using Asciidoctor and Gradle is described in part two.
The code can be found on GitHub. The architecture documentation we create in this article can be found on GitHub Pages.
Diagrams
To work efficiently with PlantUML diagrams, we should be able to view and edit them in our IDE. For this purpose, just as for AsciiDoc, there are PlantUML plugins for the most common IDEs such as VS Code, Eclipse and IntelliJ.
After installing the plugin, we should be able to edit PlantUML diagrams in our IDE and get a preview window showing the rendered diagrams as shown in the following figure.
What should be diagrammed?
Before we start creating our diagrams, let's have a look back at the arc42 template we already used in part two of this series to structure our architecture documentation. The document structure defined by this template is shown in the following figure.
The template defines multiple sections where the different diagram types provided by the C4 model can be used. The following table shows how the C4 diagram types can be mapped to the arc42 template sections.
arc42 template section | C4 diagram type |
---|---|
Context and Scope | System context diagram System landscape diagram |
Building Block View | Container diagram Component diagram |
Runtime View | Dynamic diagram |
Deployment View | Deployment diagram |
Additionally, Code diagrams can be added to the building block, runtime or deployment view, but should only be added if they provide value to the reader. Such low-level diagrams contain a lot of detail, which tend to become obsolete fast and thus need a lot of maintenance to keep them up to date. To minimize these efforts, they should be generated from code automatically. This also applies to component diagrams. How component diagrams, and other elements of the Structurizr model, can be generated from code will be described in the final part of this series.
system landscape diagrams can be used in Context and Scope section if a broader overview of the systems in the enterprise or project is needed. Especially when diagram elements contain links to other diagrams or documentation resources, system landscape diagrams are useful to scale the use of the C4 model for more complex scenarios. For example, Spotify lately released a blog post describing how they use these features to scale the C4 approach for their software landscape.
In the following, we will use a rather simple example, which will be described in the next section. However, despite its simplicity, it aims to illustrate best practices, like the just mentioned linking between diagrams and other documentation resources.
The example project
We will document a fictional application called “Inventory Service”. The Inventory Service is part of the backend of an online shop. It's responsible for providing meta information regarding the available goods like name, description and the available amount in stock. The Inventory Service receives this information from a system called "Warehouse" and provides the data to clients via GraphQL. Furthermore, if a good runs out of stock, the Inventory Service triggers a system called "Order Service" via a REST API call to order new goods.
For the Inventory Service, we will create a system context, container, and deployment diagram. But first, we have to set up Structurizr.
Structurizr
As pointed out in part one, we will use the Structurizr C4-PlantUML extension to model our architecture and to create our diagrams. The related code will be placed in src/docs/kotlin
. Therefore, we have to declare the Structurizr C4-PlantUML extension dependency for our doc's source set. This can be done either in a dedicated dependencies block inside the sourceSets
section
1sourceSets { 2 ... 3 val docs by getting { 4 dependencies { 5 "docsImplementation"( 6 "io.github.chriskn:structurizr-c4puml-extension:0.7.2" 7 ) 8 } 9 } 10}
or as part of the top-level dependencies block together with all other dependencies.
1dependencies { 2 "docsImplementation"("io.github.chriskn:structurizr-c4puml-extension:0.7.2") 3}
The dependency can be found in the Maven central repository.
1repositories { 2 mavenCentral() 3}
Modelling
Before we start creating the model, we need to initialize the Structurizr workspace. The workspace is the root element of the Structurizr API. It serves as wrapper for all model elements and views.
To initialize the workspace, we create a new Kotlin object called InventoryWorkspace
under src/docs/kotlin/model
as shown in the following listing.
1object InventoryWorkspace { 2 val workspace: Workspace = Workspace( 3 "Inventory Service", 4 "Inventory Service example" 5 ) 6 val model: Model = workspace.model 7 val views: ViewSet = workspace.views 8}
Inside this object, we create a new Structurizr workspace and set the name as well as the description. For convenience and clarity reasons, we assign the contained model and views to variables. This wrapper allows us to share the contained Structurizr workspace instance between files.
System context
Let's start modelling our system in scope, the Inventory Service, as well as the surrounding, external systems it interacts with. Therefore, we create a new Kotlin object named Systems
with the following content.
1object Systems { 2 3 val orderService = model.softwareSystem( 4 name = "Order service", 5 description = "Orders new goods", 6 location = Location.External 7 ) 8 val warehouse = model.softwareSystem( 9 name = "Warehouse", 10 description = "Provides inventory data via Kafka", 11 location = Location.External 12 ) 13 14 val graphQlFederation = model.softwareSystem( 15 name = "GraphQL Federation", 16 description = "Provides a federated Graph to clients", 17 location = Location.External, 18 ) 19 20 val inventoryService = model.softwareSystem( 21 name = "Inventory service", 22 description = "Reads inventory data and provides it to clients" + 23 "via subgraph-inventory. Orders new goods if they run out of stock", 24 uses = listOf( 25 Dependency( 26 destination = graphQlFederation, 27 description = "subgraph-inventory", 28 link = "http://your-federated-graph/subgraph-inventory" 29 ), 30 Dependency( 31 destination = warehouse, 32 description = "Reads inventory data from", technology = "Kafka", 33 interactionStyle = InteractionStyle.Asynchronous 34 ), 35 Dependency( 36 destination = orderService, 37 description = "Triggers order if goods run out of stock", 38 technology = "REST" 39 ) 40 ), 41 link = "#container-view" 42 ) 43}
We create the external systems by adding them to the model
we initialized in the previous section and set their location parameter to Location.External
.
Next, we initialize the system we want to describe, the inventoryService
system. To model the dependencies to the external systems, we add a list of dependencies using the uses
parameter. For the dependencies, we set the description
and technology
. For the Kafka topic dependencies, we furthermore set the interactionStyle
to Asynchronous
, which will be reflected in the resulting diagram by a different line style.
Furthermore, we add a link to the HTML anchor container-view
. This makes the diagram interactive and allows the reader to navigate to the container diagram by clicking on the Inventory Service system in the rendered system context diagram, thereby emphasizing the zoom-level metaphor of the C4 model.
Collaboration is key when building complex software systems. Thus, let's add information about who maintains and operates the external systems using the following extension function.
1fun SoftwareSystem.maintainedBy(teamName: String) = model.person(
2 name = teamName,
3 description = "Maintains and operates the ${this.name}",
4 location = this.location,
5 uses = listOf(Dependency(this, "maintains and operates")),
6 link = "https://yourconfluence.com/teams/${teamName.replace(" ", "+")}"
7)
To allow readers to obtain more information about these teams, we add links to the corresponding team Confluence pages. Finally, we call these methods in the init
block of the Systems
object.
1init { 2 warehouse.maintainedBy(teamName = "Team A") 3 orderService.maintainedBy(teamName = "Team B") 4}
Containers
Next, let's zoom in to the container level of the C4 model and describe what deployable units the system we just created consists of. We create a new Kotlin object named Containers
and add the related containers to the software systems as shown in the following listing.
1object Containers {
2
3 val subGraphInventory = graphQlFederation.container(
4 name = "subgraph-inventory",
5 description = "Provides inventory data",
6 location = Location.Internal,
7 technology = "GraphQL",
8 icon = "graphql"
9 )
10
11 private val stockTopic = warehouse.kafkaTopic(
12 name = "warehouse.stock",
13 description = "Contains data regarding the amount of goods in stock"
14 )
15 private val goodsTopic = warehouse.kafkaTopic(
16 name = "warehouse.goods",
17 description = "Contains metadata regarding goods"
18 )
19
20 private fun SoftwareSystem.kafkaTopic(
21 name: String,
22 description: String
23 ): Container = this.container(
24 name = name,
25 description = description,
26 c4Type = C4Type.QUEUE,
27 technology = "Kafka",
28 icon = "kafka",
29 link = "https://examplecompany.akhq.org/$name"
30 )
31
32 val database = inventoryService.container(
33 name = "Inventory Database",
34 description = "Stores inventory items",
35 c4Type = C4Type.DATABASE,
36 technology = "PostgreSQL",
37 icon = "postgresql"
38 )
39
40 val inventoryProvider = inventoryService.container(
41 name = "Inventory Provider",
42 description = "Reads inventory data and provides it to clients" +
43 "via subgraph-inventory. Orders new goods if they run out of stock",
44 technology = "SpringBoot, Spring Data JDBC, Kafka Streams",
45 icon = "springboot",
46 uses = listOf(
47 Dependency(
48 destination = database,
49 description = "Reads and writes inventory data to/from"
50 ),
51 Dependency(
52 destination = subGraphInventory,
53 description = "contributes to federated graph"
54 ),
55 Dependency(
56 destination = goodsTopic,
57 description = "reads",
58 technology = "Kafka",
59 interactionStyle = Asynchronous
60 ),
61 Dependency(
62 destination = stockTopic,
63 description = "reads",
64 technology = "Kafka",
65 interactionStyle = Asynchronous
66 ),
67 Dependency(
68 destination = orderService,
69 description = "Triggers order if goods run out of stock",
70 technology = "REST"
71 )
72 )
73 )
74}
We add the subgraph-inventory
to the graphQlFederation
system, since the subgraph is part of a federated graph. However, the subgraph is provided by our service and we are responsible for it. Therefore, we set the location to Internal
.
For the Warehouse system, we add two containers, one for each Kafka topic the Inventory Service ingests. To avoid duplicate code, we use the extension method kafkaTopic
. Inside the method, we set the c4Type
parameter to Queue
. As a result, the containers will be rendered using a queue shape. Furthermore, we allow readers to explore the data contained in the topics by adding links to a fictitious AKHQ instance.
Finally, we add the containers to the Inventory Service. The Inventory Service consists of a database, which we add using the C4Type database
, and the inventoryProvider
application. For the latter, we add the dependencies to the other containers we created.
For all the containers, we used icons. These icons will be rendered in the PlantUML diagrams and help readers to identify the main technology of the container easily. The icons are resolved case-insensitive by a component called IconRegistry
. There are some icons already available. Additional icons can be added via:
1IconRegistry.addIcon(name = "unique icon name", url="url to the icon puml file")
Deployment
Structurizr allows us to describe how containers and systems are mapped to infrastructure using deployment nodes. Those nodes represent physical or virtualized infrastructure that can be used as deployment targets (e.g. runtime environments, Docker containers, servers). Deployment nodes can be nested. Besides containers and systems, deployment nodes can host infrastructure nodes like load balancers, proxies, firewall, etc.
Let's say we deploy our Inventory Service in a Docker container to an EKS cluster in AWS and using an NGINX as reverse Proxy for external requests. The database of the Inventory Service runs in RDS. The federated graph containing the subgraph-inventory
is deployed to Apollo Studio, running outside of AWS. This deployment could be modelled as shown in the following listing.
1object Deployment { 2 3 private val aws = InventoryWorkspace.model.deploymentNode( 4 name = "AWS", 5 icon = "aws", 6 properties = C4Properties( 7 values = listOf( 8 listOf("accountId", "123456"), 9 listOf("region", "eu-central-1") 10 ) 11 ) 12 ) 13 14 private val rds = aws.deploymentNode( 15 name = "Relational Database Service (RDS)", 16 icon = "awsrds", 17 hostsContainers = listOf(Containers.database) 18 ) 19 20 private val eks = aws.deploymentNode( 21 name = "Elastic Kubernetes Service (EKS)", 22 icon = "awsekscloud" 23 ) 24 25 private val inventoryPod = eks.deploymentNode( 26 name = "Inventory POD", 27 properties = C4Properties( 28 values = listOf( 29 listOf("port", "8080"), 30 listOf("minReplicas", "2"), 31 listOf("maxReplicas", "10") 32 ) 33 ) 34 ) 35 private val inventoryDocker = inventoryPod.deploymentNode( 36 name = "Docker Container", 37 icon = "docker", 38 hostsContainers = listOf(Containers.inventoryProvider) 39 ) 40 41 private val ingress = eks.infrastructureNode( 42 name = "Ingress", 43 description = "Used for load balancing and SSL termination", 44 icon = "nginx", 45 technology = "NGINX", 46 uses = listOf( 47 Dependency( 48 destination = inventoryPod, 49 description = "forwards requests to" 50 ) 51 ) 52 ) 53 54 private val appollo = InventoryWorkspace.model.deploymentNode( 55 name = "Apollo Studio", 56 hostsSystems = listOf(Systems.graphQlFederation), 57 uses = listOf( 58 Dependency( 59 destination = ingress, 60 description = "forwards ${Containers.subGraphInventory.name}"+ 61 " queries to" 62 ) 63 ) 64 ) 65}
The listing shows that we can add properties to model elements. These properties will be shown in tables in the resulting diagram. A deployment usually depends on more properties than the few we added in this simple example. Those properties have a big impact on the deployment and therefore are relevant for readers of the documentation. At the same time, keeping them up to date can be time-consuming. Therefore, we will see how we can extract them from Helm charts in the final part of this series.
System context diagram
Now that we have our model in place, we create the views on this model and export them as C4-PlantUML diagrams. Let's start with the system context diagram. We add the following method to the Systems
object.
1fun createContextView(){
2 val contextView = InventoryWorkspace.views.systemContextView(
3 softwareSystem = inventoryService,
4 key = "inventory_context",
5 description = "Context diagram for the web shop inventory system",
6 )
7 contextView.addAllElements()
8}
We create a systemContextView
for the invertorySystem
, set a key
, which has to be unique for each diagram, and add a description
. Afterwards, we call the addAllElements
method on the view. This will add all elements of the types SoftwareSystem
and Person
we created in our Structurizr model.
To export the view as C4-PlantUML diagram, we create a new Kotlin file under src/doc/kotlin
and name it WriteDiagrams
.
1private val outputFolder = File("src/docs/resources/plantuml/")
2
3fun main() {
4 Systems.createContextView()
5
6 workspace.writeDiagrams(outputFolder)
7}
Inside the file, we create a main
method and call the createContextView
method we just created. Next, we write all diagrams of the workspace
to the plantuml
folder we configured in our asciiAttributes
map inside the build.gradle.kts
file using the writeDiagrams
method. After executing the main method, we should find the following diagram under src/docs/resources/plantuml
.
The diagram contains all expected elements, but the layout can be improved. Therefore, we go back to the createContextView
method and add a C4PlantUmlLayout
.
1fun createContextView(){
2 val contextView = InventoryWorkspace.views.systemContextView(
3 softwareSystem = inventoryService,
4 key = "inventory_context",
5 description = "Context diagram for the web shop inventory system",
6 layout = C4PlantUmlLayout(
7 dependencyConfigurations = listOf(
8 DependencyConfiguration(
9 filter = {it.destination == graphQlFederation},
10 direction = Direction.Up
11 ),
12 DependencyConfiguration(
13 filter = {it.destination == orderService},
14 direction = Direction.Right
15 ),
16 DependencyConfiguration(
17 filter = {it.source is Person},
18 direction = Direction.Up
19 )
20 )
21 )
22 )
23 contextView.addAllElements()
24}
Inside the layout, we configure dependency directions. This will influence the positioning of the connected elements. As a result, we should end up with the well-layouted diagram shown in the following figure.
To lay out especially complex (C4-)PlantUML diagrams is not trivial. A good approach is to first generate the diagram without any layout configuration, editing the generated PlantUML directly until you find a decent layout, and then configure the C4PlantUmlLayout of the view accordingly.
Container diagram
For the container diagram, we add the following method to the Containers
object.
1fun createContainerView() {
2 val containerView = InventoryWorkspace.views.containerView(
3 system = inventoryService,
4 key = "inventory_container",
5 description = "Container diagram for the inventory domain",
6 layout = C4PlantUmlLayout(
7 dependencyConfigurations = listOf(
8 DependencyConfiguration(
9 filter = { it.destination == database },
10 direction = Direction.Left
11 ),
12 DependencyConfiguration(
13 filter = { it.destination == subGraphInventory },
14 direction = Direction.Up
15 ),
16 DependencyConfiguration(
17 filter = { it.destination == orderService },
18 direction = Direction.Right
19 )
20 ),
21 nodeSep = 80,
22 rankSep = 80,
23 )
24 )
25 containerView.addNearestNeighbours(inventoryProvider)
26 containerView.externalSoftwareSystemBoundariesVisible = true
27}
This time, additionally to the dependencyConfiguration
, we add extra space between nodes using the nodeSep and rankSep parameters of the C4PlantUmlLayout. After creating the view, we add the nearest neighbours of the inventoryProvider
. This will add all systems and containers the inventoryProvider
relates to, given a detailed view on the external dependencies of inventoryProvider
. Furthermore, we set externalSoftwareSystemBoundariesVisible
to true
. This will allow the reader to identify to which external systems the external containers used by the inventoryProvider
belong to.
After adding the following line to our main
method inside the WriteDiagrams
file
1Containers.createContainerView()
and executing the main method, the following container diagram should be written.
Deployment diagram
Finally, we add a deployment view by creating the following method inside the Deployment
object.
1fun createDeploymentView() {
2 val deploymentView = InventoryWorkspace.views.deploymentView(
3 system = Systems.inventoryService,
4 key = "inventory_deployment",
5 description = "Deployment diagram for the Inventory service",
6 layout = C4PlantUmlLayout(
7 dependencyConfigurations = listOf(
8 DependencyConfiguration(
9 filter = { it.source == ingress },
10 mode = Mode.Neighbor
11 ),
12 ),
13 layout = Layout.LeftToRight
14 )
15 )
16 deploymentView.addAllDeploymentNodes()
17}
This time, we change the layout direction to LeftToRight
and ensure that the ingress
infrastructure node is always placed next to the inventoryPod
by seeting the relation mode to neighbor
.
Again, we call the method inside our main method
1Deployment.createDeploymentView()
resulting in the following diagram.
PlantUML integration
Before we integrate the C4-PlantUML diagrams into our architecture documentation, we have to make sure that the diagrams are up to date before the documentation is generated. Therefore, we register the following task in our build.gradle.kts
file, which calls our main method and therefore writes the diagrams.
1tasks.register("writeDiagrams", JavaExec::class) {
2 classpath += sourceSets["docs"].runtimeClasspath
3 mainClass.set("docsascode.WriteDiagramsKt")
4 group = "documentation"
5}
To make sure this task is executed before the document is generated, we let the Asciidoctor task depend on it.
1tasks.withType(AsciidoctorTask::class)
2 …
3 dependsOn("writeDiagrams")
4}
Now we can integrate the diagrams in our AsciiDoc files as shown in the following figure.
The important line is highlighted, line 4. We integrate the diagram using the plantuml
keyword. For the file path, we use the plantUmlDir
variable we defined as an asciiAttribute
in our build.gradle.kts
file. As format, we choose svg
and use the inline
option to ingrate the diagram.
As a result, diagram texts are searchable and the links we added for our model elements are clickable, allowing readers to navigate inside the document or to external resources.
Summary and outlook
In this article, we learned by example how to use Structurizr to model our system and create C4-PlantUML diagrams from this model. Finally, we integrated the resulting diagrams into our AsciiDoc documentation.
In the next part, we will learn how to publish our architecture documentation to GitHub Pages and Atlassian Confluence as part of a build pipeline using GitHub Actions.
More articles
fromChristoph Knauf
Your job at codecentric?
Jobs
Agile Developer und Consultant (w/d/m)
Alle Standorte
More articles in this subject area
Discover exciting further topics and let the codecentric world inspire you.
Gemeinsam bessere Projekte umsetzen.
Wir helfen deinem Unternehmen.
Du stehst vor einer großen IT-Herausforderung? Wir sorgen für eine maßgeschneiderte Unterstützung. Informiere dich jetzt.
Hilf uns, noch besser zu werden.
Wir sind immer auf der Suche nach neuen Talenten. Auch für dich ist die passende Stelle dabei.
Blog author
Christoph Knauf
Do you still have questions? Just send me a message.
Do you still have questions? Just send me a message.