C4 diagrams
Day 28: C4 Diagrams, Written for the Reader Who Forgets Everything
Your coding agent has never seen your architecture diagram. It's a PNG on the wiki, or it's in the head of whoever drew it. Either way, the next reader of your code can't see it. In 2026 that next reader is usually an agent, and it starts every session knowing only what's written down. Today you'll learn the C4 model's four zoom levels and how to write them as a plain-text file in your repo, so people and agents work from the same map.
The hook: correct code, wrong architecture
We kept seeing the same failure. Someone asks an agent to "send the customer an email when their invoice fails." The agent reads the code nearby and finds the billing database connection string in a shared config. Then it writes a tidy query from the notification service straight into the billing tables. Tests pass. The code is clean. If you only read the diff, the PR looks fine.
The architecture says otherwise. Billing owns its data, and every other service hears about billing through events like invoice.payment_failed on the queue. The agent didn't break that rule on purpose. It never knew the rule existed, because nothing in the repo mentioned it. The code was correct. The architecture it was written against was wrong.
Where this fits in the course
Days 1–27 were about making decisions: drawing service boundaries, choosing sync or async integration, deciding who owns which data, and recording the trade-offs in ADRs. Today is about communicating those decisions. A boundary only protects you if the next person or agent touching the code can see it. C4 is the most practical shared vocabulary I know for making boundaries visible.
C4 in five minutes
Simon Brown built the C4 model on one idea: architecture diagrams should work like maps at different zoom levels. A country map doesn't show street names, and a street map doesn't show national borders. Most bad architecture diagrams mix zoom levels, so the AWS account, three classes and a user icon all end up in one picture. C4 fixes that with four levels:
- ▹Level 1, System Context: your system as one box, plus the people and external systems it talks to. It answers "what is this and what is around it?" A non-engineer should be able to read it.
- ▹Level 2, Container: the separately deployable or runnable things inside your system, such as web apps, APIs, workers, databases, queues and serverless functions. ("Container" has nothing to do with Docker here. It means a runtime boundary.) It answers "what runs where, and how do the parts talk?"
- ▹Level 3, Component: the major modules inside one container and what each is responsible for. It answers "where in this codebase does X live?"
- ▹Level 4, Code: classes, functions and tables. You can usually skip this one. Your IDE or your agent can generate it when you need it, and a hand-drawn version goes stale within a week.
One example runs through all four levels: Ledgerly, a small SaaS billing product. It manages subscriptions, issues invoices, collects payments through Stripe and tells customers what happened.
Level 1: System Context for Ledgerly
Notice what isn't there. No databases, no microservices, no technology inside Ledgerly. The Context level is only about the edges of the system and who sits on them.
Level 2: Containers inside Ledgerly
That dotted arrow is the bug from the hook. Drawing a forbidden edge isn't standard C4 notation, but I recommend it anyway. The edges you don't want are often more important than the ones you do.
Level 3: Components inside the Billing API
- ▹Subscriptions (src/subscriptions): plan changes, seat counts, proration. Writes the subscription tables.
- ▹Invoicing (src/invoicing): builds and finalizes invoices. Once an invoice is finalized it can't change. Corrections go through credit notes.
- ▹Payments Adapter (src/payments/stripe): the only module allowed to call Stripe. It handles idempotency keys and retries.
- ▹Webhook Handler (src/webhooks): checks Stripe signatures and converts webhook payloads into internal commands.
- ▹Outbox Publisher (src/outbox): the only route from a billing state change to the event bus. Events are written to the outbox table in the same transaction as the business change.
What Level 3 leaves out matters too. Utils, DTOs, middleware and every other folder are missing on purpose. It only lists modules where ownership or a boundary matters.
The two rules beginners miss
Most C4 diagrams I review break the same two rules. With an LLM as the reader, that costs you even more:
- ▹Rule 1: every box gets a name, a technology and a one-line responsibility. A box that just says "Service" tells the reader nothing.
- ▹Rule 2: every arrow says what it carries and over what protocol. An unlabelled arrow leaves too many questions open: which way the data flows, whether the call is sync or async, and who depends on whom.
BAD
[Notifications] ----> [DB]
GOOD
[Notification Service | Python worker | Turns billing events into customer emails]
<-- consumes invoice.payment_failed, invoice.paid (JSON over SQS) --
[Event Bus | SQS+SNS | Carries billing domain events]
Notification Service --X--> Billing DB (forbidden: billing data is read via events only)The bad version practically asks for the hook bug. "DB" could be any database, and the arrow makes a direct connection look normal. The good version tells any reader, human or agent, exactly how notifications are meant to get billing data.
The twist: who reads this now?
C4 was usually pitched as onboarding material for new hires. In practice a new hire reads the diagram once, asks a colleague about the rest, and has a working mental model within a few weeks. After that, the diagram is mostly wall art.
A coding agent never gets past onboarding. It reads the codebase fresh on every task and builds its picture of the system from whatever fits in its context window: open files, grep results, and any instruction or memory files you've set up. Last week's review comment saying notifications must never touch billing? Gone, unless someone wrote it somewhere the agent loads. Without a written architecture, the agent infers one from the code. And code shows what's possible (a connection string it can reach), not what's allowed.
Format matters as well. A PNG exported to Confluence is invisible to the agent. Even if you paste the image in, a model reading boxes and arrows from pixels is less reliable than one reading a few clear sentences. Text in the repo is what agents actually use. So the agent turns out to be the reader C4 was always missing: it needs the map on every single task, and it never keeps it.
How-to: write C4 as ARCHITECTURE.md
Put one ARCHITECTURE.md at the repo root. If your system spans several repos, put it in a shared architecture repo. Keep it short enough that loading it on every task is cheap, which for most systems means a few hundred lines at most. This is the template we use:
# ARCHITECTURE.md — Ledgerly
> C4 levels 1-3 as text. If you change a boundary, update this file in the same PR.
## 1. System Context
Ledgerly manages SaaS subscriptions, invoicing and payment collection.
- Users: Customer Admin (browser), Finance Ops (browser)
- External systems:
- Stripe: card payments. We call REST; Stripe calls our webhooks.
- Email provider: transactional email via REST. ONLY Notification Service calls it.
- Identity provider: OIDC login.
- Accounting system: nightly export of posted invoices.
- We do NOT integrate with: any CRM, any tax engine (tax is computed in-house),
any second payment provider. Do not add one without an ADR.
## 2. Containers
| Container | Tech | Owner | Responsibility | Talks to (protocol) |
|----------------------|-----------------|--------------|--------------------------------------|-----------------------------------------|
| web | Next.js | team-portal | Admin + finance UI | billing-api (JSON/HTTPS) |
| billing-api | Node/TypeScript | team-billing | Owns subscriptions, invoices, payments | billing-db (SQL), event-bus (publish), Stripe (REST) |
| billing-db | PostgreSQL | team-billing | Source of truth for billing data | - |
| event-bus | SQS + SNS | platform | Billing domain events | - |
| notification-service | Python worker | team-growth | Events -> customer emails | event-bus (consume), notification-db (SQL), Email provider (REST) |
## 3. Key Components (only where boundaries matter)
### billing-api
- src/payments/stripe: ONLY module that calls Stripe.
- src/outbox: ONLY path from billing state changes to event-bus (transactional outbox).
- src/invoicing: finalized invoices are immutable; use credit notes.
## 4. Forbidden edges (Does NOT talk to)
- notification-service -X-> billing-db. Read billing data via events or billing-api.
- web -X-> billing-db. Always go through billing-api.
- Anything except src/payments/stripe -X-> Stripe API.
- No service publishes billing events except billing-api/src/outbox.
## 5. Need something that isn't here?
If a task seems to need a new edge, STOP and propose it in the PR description.
Do not create the integration.The forbidden-edges section and the "we do NOT integrate with" list aren't part of classic C4. In our experience they change agent behaviour more than anything else in the file. Agents rarely break a rule that's written down. They make architecture mistakes by filling gaps. So close the gaps on purpose.
If you also want rendered diagrams, use Structurizr DSL. It's Simon Brown's text format for describing a C4 model once and rendering every view from that one source. It stays diffable, you can review it in PRs, and agents read it well. Here's a short taste:
workspace "Ledgerly" {
model {
admin = person "Customer Admin"
stripe = softwareSystem "Stripe" "Card payments" "External"
ledgerly = softwareSystem "Ledgerly" {
api = container "Billing API" "Owns subscriptions, invoices, payments" "Node/TypeScript"
db = container "Billing DB" "Billing source of truth" "PostgreSQL" "Database"
bus = container "Event Bus" "Billing domain events" "SQS+SNS"
notif = container "Notification Service" "Events to customer emails" "Python"
}
admin -> api "Manages subscription" "JSON/HTTPS"
api -> db "Reads/writes" "SQL"
api -> bus "Publishes invoice.* events" "SNS"
bus -> notif "Delivers events" "SQS"
api -> stripe "Creates charges" "REST/HTTPS"
}
views {
systemContext ledgerly {
include *
}
container ledgerly {
include *
}
}
}My advice: start with ARCHITECTURE.md. Switch to Structurizr DSL only when people start asking for pictures, and even then keep the forbidden edges in Markdown. The DSL has no natural place to say "never do this," and that's the part agents need most.
What changed at PhoenixDX after we added it
I can't give you a percentage, because we didn't run a controlled experiment. This is what we saw in reviews once the file existed and our agent instructions pointed to it:
- ▹Fewer cross-service edits in the wrong place. When a requested change crossed a boundary, agents started editing the service that owned the behaviour, or they flagged that the change belonged in another repo. Before that, they patched whichever repo happened to be open.
- ▹Far fewer made-up integrations. Agents used to call internal endpoints that didn't exist, or add a client library for some third-party service we don't use, because it looked plausible. The "we do NOT integrate with" list and the forbidden edges cut that down sharply.
- ▹Better PR descriptions. Agents began writing things like "this touches the billing-api → event-bus edge," and that sped up review for the humans too.
What it didn't fix:
- ▹Logic bugs inside a component. The map tells an agent where code belongs. It doesn't tell it whether the proration maths is right. You still need tests and review.
- ▹Drift. The first time someone moved a responsibility between services and didn't update the file, the agent followed the stale map with total confidence. A wrong map is worse than no map, because it adds false certainty.
Keeping it alive
- ▹Link it from CLAUDE.md / AGENTS.md with a direct instruction, for example: "Before any change that touches more than one container, read ARCHITECTURE.md and respect section 4." Don't paste the whole architecture into CLAUDE.md. That's how CLAUDE.md files bloat until nobody maintains them, and then they stop working.
- ▹Update it in the same PR that changes a boundary. Add this to your PR template: "Does this add, remove or move a container or an edge? If yes, ARCHITECTURE.md is updated." Reviewers should reject boundary changes that skip it.
- ▹Keep Level 3 to ownership and boundaries. For each line, ask: "would an agent do damage without knowing this?" If not, cut it. A Level 3 that lists every folder turns into noise, and agents skim it just like people do.
- ▹Treat Level 4 as generated, on demand. Ask the agent for a class or call graph of the module it's working in. Never commit hand-maintained Level 4 diagrams.
- ▹Give every container an owner in the table. When the file is wrong, everyone knows who fixes it.
When NOT to bother, and common pitfalls
- ▹Skip it for a single-container app with no external integrations. A short paragraph in README.md does the job.
- ▹Pitfall: mixing zoom levels. Stripe, a React hook and an RDS instance should not share one picture.
- ▹Pitfall: unlabelled arrows and boxes with no technology. They break Rules 1 and 2, and agents fill the blanks with guesses.
- ▹Pitfall: documenting the system you wish you had. If the Notification Service really does read the billing DB today, write that down, mark it as known debt and give the target state. The first time an aspirational map disagrees with the code, people and agents both stop trusting it.
- ▹Pitfall: keeping the only copy in a drawing tool. If it isn't text in the repo, assume the agent can't see it.
Exercise for today (30 minutes)
- ▹Pick a system you work on and set a 30-minute timer.
- ▹Write Level 1: one system, its users, its external systems, and one labelled arrow (what plus protocol) per relationship. Add a "we do NOT integrate with" line.
- ▹Write Level 2 as a table: container, technology, owner, responsibility, talks to (protocol). Add at least two forbidden edges that reviewers already enforce informally.
- ▹Pick a cross-cutting task, one that touches two containers, such as "notify users when X happens in service Y."
- ▹Run that task with your coding agent twice, each in a fresh session: once without the file, and once with ARCHITECTURE.md linked from CLAUDE.md/AGENTS.md.
- ▹Compare the two diffs. Which containers did each run touch? Did either one invent an integration or cross a forbidden edge? Also write down what the agent still got wrong with the file loaded. That's your list of what the map is missing.
Bridge to Day 29
Once the architecture is written down, it becomes a claim about the system, and claims can be checked. The next question is how you notice when the code drifts away from the file: a new edge, a dependency nobody declared, a service quietly reading another service's tables. Day 29 covers detecting architecture drift, including automating some of the forbidden-edge checks so nobody has to remember them.
Extend your knowledge
- ▹Read Simon Brown's c4model.com. Start with the notation guidance and the FAQ on what counts as a container.
- ▹Try Structurizr DSL with Structurizr's local tooling. Render the context and container views of a Ledgerly-style model from one source file.
- ▹For the reasoning behind each level, read Simon Brown's book 'The C4 Model' (O'Reilly) or watch his talk 'Visualising software architecture with the C4 model'.
- ▹Read Aleksey Kladov's (matklad) blog post 'ARCHITECTURE.md', which describes the ARCHITECTURE.md convention. Compare its codemap style with the C4-structured template in this lesson.
Discussion
Chat with Chi Cong (AI) about this article. Your conversation is private to you — you can publish a summary for others when you're done.