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.
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:
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.dslper bounded context, not per repo. - Mirror every DSL arrow as a
whereLayerclause in ArchUnit. - Disable implied relationships; force every dependency to be explicit.
- Treat a green
./gradlew testas 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.
Still here? You might enjoy this.
Nothing close enough — try a different angle?
Related Posts
Turning Repo Maintenance into Markdown: Keeping a Rust Codebase Alive with Agentic Workflows
Long-lived repositories drift: deprecated components linger, layers bleed, and tests miss the functions that actually break. In my own study I turned three recurring chores into scheduled markdown workflows the repo runs on itself, then wrote up what I learned about capping blast radius, pairing LLM checks with deterministic scans, and letting agents draft shapes while I write the substance.
A Fitness Function Is Just a Test That Fails the Build When the Architecture Drifts
A fitness function is not a framework artifact — it is a build-failing test that encodes one architectural invariant. I encode a layering rule in about 60 lines of TypeScript using the compiler's own API, test the test against good, bad, and generated-code trees, then draw the line between an invariant worth gating and a metric gate that backfires under Goodhart's law.
Auditing a Scala Service Against Chad Fowler's Four Regenerative Constraints
I walked a Scala order-processing service from my notes through Chad Fowler's four regenerative constraints. Two passed for free, two would force a real redesign. Here is what I learned about where "loosely coupled module" ends and "regenerative component" begins, and which parts of the redesign I would actually pay for.