Beliebte Suchanfragen
//

Architecture docs as code with Structurizr & Asciidoctor. Part 3: Structurizr

21.10.2022 | 15 minutes of reading time

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 workflow we will implement in this article.

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.

The IntelliJ PlantUML plugin

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 chapters of the arc42 template. Source: www.arc42.org

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 sectionC4 diagram type
Context and ScopeSystem context diagram
System landscape diagram
Building Block ViewContainer diagram
Component diagram
Runtime ViewDynamic diagram
Deployment ViewDeployment 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 automatically layouted system context diagram.

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.

The system context diagram with a custom layout.

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.

The Container diagram of the Inventory Service.

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.

The deployment diagram of the Inventory Service.

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)
23    dependsOn("writeDiagrams")
4}

Now we can integrate the diagrams in our AsciiDoc files as shown in the following figure.

Including the C4-PlantUML diagrams in the architecture documentation.

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.

share post

//

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.