Generate C4 Diagrams from Code Automatically (and Keep Them Current)

Raghav ChamadiyaUpdated 9 min read
Compared in this post: repowise and
  • Structurizr
  • Mermaid
  • PlantUML
On this page

To generate C4 diagrams from code, parse the repo for its containers (deployable units, usually one per package manifest), its components (the modules inside each) and the external systems it imports, roll the import edges up between them, and emit Structurizr DSL, Mermaid or PlantUML. Regenerate the model on every push; keep your views separate.

ApproachLanguagesC4 levels it can produceOutputHow it stays currentTrade-off
Hand-written Structurizr DSLAnyAll, including deployment and dynamicStructurizr DSL, exports to PlantUML, Mermaid and moreSomeone edits itDrifts the moment nobody owns it
Mermaid C4 by handAnyContext, container, component, dynamic, deploymentMermaid in MarkdownSomeone edits itMermaid marks its C4 syntax as experimental
go-structurizrGoComponentPlantUMLRe-run in CIReflects over a running application context; you write YAML rules for what counts as a component
SharpitectC#Mapped from solutions, projects and namespacesQueryable graph, C4 mappingRe-run in CIYoung project (0.2.x on NuGet); relies on annotations and conventions
A coding agent (Codex CLI, Claude Code)AnyWhatever you ask forMermaid, PlantUML or DSLcodex exec or similar in CIA model reads the code and can mix C4 levels or miss modules
repowise27 languages parsed to a full AST, 14 at the Full tierSystem context, container, component (opt-in), with hotspot, dead-code and ownership tagsStructurizr DSL (workspace or model fragment)Rebuilt from each index; on every push on hosted ProComponents are directories; actors are inferred from entry points

Scroll the table sideways to see every column.

I build repowise, so weigh that row accordingly. The method below works the same whichever tool you pick.

We build repowise, so weigh our entry accordingly. The measurements we publish about it, with their methods, are on the benchmarks page.

What C4 asks for, and which parts code can answer

The C4 model, from Simon Brown, describes a system at four zoom levels: system context (your system, its users, the systems around it), containers (the separately deployable or runnable things: a web app, an API, a worker, a database), components (the major building blocks inside a container) and code (classes and functions, which most teams never draw).

The table below lines up each level with what a parser can see.

C4 levelWhat it needsCan code tell you?
System contextYour system, its users, external systemsExternal systems, yes: every package you import. Users, only roughly: a CLI entry point suggests a CLI user, an HTTP router suggests an API client.
ContainerDeployable units and how they talkMostly. A package.json, pyproject.toml, go.mod or .csproj usually marks one. How they talk at runtime (HTTP, a queue, a shared database) is often in config, not imports.
ComponentThe building blocks inside a containerPartly. Directories and import clusters are a good first guess. Whether a folder is a meaningful "component" is a human judgement.
CodeClasses, functionsYes, completely, which is why nobody needs a diagram of it.

Scroll the table sideways to see every column.

So a generator can give you a strong first draft of context and container diagrams, a reasonable draft of components, and nothing about deployment. That is still most of the work, and it is the part that goes stale fastest.

A worked example: httpx

I took the C4 model repowise generated for encode/httpx, the Python HTTP client, from its most recent index (commit b5addb6, 3 October 2026). You can download the same file from the Knowledge Graph page with the "Structurizr DSL" button. Trimmed, the model looks like this:

text
sys_httpx = softwareSystem "httpx" {
    pkg = container "httpx" "98 files, 1245 symbols" "python" {
        cmp_httpx = component "httpx" "23 files, 612 symbols"
        cmp_tests = component "tests" "38 files, 633 symbols"
        cmp_docs  = component "docs" "24 files"
    }
}
ext_httpcore = softwareSystem "Httpcore" "library · pypi · ==1.*" { tags "External" }
ext_h2       = softwareSystem "H2" "library · pypi · >=3,<5" { tags "External" }
ext_trio     = softwareSystem "Trio" "library · pypi · ==0.31.0" { tags "External" }

cmp_httpx -> ext_httpcore "imports" { tags "Loose" }
cmp_httpx -> ext_h2 "imports" { tags "Loose" }
cmp_httpx -> ext_trio "imports" { tags "Loose" }
cmp_tests -> cmp_httpx "imports" { tags "Moderate" }

The model shows what generation is good at: versioned externals, coupling strength and real import direction.

The external systems are real and versioned. Every external system comes from a package the code imports, with the version range from the manifest. The httpx -> Trio edge looked wrong to me at first, since Trio is not a required dependency, but it is right: httpx/_transports/asgi.py imports trio lazily, only when it detects it is running under Trio. A hand-drawn diagram would almost certainly leave that out.

Coupling strength is recorded on each edge. Every relationship carries a Loose, Moderate or Tight tag, so you can style tight couplings thicker. C4 has no vocabulary for this, but it is the first thing I want to know about an arrow.

The model shows what the code does, which is not always what the manifest says. httpx lists anyio as a required dependency in pyproject.toml, yet in the model only the tests component imports it, and both facts are correct. httpx's own source never imports anyio; httpcore, which httpx calls, has an asyncio backend built on anyio that it treats as an optional extra, and httpx declares anyio so that backend is installed. An import-based diagram drops anyio from the httpx component, and a manifest-based one shows it but cannot tell you why, so the reason belongs in a sentence in your docs.

Actors are inferred from how the code is entered. The model includes a person called "API client" who "calls the HTTP API", because the package exposes an API surface. On a framework like Flask the same rule yields a "CLI user", since Flask ships a CLI. These are sensible defaults to rename to the real users of your system.

What repowise adds to the model

repowise exports the C4 model as Structurizr DSL with repowise export --format structurizr, built on demand from the graph the index already holds. It needs no LLM call and no API key, and the output is byte-identical for identical code (no timestamp in the header), so a diff shows architecture changes only.

C4 has no vocabulary for health, so repowise adds it as tags and namespaced properties on each container and component. Hotspot marks a box with at least one churn hotspot, Dead marks one with an unreachable file, and repowise.owner, repowise.ownerPct and repowise.minBusFactor record who owns it and the worst-case bus factor. Unknown values are left out rather than written as zero. Five lines of styles in your workspace colour them:

text
styles {
    element "Hotspot" { background #b5432f }
    element "Dead"    { background #6b6b6b }
    relationship "Tight" { thickness 4 }
}
repowise architecture graph of the repowise repository, showing module clusters (ui, server, cli, ingestion) sized by file count with import edges between them, the same graph the C4 export is built from
repowise architecture graph of the repowise repository, showing module clusters (ui, server, cli, ingestion) sized by file count with import edges between them, the same graph the C4 export is built from

Layer membership is a tag too, so one filtered view per layer (include "element.tag==Layer: Domain") shows the same model to a different audience. Details are in the Structurizr export docs.

The other ways to do it

You can write the model yourself. Structurizr DSL is still the most complete way to express C4: one model, many views, deployment diagrams, dynamic diagrams, decision records alongside. If your architecture is stable and someone owns the file, it is the best result you will get (I compared Structurizr, IcePanel, Mermaid and PlantUML for that job in the best architecture documentation tools, and the three notations side by side in Structurizr vs Mermaid vs PlantUML for C4). Note that Structurizr's own docs now list Structurizr Lite, the on-premises install and the cloud service under "End of life"; the current tools are structurizr local, the self-hosted server, the playground and the CLI (render with the structurizr/structurizr image). Mermaid also has C4 diagram types, which render inside GitHub Markdown, but its docs call them experimental and warn the syntax may change.

For Go, you can generate from the running application. Three Dots Labs described an approach in 2021 that still ranks well: go-structurizr builds your service's application context, walks the object graph with reflection, and matches components against rules you write in YAML, emitting PlantUML component diagrams. It is precise because it sees the real wiring, and it is Go-only and component-level only.

For C#, you can generate from Roslyn, the C# compiler platform. Sharpitect parses a C# solution with Roslyn and maps solutions, executable projects and namespaces to C4 systems, containers and components. It is early (0.2.x), so check that it handles your solution layout before relying on it.

You can also ask a coding agent. Codex CLI or Claude Code can read a repo and write Mermaid, PlantUML or DSL in any language. Daniel Vaughan's write-up on doing this with Codex CLI is a good guide, and he is candid about the limits: the agent only sees static code, not runtime traffic, and without explicit C4 guidance "the model often conflates container and component levels". If you go this way, put the C4 rules in your AGENTS.md, keep the agent's output under review like any other change, and expect a summary that may leave modules out.

How to keep C4 diagrams current

Generated or hand-written, a diagram goes stale for the same reason: nobody regenerates it. The habits below fix that.

  1. Split the model from the presentation. Generate only the model { } block into its own file and !include it from a workspace.dsl you own, where your views, styles, descriptions and decision records live. Regenerating the model then cannot overwrite anything a person wrote. repowise's CLI emits exactly that fragment by default (repowise export --format structurizr), and only writes a full workspace when you pass --standalone.
  2. Make the output deterministic. If the same code produces a byte-identical file, the diff on a pull request shows architecture changes and nothing else: a new external system, a new container, a component that started importing another. Avoid generators that put timestamps or random ordering in the file.
  3. Regenerate on every push. In CI, run the generator after tests and commit or upload the result. With hosted repowise on Pro, the index rebuilds within about 30 seconds of a push and the Structurizr export is rebuilt from it on request. With the open-source CLI, repowise update after each commit (there is an optional post-commit hook) followed by the export command does the same on your own machine, with no model and no API key involved. To see the result locally, export with --standalone --output arch/ and render it with docker run --rm -p 8080:8080 -v .:/usr/local/structurizr structurizr/structurizr local.

Then do the human part once: rename the generic actors, add the protocols and the deployment view, and write an ADR (an architecture decision record, a short note of why a choice was made) for anything surprising the generated model shows you, like a declared dependency the code never imports.

How we checked

  • httpx model: GET https://api.repowise.dev/snapshots/eef9d52b5432/c4/structurizr?standalone=true&components=true, fetched 6 October 2026, snapshot of encode/httpx at commit b5addb6 (indexed 3 October 2026).
  • The Trio and anyio claims: read from httpx at that commit (httpx/_transports/asgi.py, a search of httpx/ for anyio) and from httpcore's pyproject.toml and _backends/ on its default branch.
  • Third-party tools: from their own docs and repos. I did not run go-structurizr or Sharpitect for this post, so their rows describe what their documentation says, not what I measured.
  • httpx is a single library with one container; a repo with several deployable services shows the container level more fully.
  • repowise export behavior: from docs/architecture/structurizr-export.md and the CLI reference in the repowise repository, release 0.56.

FAQ

Can you generate C4 diagrams from code automatically?
Yes, for the context, container and component levels. A parser can find deployable units from package manifests, components from directories and import clusters, and external systems from imports, then write Structurizr DSL, Mermaid or PlantUML. It cannot infer runtime protocols or deployment, so plan to add those by hand.
What are the alternatives to the C4 model?
Common ones are arc42 (a documentation template that often uses C4 diagrams inside it), UML component and deployment diagrams, the 4+1 view model, and plain boxes-and-lines diagrams with a legend. In practice most teams keep C4 for structure and add ADRs for the reasons behind it, because C4 itself says nothing about why.
How do C4 diagrams and ADRs fit together?
C4 shows what the system looks like; an ADR records why a part of it looks that way. Structurizr can hold decision records next to the model in the same workspace. A generated model is a good prompt for ADRs: every surprise it shows you (an unexpected dependency, an import in the wrong direction) is either a bug or a decision someone should write down.
Can Structurizr generate diagrams from code?
Not by itself. Structurizr renders a model you give it, written in its DSL or built with a client library. To get the model from code you need a generator in front of it: a language-specific one like go-structurizr for Go, an agent prompted to write DSL, or a parser-based tool such as repowise (repowise export --format structurizr) that emits a Structurizr DSL file.
Is Mermaid good enough for C4 diagrams?
For a few diagrams inside a README or PR, yes, and it renders on GitHub. Mermaid's C4 support is labelled experimental and each diagram is a separate file, so there is no single model behind several views. For more than a handful of diagrams, a Structurizr DSL model with views generated from it is easier to keep consistent.
How often should I regenerate architecture diagrams?
On every merge to your main branch, in CI. Once generation is deterministic, the regenerated file only changes when the architecture does, so frequent regeneration costs nothing and the diff on each pull request becomes a small architecture review.