C4 Modelling for Distributed Systems: Structure, Decisions and Limits
Use C4, ADRs and versioned diagram sources to maintain a structural map of a distributed system~then add the behavioural and operational evidence it cannot provide alone.
Architecture diagrams tend to fail in one of two ways: a broad picture is preserved long after it stopped being useful, or every team keeps a detailed but incompatible version. The hard part is not drawing boxes. It is agreeing which question a diagram answers, who keeps it current, and what evidence it does not provide.
The C4 model offers a useful hierarchy for that first part: Context, Container, Component and optional Code views. Paired with Architecture Decision Records (ADRs) and diagram sources kept with the repository, it can form maintainable structural documentation for a distributed system. It is not a complete, self-validating account of runtime behaviour. Dynamic and Deployment views, contracts, tests, runbooks and operational evidence still matter.
This article uses a Mesh-Sync-inspired 3D-processing design as an illustrative example, not a claim about a deployed system or public decision record. The examples are deliberately static and simplified; C4 is notation-independent, so Mermaid is a rendering choice rather than the model itself.
Start with the reader’s question
The same system needs different views for different readers:
| Reader question | Smallest useful C4 view |
|---|---|
| What is this system, and which outside parties does it depend on? | Context |
| Which applications and data stores collaborate? | Container |
| Where should I change pipeline validation or callback handling? | Component |
| How does one critical dispatch abstraction fit together? | Selected conceptual Code view |
C4 supplies these abstractions and views. It does not assign owners, review pull requests or prove that a diagram matches production. Those are documentation practices a team must establish around the model.
Level 1: Context ~ who and what crosses the boundary?
A System Context view treats the system of interest as one software-system boundary and shows people and external software systems around it. Here, an API client is software, not a person. “External identity providers” is also more precise than suggesting that OAuth itself is an identity provider: OAuth 2.0 is an authorisation framework, while OpenID Connect adds an identity layer on top of it (OAuth 2.0, OpenID Connect).
graph TB
Artist([Person: 3D artist<br/>uploads and reviews models])
Admin([Person: platform administrator<br/>monitors processing])
Client[External software system: API client<br/>integrates through HTTPS API]
subgraph system[Software system: Mesh-Sync-inspired platform]
MS[Processes 3D models and exposes business state]
end
S3[External software system: S3-compatible durable object storage<br/>uploaded models and durable results]
IdP[External software system: identity providers<br/>OIDC authentication / OAuth 2.0 authorisation]
Artist -->|uses HTTPS| MS
Admin -->|uses HTTPS| MS
Client -->|calls HTTPS API| MS
MS -->|stores and retrieves objects| S3
MS -->|delegates authentication and authorisation flows| IdP
The scope is the platform, not its internal services. This view can help a stakeholder discuss integration and responsibility boundaries, but it cannot show a pipeline’s order of operations, availability, or security controls in enough detail to validate them.
Level 2: Container ~ applications and data stores, not deployment
A C4 Container view shows the applications and data stores inside a software system and their relationships. “Container” is an architectural abstraction, not a Docker container, and it does not answer “what runs where”. A Deployment view maps instances of those containers to infrastructure and environments.
The following is a static Container view. Labels provide the relationship and technology; the prose is its compact legend.
graph TB
Client[External API client]
S3[External S3-compatible object storage<br/>durable uploaded models and results]
subgraph system[Software system: Mesh-Sync-inspired platform]
API[NestJS Backend<br/>business API and authoritative business-state owner]
Orchestrator[Worker Backend<br/>pipeline orchestration and callback validation]
Queue[(Redis / BullMQ<br/>queued work and queue/lock state)]
Cache[(MinIO<br/>derived pipeline cache)]
DB[(PostgreSQL<br/>authoritative business state)]
Search[(Elasticsearch<br/>search and observability index)]
Workers[Worker runtimes<br/>model-processing stages]
end
Client -->|HTTPS: submit pipeline / read status| API
API -->|authenticated pipeline command/API call| Orchestrator
API -->|SQL: reads and writes business state| DB
Orchestrator -->|enqueues jobs| Queue
Workers -->|claims/polls queued jobs| Queue
Workers -->|reads artefacts and writes durable results| S3
Workers -->|signed result callback| Orchestrator
Orchestrator -->|authenticated business-state command| API
Orchestrator -->|reads/writes derived cache entries| Cache
Orchestrator -->|publishes operational/search records| Search
Several choices are visible, but none is automatically justified by the arrows:
- The NestJS Backend is the sole shown writer of authoritative business state in PostgreSQL. A worker callback is received and validated by the Worker Backend, which then issues a business-state command to the NestJS Backend; the Worker Backend is not the database writer.
- The initial call from the NestJS Backend starts or changes a pipeline. A callback is a later result from a worker. Naming both “webhooks” would hide a useful direction and trust-boundary distinction.
- S3-compatible storage is durable storage for submitted artefacts and results. MinIO is a separate, disposable derived-data cache in this example; conflating the two would make recovery requirements unclear.
- BullMQ is the queue mechanism. Workers claim or poll queued work rather than Redis pushing work into them. The diagram intentionally does not prescribe how a non-TypeScript runtime would implement a BullMQ-facing adapter; that cross-language boundary needs an explicit compatibility contract.
- Redis is dedicated to BullMQ queue and lock state in this illustration; MinIO holds the derived cache. Redis reduces component count, but it also creates a shared failure, capacity and configuration domain for queue and lock state. BullMQ’s production guidance calls for a no-eviction policy, so persistence configuration and capacity need deliberate planning.
Separate dependencies describe intended isolation; they do not prove resilience. Stateless workers can make horizontal scaling easier, subject to capacity, backpressure, resource limits and idempotent work.
The asynchronous path needs a Dynamic view
The static hierarchy says what may communicate. It does not say which action happens first, what is durable before an acknowledgement, or how a late callback is treated. C4’s Dynamic view is a compact complement for such a path.
sequenceDiagram
participant C as API client
participant A as NestJS Backend<br/>business-state owner
participant O as Worker Backend<br/>orchestrator
participant Q as Redis / BullMQ
participant W as worker runtime
participant S as durable object storage
participant P as PostgreSQL
C->>A: submit pipeline
A->>O: authenticated pipeline command
O->>Q: enqueue job
O->>O: confirm configured and tested queue persistence/loss guarantee
O-->>A: accept only after required queue-state guarantee is met
W->>Q: claim or poll job
W->>S: read input; write durable result
W->>O: signed callback with job and attempt identity
O->>O: verify and deduplicate callback
O->>A: authenticated business-state command
A->>P: durable state transition
A-->>O: acknowledgement
O->>Q: acknowledge or schedule retry
This is an intended interaction, not proof that the system has those guarantees. A design for it needs explicit answers to the awkward cases:
- Job delivery and callbacks may be duplicated, delayed or arrive out of order. Jobs and callbacks therefore need idempotency identities and state transitions that tolerate repeat attempts. BullMQ documents both retries and idempotent jobs; the application still owns the business-level semantics.
- Queue acceptance is durable only when BullMQ/Redis persistence and recovery behaviour are explicitly configured and tested for the design’s required loss guarantee. Otherwise, the orchestrator must not claim durability.
- “Success” should follow durable acceptance of the relevant transition, not merely receipt of an HTTP request. Retry/backoff, poison-work handling, and a crash between an acknowledgement and the next durable action need a recovery story.
- A timeout can race a late successful callback. The durable business state is the source of truth for resolving that race, with attempt numbers, deadlines or transition rules chosen deliberately.
- An outbox/inbox pattern, or an equivalent durable transition mechanism, is one option where a command, persistence update and acknowledgement must be coordinated. Its presence should be documented only if the implementation actually uses it.
For untrusted 3D inputs, worker isolation also needs a separate threat and resource plan: sandboxing, file-size and format limits, CPU/memory/time budgets, and safe object access are operational constraints, not properties supplied by C4.
Callback boundary, briefly
For a simple callback scheme, use TLS and an HMAC over the exact received request body plus a defined timestamp representation. Verify it with a vetted constant-time verification library; enforce timestamp and replay controls; rotate secrets; carry an idempotency key; and make acceptance durable before reporting success. This raw-body HMAC callback scheme is not RFC 9421.
Level 3: Component ~ cohesive responsibilities inside one container
A Component view is useful when a reader needs to understand the internals of a particular container. Components are cohesive units of responsibility behind interfaces, not a catalogue of classes. The following view scopes itself to the illustrative Worker Backend.
graph TB
API[NestJS Backend]
Workers[Worker runtimes]
Queue[(Redis / BullMQ)]
Cache[(MinIO cache)]
Search[(Elasticsearch)]
subgraph wb[Container: Worker Backend ~ pipeline orchestration]
Entry[Pipeline command endpoint<br/>accepts authenticated start/change commands]
Validate[Pipeline validator<br/>schema, semantic and interpolation validation]
Plan[Dependency planner<br/>builds executable stage plan]
Coordinate[Stage coordinator<br/>schedules and tracks stage attempts]
QueueGateway[Queue gateway<br/>writes jobs and schedules retries]
Callback[Callback receiver<br/>verifies, deduplicates and normalises results]
Dispatch[Internal command dispatcher<br/>routes one command to one handler]
StateClient[Business-state client<br/>sends authenticated commands to API]
CacheManager[Cache manager<br/>manages derived entries]
Timeout[Timeout and retry scheduler<br/>finds expired attempts]
Observe[Operational publisher<br/>emits records for observability/search]
end
API -->|pipeline command| Entry
Entry --> Validate --> Plan --> Coordinate
Coordinate --> QueueGateway -->|enqueue/schedule| Queue
Coordinate --> CacheManager -->|cache R/W| Cache
Workers -->|signed result callback| Callback
Callback --> Dispatch --> StateClient
StateClient -->|business-state command| API
Timeout -->|reschedule or mark attempt| Coordinate
Timeout -->|inspect queue state| Queue
Entry --> Observe
Callback --> Observe
Timeout --> Observe
Observe -->|publish records| Search
| Component | Responsibility |
|---|---|
| Pipeline command endpoint | Authenticates and accepts pipeline commands before handing off orchestration. |
| Pipeline validator | Checks the schema, semantics and safe interpolation assumptions before work is queued. |
| Dependency planner and stage coordinator | Turn valid definitions into ordered, observable stage attempts. |
| Queue gateway | Encapsulates enqueue, retry and scheduling operations. |
| Callback receiver | Verifies signed worker callbacks, detects duplicates and normalises accepted results. |
| Internal command dispatcher and handlers | Route an imperative internal command to its single responsible handler. |
| Business-state client | Sends state-change commands to the NestJS Backend; it does not write PostgreSQL. |
| Cache, timeout and operational services | Handle derived cache entries, expiry/retry checks and published operational records respectively. |
This view does not establish a particular design-pattern label or prove that all stages use every service. It gives maintainers a place to test and review such responsibilities without mixing them with infrastructure deployment detail.
Level 4: Code ~ selected, conceptual detail
Code views are optional (C4 guidance). Create one only when classes, interfaces or a module boundary answer a real reader question. Here, the commands are intentionally imperative: UpdateModelStatus asks for a state change, so calling it an event would be misleading. The Mermaid diagram is conceptual, not runnable TypeScript.
classDiagram
class InternalCommand {
<<interface>>
+type: string
+correlationId: string
+idempotencyKey: string
}
class CommandResult {
+accepted: boolean
+stateVersion: string
}
class CommandHandler {
<<interface>>
+handle(command: InternalCommand) Promise~CommandResult~
}
class InternalCommandDispatcher {
-handlers: Map~string, CommandHandler~
+register(commandType: string, handler: CommandHandler) void
+dispatch(command: InternalCommand) Promise~CommandResult~
}
class UpdateModelStatusCommand {
+type: "model.status.update"
+modelId: string
+newStatus: string
}
class SaveTechnicalMetadataCommand {
+type: "model.technical_metadata.save"
+modelId: string
+metadata: object
}
class UpdateModelStatusHandler {
-client: BusinessStateClient
+handle(command: InternalCommand) Promise~CommandResult~
}
class SaveTechnicalMetadataHandler {
-client: BusinessStateClient
+handle(command: InternalCommand) Promise~CommandResult~
}
class BusinessStateClient {
+send(command: InternalCommand) Promise~CommandResult~
}
InternalCommand <|.. UpdateModelStatusCommand
InternalCommand <|.. SaveTechnicalMetadataCommand
CommandHandler <|.. UpdateModelStatusHandler
CommandHandler <|.. SaveTechnicalMetadataHandler
InternalCommandDispatcher --> CommandHandler : dispatches to one handler
UpdateModelStatusHandler --> BusinessStateClient : sends command
SaveTechnicalMetadataHandler --> BusinessStateClient : sends command
One reasonable policy is to keep only selected, critical conceptual Code views in the repository, with a named container owner. Other code diagrams can be generated for investigation and discarded. That is more honest than claiming every Code view is both generated and permanently maintained.
ADRs record decisions, not every arrow
C4 answers “what is the intended structure?” An ADR records why an architecturally significant choice was made, its status and consequences, and whether it was later superseded. That is the purpose of the original ADR practice, not a reason to create an ADR for every connection in a diagram.
The identifiers below are an illustrative mapping, not links to real Mesh-Sync records:
| C4 element or concern | Illustrative ADR | Decision to capture |
|---|---|---|
| Worker callback → business-state command → PostgreSQL | ADR-010 | Single-writer persistence via authenticated callbacks, including failure and idempotency consequences. |
| Redis / BullMQ | ADR-003 | Queue mechanism, retry policy and the shared Redis capacity/failure trade-off. |
| S3 durable storage versus MinIO cache | ADR-007 | Recovery and retention boundary for artefacts and derived data. |
| Selected command dispatcher | ADR-012 | Why commands have one handler and how notifications fan out elsewhere, if needed. |
An illustrative ADR is short enough to review:
# ADR-010: Route worker results through the business-state API
## Status
Proposed
## Context
Workers finish asynchronous stages and must report results without owning
the business database schema. Callbacks can be repeated, delayed or rejected.
## Decision
Validate a signed callback in the Worker Backend, then send an authenticated,
idempotent business-state command to the NestJS Backend. Only that backend
writes authoritative business state to PostgreSQL.
## Consequences
- Worker/database schema coupling is reduced.
- Callback and command idempotency, durable acceptance and retry behaviour
become explicit responsibilities.
- The extra network boundary needs contract tests and operational visibility.
Keeping the map useful
Repository-managed Mermaid keeps a proposed structural map close to the change that might invalidate it, but it is not magic. A practical ownership and review policy can be small:
| Artefact | Suggested owner and review trigger |
|---|---|
| Context and Container views | Architecture or platform owner; review when a boundary, integration, deployable unit or durable store changes. |
| Component and selected Code views | Owning service team; review with significant internal responsibility or interface changes. |
| ADRs | Decision owner; update status and supersession when the decision changes. |
Label views current or target when they describe different states. In a multi-repository system, version the diagram source and contracts with the participating interfaces; a diagram in one repository cannot silently stand in for another repository’s release state.
CI and review can validate Astro frontmatter, render Mermaid with the production-pinned renderer version, check links and ADR identifiers, and optionally compare named deployable units with manifests. Automation can catch syntax and referential drift. It cannot prove runtime truth, architectural intent or an undeclared operational dependency.
Validation should also exercise the boundary the diagrams describe: schema and contract compatibility across TypeScript/Python boundaries; duplicate, delayed and reordered callbacks; crashes at acknowledgement boundaries; Redis, object-store and search outages; timeout-versus-late-success races; and rolling-version compatibility.
Choose the source format deliberately
| Approach | Strength | Limitation |
|---|---|---|
| Mermaid in Markdown | Low friction and reviewable beside prose. | Weak semantic consistency unless conventions and review enforce it. |
| Structured model/DSL such as Structurizr DSL | Stronger reuse and consistency checks. | More tooling and governance to learn and maintain. |
| Generated or runtime topology | Fresh evidence about observed connections. | Often noisy and unable to capture intended boundaries or decision rationale alone. |
What C4 should not hide
Avoid treating the hierarchy as a mandatory four-diagram set. A Context and Container view may be sufficient for one reader; a Component or Code view earns its maintenance cost only when it answers a question. Likewise, an arbitrary box limit is less useful than a clear scope, typed elements, labelled relationships and a diagram readable by its intended audience~the concerns in C4’s checklist.
For distributed systems, supplement the structural map with Dynamic and Deployment views, threat models, SLOs, runbooks, contract tests and recovery documentation. These artefacts expose ordering, trust, capacity, rollout and failure assumptions that a static Container diagram cannot.
Conclusion
C4 can give architects and engineers a shared, audience-oriented structural map. ADRs explain selected significant decisions, and repository-managed sources make review possible. None of that proves the map is current or that the system behaves as intended.
Start with a reader question and the smallest useful view. Use C4 for structure; add Dynamic and Deployment views, tests and operational documentation where behaviour and operations matter.