Architectural Diagrams
0. Why the non-UML ones won
UML has notation for architecture. Almost nobody uses it, and the reason is not fashion: UML’s architecture diagrams have no concept of zoom level. Every box is a box, so one diagram ends up carrying a load balancer, a Python module and a domain class, and it becomes unreadable at exactly the moment it becomes important.
The notations here each fix that by picking one axis and staying on it.
| Notation | Axis | The question it owns |
|---|---|---|
| C4 | Level of abstraction | “What are the moving pieces, at the zoom level you asked about?” |
| Data flow diagram | Where data goes and which boundaries it crosses | “What could an attacker do here?” |
| Network topology | Addresses, segments, firewall zones | “Can this host actually reach that one?” |
Same running example as the rest of the folder: MeterHub, a service that ingests smart-meter readings over MQTT and bills customers monthly.
1. C4: the four levels
Simon Brown’s C4 model is the pragmatic replacement for most UML architecture diagrams. Its entire contribution is insisting that one diagram holds one zoom level:
| Level | Boxes are | Audience | Redraw when |
|---|---|---|---|
| 1. Context | Your system, its users, the systems it talks to | Anyone, including non-technical | A new external integration appears |
| 2. Container | Separately deployable or runnable things: apps, services, databases, queues | Engineers, ops | A service or datastore is added |
| 3. Component | Major building blocks inside one container | Engineers on that container | Rarely, and only for a container worth explaining |
| 4. Code | Classes | Almost nobody | Never, generate it if you truly need it |
The practical advice is level 1 and level 2, and stop. A context diagram and a container diagram cover nearly every architecture conversation that happens in real teams. Level 3 is worth drawing for the one or two containers with non-obvious internals (see the component diagram section). Level 4 is a waste of an afternoon.
Level 1: system context
The rule is that your entire system is one box. Resist splitting it. The audience for this diagram is the person who does not know what the system does.
C4Context
title MeterHub, system context
Person(customer, "Customer", "Views usage, pays bills")
Person(ops, "Billing operator", "Runs and corrects billing")
System(meterhub, "MeterHub", "Ingests meter readings, prices them, issues invoices")
System_Ext(meters, "Smart meters", "ESP32 firmware in the field, publishes over MQTT")
System_Ext(gateway, "Payment gateway", "Card payments and 3DS")
System_Ext(email, "Email provider", "Transactional email and PDF delivery")
System_Ext(regulator, "Regulator portal", "Monthly compliance submissions")
Rel(customer, meterhub, "Views usage, pays invoices", "HTTPS")
Rel(ops, meterhub, "Runs billing, corrects readings", "HTTPS")
Rel(meters, meterhub, "Publishes interval readings", "MQTT over TLS")
Rel(meterhub, gateway, "Takes payment", "HTTPS")
Rel(meterhub, email, "Sends invoices", "SMTP")
Rel(meterhub, regulator, "Submits report", "SFTP, monthly")
Every arrow leaving that box is an integration someone has to build, monitor and page for. That is the whole value of level 1: it makes the real surface area countable.
Level 2: containers
Now open the box. Each container is something that runs on its own and could, in principle, be restarted independently. Technology choice goes in the second field, and it belongs there: “queue” is not a decision, “Mosquitto” is.
C4Container
title MeterHub, containers
Person(customer, "Customer", "Views usage, pays bills")
Person(ops, "Billing operator", "Runs and corrects billing")
System_Ext(meters, "Smart meters", "ESP32 firmware")
System_Ext(gateway, "Payment gateway", "Card payments")
Container_Boundary(mh, "MeterHub") {
Container(spa, "Customer web app", "React, served by nginx", "Usage charts, invoice list, payment")
Container(api, "Query API", "Python, FastAPI, gunicorn", "Reads, authorisation, payment orchestration")
Container(broker, "MQTT broker", "Mosquitto", "Terminates meter TLS, buffers messages")
Container(ingest, "Ingest service", "Go", "Validates, deduplicates, batch writes")
Container(billing, "Billing service", "Python, systemd timer", "Nightly pricing and invoice issue")
ContainerDb(ts, "Time series store", "TimescaleDB", "Readings hypertable, invoices, customers")
ContainerDb(cache, "Dedupe cache", "Redis", "24 hour dedupe keys, rate limits")
}
Rel(customer, spa, "Uses", "HTTPS")
Rel(ops, spa, "Uses", "HTTPS")
Rel(spa, api, "Calls", "JSON over HTTPS")
Rel(meters, broker, "Publishes readings", "MQTT over TLS, client certs")
Rel(broker, ingest, "Delivers batches", "MQTT, LAN only")
Rel(ingest, cache, "Checks dedupe keys", "RESP")
Rel(ingest, ts, "Batch writes", "COPY, write role")
Rel(api, ts, "Queries", "SQL, read role")
Rel(billing, ts, "Reads and writes invoices", "SQL")
Rel(api, gateway, "Charges card", "HTTPS")
Mermaid’s C4 support is officially experimental and its auto-layout is the weakest part of it. For a diagram you will show to other people, either accept the layout, hand it to Structurizr (C4’s reference tool, which also renders from a text DSL), or draw the same content as a plain flowchart with subgraphs, which is what the deployment diagram in notebook 02 does. The C4 thinking matters far more than the C4 renderer.
Container versus deployment
These two get confused constantly. A container diagram says what the pieces are. A deployment diagram says where those pieces run. One container can be deployed to three hosts, and one host can run five containers, so neither diagram implies the other. Draw both, keep them separate, and cross-reference them.
2. Data flow diagram, and the threat model on top of it
What it is: processes, data stores, external entities and the data moving between them, with trust boundaries drawn as lines that flows cross. Notation predates UML and outlived it, because it is the foundation of STRIDE threat modelling.
When it earns its keep: any security review, any system handling money or personal data, and any compliance conversation. It is also the cheapest security exercise that exists: an hour with a DFD finds more than a week of scanner output.
The one rule: a DFD without trust boundaries is just a worse architecture diagram. The boundaries are the entire point, because every threat lives on a flow that crosses one.
flowchart LR
subgraph tb1["Trust boundary 1: the field, physically unprotected"]
E1(["Smart meter<br/>external entity"])
end
subgraph tb2["Trust boundary 2: internet"]
E2(["Customer browser<br/>external entity"])
end
subgraph tb3["Trust boundary 3: DMZ"]
P1["1.0 Terminate TLS<br/>and authenticate meter"]
P2["2.0 Serve web app"]
end
subgraph tb4["Trust boundary 4: application LAN"]
P3["3.0 Validate and<br/>deduplicate reading"]
P4["4.0 Price and<br/>issue invoice"]
P5["5.0 Authorise<br/>and serve query"]
end
subgraph tb5["Trust boundary 5: data LAN"]
D1[("D1 readings")]
D2[("D2 invoices, customers")]
D3[("D3 dedupe keys")]
end
E3(["Payment gateway<br/>external entity"])
E1 -->|"a. reading, MQTT TLS"| P1
P1 -->|"b. reading, plaintext MQTT"| P3
P3 -->|"c. dedupe key"| D3
P3 -->|"d. validated reading"| D1
D1 -->|"e. interval data"| P4
P4 -->|"f. invoice, PDF"| D2
E2 -->|"g. session, HTTPS"| P2
P2 -->|"h. API call, JWT"| P5
P5 -->|"i. query"| D1
P5 -->|"j. invoice fetch"| D2
P5 -->|"k. charge request"| E3
E3 -->|"l. webhook, payment result"| P5
classDef ext fill:#742a2a,stroke:#e53e3e,color:#fff
classDef store fill:#2b6cb0,stroke:#1a365d,color:#fff
class E1,E2,E3 ext
class D1,D2,D3 store
STRIDE, applied to the flows above
STRIDE assigns candidate threats by element type. Processes can suffer all six. Data stores mostly suffer tampering, information disclosure, repudiation and denial of service. Data flows suffer tampering, disclosure and denial of service. External entities suffer spoofing and repudiation.
| Flow or element | Threat (STRIDE) | Concrete version | Mitigation |
|---|---|---|---|
a meter to DMZ |
Spoofing | Anyone can publish readings claiming to be NMI123 | Per-meter client certificates, broker ACL scoped to the meter’s own topic |
b DMZ to app LAN |
Tampering, Info disclosure | Plaintext MQTT inside the LAN, readable by anything on that segment | mTLS internally, or accept it and document the segment as trusted |
d, D1 |
Tampering | An altered reading changes a bill and nobody can tell | Append-only readings table, corrections as new rows with a reason code |
e to 4.0 |
Repudiation | Customer disputes a bill, no record of which readings priced it | Store the reading ids and tariff version on every invoice line |
5.0 query |
Elevation of privilege | Customer A reads customer B’s usage by changing an id | Authorisation on every row, tested, not just on the route |
l webhook |
Spoofing | Forged payment success marks an invoice settled | Verify gateway signature, confirm against the gateway API before settling |
1.0 broker |
Denial of service | One misconfigured meter floods the broker | Per-client rate limits, queue depth alarms |
D3 dedupe cache |
Denial of service | Cache eviction lets duplicate readings through | 25 hour TTL sized above the retry window, monitor eviction count |
Notice how mechanical that is. Walk each flow, ask the six questions, write down the ones that are not already handled. The diagram exists so the walk is exhaustive rather than a memory test.
Number the processes (1.0, 2.0) and letter the flows (a, b). The numbering is what lets a threat table, a code review comment and a penetration test report all point at the same thing.
3. Network topology diagram
What it is: subnets, VLANs, firewall zones, addresses and the physical or virtual links between them. The most literal diagram in the set, and the one that ages best, since IP addresses either are or are not correct.
When it earns its keep: connectivity debugging, firewall change reviews, onboarding anyone who will be paged, and any audit. It is the answer to “can this host reach that one”, which is a question no amount of application-level architecture can settle.
What must be on it: addresses or CIDR ranges, VLAN identifiers, the direction and port of each allowed flow, and where the default deny sits. A topology diagram without ports is a wiring picture.
flowchart TB
NET(["Internet"])
NET -->|"WAN"| FW["Edge firewall / router<br/>NAT, WireGuard endpoint :51820"]
subgraph vlan10["VLAN 10, DMZ, 192.168.10.0/24"]
RP["reverse-proxy 192.168.10.10<br/>in: 443/tcp, 8883/tcp"]
end
subgraph vlan20["VLAN 20, servers, 192.168.2.0/24"]
PVE["Proxmox host 192.168.2.70<br/>mgmt 8006/tcp, admin VLAN only"]
APP["meterhub CT 192.168.2.205<br/>in: 8000/tcp, 9001/tcp from VLAN 10"]
DB["data CT 192.168.2.206<br/>in: 5432/tcp, 6379/tcp from 192.168.2.205 only"]
end
subgraph vlan30["VLAN 30, IoT, 192.168.30.0/24"]
MET["Meter gateways<br/>egress 8883/tcp only, no lateral traffic"]
end
subgraph vlan99["VLAN 99, admin, 192.168.99.0/24"]
ADM["Admin workstation<br/>via WireGuard"]
end
FW -->|"443, 8883 dnat"| RP
RP -->|"8000/tcp"| APP
RP -->|"1883/tcp"| APP
APP -->|"5432, 6379"| DB
MET -->|"8883/tcp egress"| FW
ADM -->|"22/tcp, 8006/tcp"| PVE
ADM -->|"22/tcp"| APP
FW -.->|"default deny<br/>between all VLANs"| vlan20
classDef untrusted fill:#742a2a,stroke:#e53e3e,color:#fff
classDef mgmt fill:#276749,stroke:#9ae6b4,color:#fff
class NET,MET untrusted
class ADM,PVE mgmt
The rule this diagram encodes: IoT gets egress only. Meters can reach the broker port and nothing else, so a compromised meter cannot scan the server VLAN. That is one line on a firewall and one edge on a picture, and without the picture nobody notices when it is removed.
Keep the topology diagram next to the infrastructure code that creates it (Terraform, Ansible), not in a network binder. When the two disagree, the code is right and the diagram is a bug report.
4. Architecture description is more than diagrams
Diagrams answer what. They are poor at why, and why is what the next engineer needs.
Architecture Decision Records. One short markdown file per decision, numbered, immutable once accepted, superseded rather than edited:
# ADR-014: TimescaleDB for interval data
## Status
Accepted, 2026-03-11. Supersedes ADR-009.
## Context
Readings arrive at roughly 8k rows per second and are queried as ranges per meter.
Plain Postgres held up in load tests until about the six month mark, when index
bloat on the readings table pushed p99 range queries past two seconds.
## Decision
Use TimescaleDB hypertables partitioned by week, with continuous aggregates for
the hourly and daily rollups the customer charts read.
## Consequences
Positive: range queries stay under 100ms at eighteen months of data, and
compression cuts storage by about 90 percent.
Negative: the extension pins our Postgres upgrade path, and managed hosting
options narrow considerably. Revisit if we move off self-hosting.Pair the ADRs with a C4 container diagram and you have covered what most teams need. If you want a fuller template, arc42 is the usual choice, and the 4+1 view model is the older idea it descends from: logical, process, development, physical views, plus scenarios that tie them together.
A practical minimum for a service repository
- A context diagram in the README, so a newcomer sees the boundary in ten seconds.
- A container diagram in the README, kept current, since it changes only a few times a year.
- A deployment diagram wherever the runbook lives.
- ADRs in
docs/adr/, numbered, never rewritten. - A DFD with trust boundaries if the system touches money or personal data.
Everything else is drawn on demand, for one conversation, and thrown away afterwards. That is a legitimate use of a diagram, and arguably the most common good one.