Skip to main content
Engineering7 min read

Architecture as Code with Structurizr DSL: A C4 Workspace Both a Reviewer and an Agent Can Read

I committed a Structurizr DSL workspace next to a Kotlin/Spring billing service and wired ArchUnit into Gradle so CI fails the moment the code drifts from the C4 design. Here is the workspace.dsl, the test that backs it, and what changed when a coding agent could read both files instead of guessing from packages.

All Posts
2/4

A Confluence diagram drawn the day a service was born is a fossil by sprint two. I had two of those in my old notes — same boxes, same lines, same lies. What pushed me to rip them out and try something different was a post Neal Ford and Mark Richards published on the O'Reilly Radar in April 2026, "Architecture as Code to Teach Humans and Agents About Architecture." Their argument is short: the readers of architecture artifacts are no longer only human. Coding agents now consult the same diagrams in the same pull requests, and intent that lives outside the repo is intent neither reader sees.

So I picked one small Kotlin/Spring service from a throwaway project, deleted its Confluence page, and committed a single workspace.dsl file at the repo root. Then I added a Gradle test that fails when the bytecode stops matching the file. That is the entire post: what those two artifacts look like, and the lesson I took from the experiment.

The takeaway

A Structurizr DSL file in the repo is worth more than a Confluence diagram only when CI fails on the divergence between the file and the code. Architecture as code without that check is just diagrams as code, and rotting prose with extra syntax is still rotting prose.

The diagram below is the loop I want a reader to picture before the rest of the post — one DSL file feeding three consumers, with a Gradle gate at the bottom that decides whether a PR ships.

The workspace.dsl I committed next to a billing service

The service is small: an HTTP layer, a domain layer, a port for the database, an outbound port for an external payment gateway. Four components, four allowed relationships, every other dependency forbidden. The Structurizr DSL is a text format that maps to the C4 model, runs on the JVM, and exports to PlantUML and Mermaid through the Structurizr command-line tooling. The full file fits on one screen.

workspace "billing" "Billing service for a small marketplace" { model { customer = person "Customer" billing = softwareSystem "Billing" { api = container "API" "HTTP edge" "Spring Boot" { http = component "HttpController" "REST endpoints" "Kotlin" domain = component "BillingDomain" "Invoice + charge logic" "Kotlin" repo = component "InvoiceRepository" "Persistence port" "Kotlin" gw = component "PaymentGateway" "Outbound port" "Kotlin" } db = container "Postgres" "Invoices, charges" "PostgreSQL 16" } stripe = softwareSystem "Stripe" "Card processor" "External" customer -> http "Pays an invoice" http -> domain "Calls" domain -> repo "Reads + writes invoices" domain -> gw "Charges a card" repo -> db "SQL" gw -> stripe "HTTPS" } views { component api "Components" { include * autolayout lr } theme default } }

Render it with the Structurizr command-line export to Mermaid, and a C4 component diagram drops out. PlantUML works the same way. The file is the source of truth; the picture is a build artifact.

Two details from my notes are worth calling out. First, every relationship is explicit. The DSL also supports implied relationships, but I switch them off — a missing arrow should be a missing arrow, not a guess. Second, the technology tag on each component (Kotlin, Spring Boot, PostgreSQL 16) is what a coding agent latches onto when it reasons about the file. A box labeled "BillingDomain — Kotlin" grounds the model far better than "BillingDomain" alone.

The Gradle check that keeps the file honest

The DSL declares that HttpController reaches BillingDomain, that BillingDomain reaches InvoiceRepository and PaymentGateway, and that is all. Nothing else. ArchUnit, a JVM library that imports compiled bytecode and asserts rules from inside any JUnit test, is the cheapest tool I found to enforce that contract. The ArchUnit user guide is direct about the integration point: rules run "in any plain Java unit testing framework," which translates cleanly to JUnit 5 inside a Gradle build for a Kotlin project.

One Kotlin file, one rule, layered onto JUnit 5:

kotlin
package com.example.billing

import com.tngtech.archunit.core.importer.ClassFileImporter
import com.tngtech.archunit.library.Architectures.layeredArchitecture
import org.junit.jupiter.api.Test

class ArchitectureTest {
    @Test
    fun `code respects the workspace dsl`() {
        val classes = ClassFileImporter().importPackages("com.example.billing")

        layeredArchitecture().consideringAllDependencies()
            .layer("Http").definedBy("..http..")
            .layer("Domain").definedBy("..domain..")
            .layer("Repo").definedBy("..persistence..")
            .layer("Gateway").definedBy("..gateway..")
            .whereLayer("Http").mayNotBeAccessedByAnyLayer()
            .whereLayer("Domain").mayOnlyBeAccessedByLayers("Http")
            .whereLayer("Repo").mayOnlyBeAccessedByLayers("Domain")
            .whereLayer("Gateway").mayOnlyBeAccessedByLayers("Domain")
            .check(classes)
    }
}

Run it with ./gradlew test. The four whereLayer clauses are a one-to-one transcription of the four arrows in the DSL. Add a Spring @Autowired from HttpController straight into InvoiceRepository, skipping the domain, and the test fails on the next CI run with the exact class names that broke the rule.

The version that mattered in my own setup: com.tngtech.archunit:archunit-junit5:1.4.2 as a testImplementation in build.gradle.kts, plus the Kotlin compiled classes already on the test classpath via the standard kotlin-jvm plugin. No extra wiring. ArchUnit reads compiled .class files, so Kotlin or Java is irrelevant by the time the test runs.

What a coding agent gets from the DSL that source code alone cannot give it

The honest answer: the verbs and the boundaries. When I dropped the same agent into the repo with and without workspace.dsl, two behaviors changed in my tests.

First, the agent stopped inventing dependencies. Without the DSL, asked to "wire payment retries", it tried to add a StripeClient field directly on HttpController. With the DSL in context, it placed retry logic inside BillingDomain and routed through the existing PaymentGateway port. The arrows in the file made the boundary visible in a way that scattered Kotlin packages did not.

Second, the agent named its new code consistently. The DSL identifiers (http, domain, repo, gw) act as a glossary; the agent reused those nouns instead of coining synonyms like service or client. Source code alone does not advertise its own ontology. A DSL does.

Neither effect is a guarantee. The agent still hallucinated a "billing-events" component once that did not exist anywhere. But the ArchUnit test caught the resulting commit before review, which is the whole point of the second artifact.

Where this breaks down

Three traps from my notes, in order of how often I hit them.

The first trap: the DSL turns into shelfware the moment the ArchUnit rule is missing or muted. Without the test, the file becomes another diagram, and adding boxes to it on a Friday afternoon costs nothing — which is exactly why nothing prevents it from drifting. The check is not optional.

The second trap: ArchUnit reads bytecode, not intent. It catches code that breaks an arrow declared in the DSL. It does not catch an arrow declared in the DSL that no code uses. I keep a small script that lists DSL identifiers without test coverage and flags them at review time; the toolchain alone does not solve this asymmetry.

The third trap: introducing both tools to a brown-field repo at once produces hundreds of violations in the first run. ArchUnit's FreezingArchRule is designed for this case — it records existing violations to a snapshot file, fails only on new ones, and shrinks the snapshot whenever an old violation gets fixed. I reach for it whenever the first run prints more than a handful of failures.

The limits worth naming out loud. Component-level granularity is where Structurizr DSL pays off; pushing it down to class diagrams turns it into low-resolution UML. Deployment views are worth committing only when the deployment topology is non-trivial. And if the service has one container and three classes, the DSL is overhead — a README diagram is fine.

When to reach for this and when to skip it

Reach for workspace.dsl plus an ArchUnit rule when the service has at least three logical components, when a coding agent regularly opens PRs against the repo, or when more than one person has introduced a "small" cross-layer dependency in the last quarter. Skip it for prototypes, for single-class libraries, and for services where the architecture changes weekly — at that velocity, the DSL itself becomes the bottleneck.

The whole exercise distilled to four lines for my future self:

  • Commit one workspace.dsl per bounded context, not per repo.
  • Mirror every DSL arrow as a whereLayer clause in ArchUnit.
  • Disable implied relationships; force every dependency to be explicit.
  • Treat a green ./gradlew test as the only proof that the diagram is current.

When workspace.dsl and the ArchUnit test both pass, the diagram in the next PR is real. When either is missing, the diagram is a story — and stories are what I had in Confluence.

Further reading

  • Neal Ford and Mark Richards, "Architecture as Code to Teach Humans and Agents About Architecture", O'Reilly Radar, April 2026.
  • Structurizr DSL documentation at docs.structurizr.com/dsl.
  • ArchUnit user guide at archunit.org/userguide.
Read next

Still here? You might enjoy this.

Nothing close enough — try a different angle?

Was this helpful?

Leave a rating or a quick note — it helps me improve.