architecture
Architecture reference pack, current for 2026: ADRs, C4 modeling and diagrams-as-code, style selection (modular monolith, microservices, event-driven, serverless, CQRS/event sourcing), DDD, module boundaries and fitness functions, data ownership and distributed consistency (sagas, outbox, read models, data mesh), integration patterns, migration (strangler fig, expand-contract), quality attribute tradeoffs (ATAM, CAP/PACELC), and docs-as-code governance. Use when choosing or reviewing a system's structure or style, writing or superseding an ADR, drawing C4 views, defining bounded contexts, aggregates, or domain events, enforcing dependency direction in CI, designing idempotent retries and ordering, planning a strangler-fig or expand-contract migration, running a lightweight ATAM, or keeping architecture docs and tech radar alive. Optional pack; consumers degrade gracefully when absent.
Architecture
Design and evolution of system structure: where the boundaries are, how parts communicate, where state lives, and how decisions and diagrams stay true over time. This pack is opinionated about what the industry has converged on (ADRs, C4, fitness functions, outbox, strangler fig) and treats the rest as explicit tradeoffs against quality attributes. It covers structure and decision-making, not framework internals; language and API specifics live in their own packs.
Domain Overview
- Architecture is the set of decisions that are expensive to change. Everything else is implementation detail. Spend review effort where reversal cost is high.
- No best style, only fitted tradeoffs. Monolith versus microservices versus serverless is a function of team size, domain volatility, operational maturity, and quality attribute priorities — not fashion.
- Boundaries follow business capabilities. Services and modules mirror bounded contexts and ownership, not layers, frameworks, or the org chart alone.
- Decisions are recorded, not remembered. An ADR is the durable artifact; diagrams and code are downstream of it.
- Distribution has a price. Every network hop buys independent deployability and pays in latency, partial failure, and consistency complexity. Prefer the simplest distribution that meets the attributes.
- Enforce boundaries mechanically. Rules that live only in reviews rot; fitness functions in CI keep them true.
- Evolution is incremental by default. Strangler fig and expand-contract beat big-bang rewrites; every migration step is shippable and reversible.
- Verify upstream. Version floors below are ranges or
xseries; confirm current support in official docs before pinning.
Core Rules (non-negotiable)
- Write the ADR before the irreversible step. If a decision is expensive to reverse (style, datastore, protocol, tenancy, cloud lock-in), record context, options, decision, and consequences first. Never delete an ADR; supersede it (see references/01-adr.md).
- State the quality attributes and rank them. At least: availability, consistency, latency, cost, security, operability. A decision that does not name which attribute it trades against is not reviewable (see references/09-quality-attributes.md).
- Cut boundaries by capability and ownership, not by technology. A boundary that does not own its data and release cadence is a folder, not a boundary (see references/04-ddd.md, references/05-modularity-boundaries.md).
- Dependencies point in one direction. Domain code never imports infrastructure or transport; stable modules never depend on volatile ones; no cycles between modules or services.
- Single writer per data set. Each service/context owns its schema and is the only writer. Others read through APIs, events, or a read model — never by sharing tables (see references/06-data-architecture.md).
- Prefer synchronous request/response until async has a named reason. Async buys decoupling and buffering; it pays in eventual consistency and operational surface. Write the reason down.
- Every remote call assumes failure. Explicit timeouts/deadlines, bounded retries with jitter, circuit breakers, bulkheads, and idempotent operations are mandatory, not hardening (see references/07-integration-patterns.md).
- Avoid distributed transactions. Use sagas with compensations plus a transactional outbox (or CDC) to publish state changes; consumers deduplicate. Dual writes are a bug (see references/06-data-architecture.md).
- Make consistency explicit per use case. "Eventually consistent" must name the propagation window and what the user sees meanwhile.
- Enforce boundaries with fitness functions in CI. ArchUnit, dependency-cruiser, import-linter, or equivalent fail the build on violations. If it is not tested, it is not a rule (see references/05-modularity-boundaries.md).
- Diagrams are code and live next to the code. Structurizr DSL, PlantUML, Mermaid, or D2 in version control; hand-drawn exports are stale the day they are committed (see references/02-c4-modeling.md).
- Migrate by strangling, not by rewriting. Route traffic incrementally, keep old and new alive during coexistence, and plan decommissioning from day one (see references/08-migration-evolution.md).
- Design for operability from the first commit. SLOs, structured telemetry, and runbooks are architecture, not aftercare.
- Revisit decisions on a cadence. ADRs marked
deprecated/superseded, docs with owners and freshness dates (see references/10-documentation-governance.md).
Decision Tables
Architecture style
| Situation | First choice | Notes | |---|---|---| | One product, one to three teams, unclear domain | Modular monolith | Enforce module boundaries in CI; service extraction stays cheap | | Many teams, independent release cadence, clear contexts | Microservices | One service per context owner; distributed cost is real | | Bursty, spiky, or event-heavy workloads; small team | Serverless / managed platforms | Watch cold starts, vendor lock-in, per-invocation cost | | Audit-critical domain, temporal queries, rich history | Event sourcing with CQRS | High modeling and ops cost; not a default | | Read/write asymmetry, heavy reporting | CQRS read models on top of the writer | Keep the write model authoritative | | Integration across heterogeneous systems | Event-driven with a broker | Choreography for fan-out, orchestration for process state |
Details, costs, and team-size fit: references/03-styles.md.
Integration style
| Need | Choose | Avoid | |---|---|---| | Interactive query, immediate answer, low blast radius | Synchronous HTTP/gRPC with deadline | Chaining more than two hops synchronously | | Workflow across services, long-running process | Orchestrated saga with compensations | Distributed transaction (2PC) across services | | Fan-out state change notification | Events via broker, transactional outbox | Dual writes to database and broker | | Request to a specific owner with retry semantics | Command/queue with idempotency key | Fire-and-forget without dead-letter handling | | Third-party callback | Webhooks with signatures and replay protection | Assuming exactly-once delivery |
See references/07-integration-patterns.md.
Data consistency
| Use case | Model | Cost | |---|---|---| | Money movement within one service | ACID transaction, single writer | Vertical scaling limits | | Cross-service workflow | Saga (orchestration or choreography) + compensation | Complexity, no isolation guarantee | | Publish after commit | Transactional outbox (or CDC) | Extra table/connector, lag monitoring | | Fast reads at scale | CQRS projection/read model | Rebuild tooling, staleness window | | Analytics across domains | Data mesh / lakehouse products with contracts | Governance overhead, metadata discipline |
See references/06-data-architecture.md.
Boundary enforcement
| Stack | Fitness function |
|---|---|
| JVM | ArchUnit / ArchUnitTS style tests in the build |
| TypeScript/JavaScript | dependency-cruiser, ESLint no-restricted-imports, package exports |
| Python | import-linter contracts, module __all__, layering tests |
| Go | internal/ packages, arch-go, import graph lint |
| Multi-repo/org | Build-system visibility rules, CODEOWNERS, review by boundary owner |
See references/05-modularity-boundaries.md.
Migration approach
| Starting point | Approach | Guardrail | |---|---|---| | Monolith to services | Strangler fig with a routing facade | One seam at a time; measurable parity | | Schema change on live data | Expand-contract (parallel change) | Old and new readers work during transition | | New store/source of truth | CDC or backfill plus dual-read, then cut reads | Never dual-write; verify counts and lag | | Legacy decommission | Traffic drain, flag removal, retention plan | Legal/retention sign-off before delete |
See references/08-migration-evolution.md.
Reference Index
Load only what the task needs. All paths are relative to this file.
| # | Reference | Scope | Load when |
|---|---|---|---|
| 01 | references/01-adr.md | ADR purpose, MADR-style template, lifecycle and statuses, repository layout, when to write one, superseding, review process | Recording or reviewing a decision, setting up docs/adr/, resolving a stale decision |
| 02 | references/02-c4-modeling.md | C4 levels, Structurizr/PlantUML/Mermaid diagrams-as-code, context/container/component patterns, runtime and deployment views | Drawing or reviewing diagrams, onboarding, choosing a diagram tool |
| 03 | references/03-styles.md | Modular monolith, microservices, event-driven, serverless, CQRS/event sourcing: decision table, costs, team-size fit | Picking or questioning an architecture style, sizing teams, justifying a split |
| 04 | references/04-ddd.md | Bounded contexts, ubiquitous language, aggregates, domain events, context mapping, when DDD is overkill | Finding boundaries, naming domains, modeling invariants, planning context maps |
| 05 | references/05-modularity-boundaries.md | Module boundaries, dependency direction, package principles, cohesion/coupling metrics, fitness functions (ArchUnit/lint) | Enforcing layering in CI, extracting modules, reviewing dependency graphs |
| 06 | references/06-data-architecture.md | Data ownership per service, consistency models, sagas, transactional outbox, CQRS read models, data mesh basics | Designing state, cross-service workflows, outbox, projections, data products |
| 07 | references/07-integration-patterns.md | Sync vs async, contracts and schema evolution, idempotency, retries and timeouts, ordering, choreography vs orchestration | Wiring services, hardening calls, choosing queues vs logs, fixing retry storms |
| 08 | references/08-migration-evolution.md | Strangler fig, expand-contract, dual writes, incremental migration plans, coexistence, decommissioning | Replacing a system or schema, planning cutover, killing legacy |
| 09 | references/09-quality-attributes.md | Quality attribute scenarios, lightweight ATAM, CAP/PACELC, consistency/latency/availability tradeoffs, risk register | Eliciting and ranking attributes, reviewing tradeoffs, writing risks |
| 10 | references/10-documentation-governance.md | Docs-as-code, architecture reviews, tech radar, decision logs, keeping docs alive | Setting up docs, running reviews, curating standards, fighting doc rot |
Loading
- Installed agent —
skill({ name: "architecture" })in OpenCode; Claude Code reads<skills-dir>/architecture/SKILL.md. - Orchestrator — read this file, then inject only the references the task needs.
- Not installed — proceed with embedded guidance, state the degraded mode, and do not invent pack-only content.
- Consumers — reference this pack as
load skill architecture (optional).
Port
- Port id —
skill://senior/architecture(version inmetadata.port-version). - Kind — read-only reference pack; no side effects, no tools required.
- Entrypoint — this
SKILL.md; depth lives inreferences/. - Load modes
- Installed agent:
skill({ name: "architecture" })in OpenCode; Claude Code reads<skills-dir>/architecture/SKILL.md. - Orchestrator: read
SKILL.md, then load only the references the task needs. - Not installed: consumers MUST degrade gracefully using their own guidance and report the degraded mode. Never block on the pack.
- Installed agent:
- Consumer contract —
metadata.consumerslists the agents that may load it; consumers reference it asload skill architecture (optional). - Stability —
stable; breaking changes bumpport-versionmajor.
References (10)
01-adr
Architecture Decision Records
Purpose, template, lifecycle, repository layout, review process, and superseding for ADRs.
Why ADRs
An ADR captures one architecturally significant decision: the forces in play, the option chosen, and the consequences accepted. It exists so that six months later a maintainer can answer "why is this like this?" without archaeology in chat history, and so a reviewer can challenge the reasoning rather than the code.
What ADRs buy:
- Decision durability — context survives team changes and reorgs.
- Cheaper review — the decision is reviewed once, before the expensive code exists.
- Explicit tradeoffs — rejected options are visible; "we always did it this way" stops.
- Onboarding speed — a chronological decision log is the fastest architecture history.
- Audit trail — regulated environments get a defensible record of design intent.
What ADRs do not do: document interfaces (that is OpenAPI/AsyncAPI/proto), describe current structure (C4 diagrams), or replace design docs for large initiatives (link those instead).
When to Write One
Write an ADR when a decision is expensive to reverse or constrains other teams. If reversing it would take more than a sprint or require coordinated migration across services, it is significant.
| Write an ADR | Skip (put it in code review or a comment) | |---|---| | Choosing a style: monolith, microservices, serverless | Naming a class or endpoint | | Datastore, message broker, or protocol choice | Library patch-level upgrades | | Data ownership and single-writer boundaries | Internal refactoring behind a stable interface | | Consistency model, saga, or outbox strategy | Test structure and fixtures | | Authentication/authorization model, tenancy | Log formatting | | Cross-team integration contract and versioning policy | Local code conventions already in a style guide | | Buy vs build for a platform capability | Throwaway prototypes (write one if the prototype informs a keep/rewrite call) | | Deliberate deviation from a standard or fitness rule | Following the standard |
Rules of thumb: one decision per ADR; if the title needs "and", split it. If you cannot state the decision in two sentences, the decision is not ready. Timebox: a good ADR takes 30-90 minutes; a week means the decision needs a design doc plus an ADR summary.
Lifecycle
Statuses are state, not ceremony. The file is never deleted; it is superseded.
| Status | Meaning | Who sets it |
|---|---|---|
| proposed | Under review; do not build on it yet | Author |
| accepted | Decided and in force | Decision owner after review window |
| rejected | Considered and declined; keep for history | Decision owner |
| deprecated | No longer recommended but still in effect somewhere | Owner when guidance shifts |
| superseded by ADR-NNN | Replaced by a newer decision | Author of the new ADR |
Transition rules:
proposed->acceptedrequires a named decision owner and a review deadline; silence past the deadline counts as lazy consensus unless an objection was raised.accepted->supersededrequires an explicit link in both directions (new ADR links back, old ADR updated in place).- Only
acceptedADRs may be cited as constraints in reviews. - An
acceptedADR can beamendedwith a dated addendum if the change is editorial; if the decision itself changes, supersede.
Template
Use MADR-style (Markdown Any Decision Records; MADR 3.x-4.x, verify upstream). Keep it to one screen where possible.
# ADR-0007: Use transactional outbox for order events
- Status: accepted
- Date: 2026-03-11
- Deciders: platform team, orders team
- Supersedes: none
- Superseded by: none
## Context and Problem Statement
Order state changes must reach billing and fulfillment without dual writes.
Dual writes to the database and broker already caused two lost-update incidents.
## Decision Drivers
- No lost events on crash between commit and publish
- At-least-once delivery acceptable; consumers already deduplicate by event id
- Delivery latency under 5 seconds p99
## Considered Options
1. Dual write to DB and broker
2. Transactional outbox with a relay
3. Change data capture (CDC) directly on the orders table
## Decision Outcome
Chosen: **transactional outbox with a relay**, because it keeps the transaction local
and the publish path observable without schema-level coupling to a CDC topology.
CDC remains an option if outbox relay lag becomes a bottleneck.
### Consequences
- Good: no lost events, replayable, testable without a broker
- Bad: relay is another component to run and monitor
- Bad: events are published after commit, so read-after-write consumers see lag
### Confirmation
Alert when outbox lag p99 exceeds 5 seconds or dead-letter count grows.
Minimal acceptable version: Title, Status, Date, Context, Decision, Consequences, and links to the design doc. Extra sections (drivers, options, confirmation) earn their space by making the decision reviewable.
Repository Layout
Keep ADRs with the code they constrain. For a monorepo, use one root log plus optional subsystem logs; for many repos, keep a central index that links to repo-local ADRs.
docs/
adr/
README.md # index table + how to add one
0001-record-architecture-decisions.md
0002-modular-monolith-first.md
0003-postgres-as-primary-store.md
0007-transactional-outbox-for-order-events.md
template.md # copied for each new ADR
Conventions:
- Zero-padded, monotonically increasing four-digit numbers; never reuse a number.
- Filename
NNNN-kebab-case-title.md; title line inside repeatsADR-NNNN: Title. - The index
README.mdlists number, title, status, date, and links. CI fails if a file is not indexed or a status link is broken. - For a large initiative with many related decisions, group them in
docs/adr/<initiative>/but keep global numbering. - The very first ADR is usually "ADR-0001: Record architecture decisions" — it documents this process itself.
Superseding Decisions
Superseding is how architecture evolves without losing history.
- Draft the new ADR with
Status: proposedand aSupersedes: ADR-0007field. - In the old file, change the status to
superseded by ADR-0012and add a one-line pointer. - Apply the same change in the index table in the same pull request.
- In the new ADR's Context, summarize what changed since the old decision (new constraints, cost, scale, incident) rather than re-arguing from scratch.
- Mark the migration work in the new ADR's Consequences and link the migration plan (see ./08-migration-evolution.md).
Do not edit accepted decisions to match present reality. That erases the reasoning that made them right at the time.
Review Process
Async-first, timeboxed, with a named owner accountable for the outcome.
| Step | Actor | Output |
|---|---|---|
| 1. Draft | Author | ADR in proposed, posted as a pull request |
| 2. Socialize | Author | Reviewers: affected teams, boundary owners, on-call for impacted systems |
| 3. Review window | Reviewers | Comments resolved or raised as objections, typically 3-5 working days |
| 4. Decide | Decision owner | accepted or rejected, with rationale recorded |
| 5. Publish | Author | Merge, index update, announce in the architecture channel |
| 6. Enforce | Code owners | Fitness functions, linters, or review checklists encode the decision |
Process rules:
- One accountable decider per ADR; "everyone agrees" is not an owner.
- Dissent is recorded, not buried: add an "Objections and resolutions" subsection when views diverge.
- Urgent decisions use an expedited window (24 hours) with a follow-up review scheduled.
- A decision with cross-team blast radius gets at least one reviewer from each affected team.
- Reviewers check for: stated quality attributes, rejected alternatives, reversibility, and consequences for operations.
Tooling and Automation
adr-toolsand similar CLIs scaffold and number files;log4brainsand Backstage plugins render searchable ADR sites from the same Markdown.- CI checks worth having: index completeness, valid status values, no duplicate numbers, links
resolve, superseded pairs are bidirectionally linked, and changed ADRs do not silently flip
acceptedtoproposed. - Link the ADR from the pull request that implements it ("Implements ADR-0007") and from code where the decision is easy to violate (a comment at the boundary, not a spray of links).
- Tool versions move; verify current releases upstream rather than pinning from this document.
Anti-Patterns
- Retroactive ADR — writing "the decision" after shipping to justify it; reviewers cannot influence what already merged.
- Omnibus ADR — one file covering half the system; nobody can supersede a subset.
- Status drift — old ADRs left
acceptedforever while the code moved on; readers lose trust in the log. - Template theater — filling every section with boilerplate; an honest short ADR beats a padded one.
- No rejected options — a decision without alternatives is an announcement, not a decision.
- ADR as design doc — narrative designs in the ADR hide the single decision; link a design doc instead.
- Orphan log — ADRs in a wiki nobody reads; keep them in the repo and reference them in reviews.
Checklist
- [ ] One significant, hard-to-reverse decision per ADR.
- [ ] Status, date, decider, and supersession links accurate.
- [ ] Context names the forces and at least one rejected alternative.
- [ ] Decision is a complete sentence: subject, verb, rationale.
- [ ] Consequences list both benefits and costs, including operational burden.
- [ ] Index updated; links resolve; numbering unique.
- [ ] Affected boundary owners reviewed it.
- [ ] Implementation PR links the ADR; enforcement (if any) is in CI.
- [ ] Superseding updates both files in one pull request.
Related
- Diagrams that show the resulting structure: ./02-c4-modeling.md
- Migration planning for decisions that replace a system: ./08-migration-evolution.md
- Reviews, tech radar, and keeping the log alive: ./10-documentation-governance.md
02-c4-modeling
C4 Modeling and Diagrams
The C4 levels, diagrams-as-code tooling, context/container/component patterns, and runtime and deployment views.
What C4 Is and Is Not
C4 (Simon Brown) is a way to describe a software system at four zoom levels with a small vocabulary: person, software system, container, component, and code. It is not a notation standard with a fixed visual grammar, and it is not UML. The value is consistency of zoom and audience, not shapes.
Rules that keep C4 useful:
- Every diagram answers one question for one audience and is titled, dated, and versioned.
- Never mix levels: a container diagram does not show classes; a context diagram does not show databases unless they are systems someone else owns.
- Show technology choices on container diagrams (for example "API — Go, gRPC", "Bus — managed Kafka-compatible") because that is the level where technology matters.
- If a diagram needs a legend longer than five items, split it.
- Model once, render many: one source model, many generated views. Hand-maintained duplicates always diverge.
The Four Levels
| Level | Audience | Shows | Typical elements | |---|---|---|---| | 1. System Context | Everyone, including non-technical | The system as one box, its users, and the other systems it depends on or serves | People, our system, external systems | | 2. Container | Technical staff, ops, architects | Deployable/runnable units inside the system and how they communicate | Web app, API, worker, database, queue, cache | | 3. Component | Developers of that container | Major building blocks inside one container and their responsibilities | Modules, services, adapters, repositories | | 4. Code | Developers, only when needed | Classes/functions in one component | UML class/sequence, usually generated |
A container in C4 means anything that runs or stores: a process, a serverless function, a database, a message broker topic-owned boundary, a browser SPA. "Container" has nothing to do with Docker here. Say "container (deployable unit)" on first use to avoid confusion.
Level 1: System Context
Purpose: scope and external dependencies. One box for your system, explicit external systems, and the people who interact with it.
Pattern:
- Place the system in the center; users above/left, external systems around it.
- Label every arrow with intent ("submits orders", "reads customer profile via OIDC"), not protocol.
- Include systems that are easy to forget: identity provider, payment provider, email/SMS, analytics, on-prem ERP, regulator reporting endpoints.
- Mark which external systems you do not control and note their criticality.
Context checklist:
- [ ] Boundary of the system explicit (inside vs outside).
- [ ] Every actor is a person, a role, or an external system — no internal modules here.
- [ ] Every external dependency has an owner or vendor named.
- [ ] Data flows in both directions are shown where they exist.
- [ ] No technology detail beyond what a product manager needs.
Level 2: Container
Purpose: the deployable and runtime topology of the system, and the communication between parts.
Pattern:
- One box per independently deployable unit and per stateful store.
- Label arrows with protocol and style: "HTTPS/JSON", "gRPC", "publishes events", "reads replica".
- Show synchronous calls solid and asynchronous messages dashed where the tool supports it.
- Include the store-and-forward infrastructure: broker, object storage, cache, search index.
- Group by trust/deployment zone (public edge, private network, data zone) when zones matter.
Container checklist:
- [ ] Every box can be deployed or scaled independently, or is a datastore.
- [ ] Every arrow has a protocol; every async arrow names the topic/queue.
- [ ] Data ownership visible: who writes each store.
- [ ] Third-party managed services are included and labeled as external.
- [ ] Failure domains visible: what happens if the broker or primary DB is down.
Level 3: Component
Purpose: responsibilities inside one container, for the team that owns it. Do this only for containers with real internal complexity; a thin CRUD API does not need one.
Pattern:
- Decompose by responsibility or bounded context, not by class.
- Show the dependency direction (controllers -> application services -> domain -> ports; adapters depend inward).
- Name the port/adapter seams so the diagram matches the fitness functions in code (see ./05-modularity-boundaries.md).
- Include the outbound integrations of the container (broker publisher, external client).
Component checklist:
- [ ] One container per diagram; everything shown lives inside it.
- [ ] Each component has one sentence of responsibility.
- [ ] Dependency arrows match the enforced architecture rules.
- [ ] Cross-container interactions appear as boundary ports, not as foreign internals.
- [ ] The team that owns the container recognizes the diagram.
Level 4: Code
Skip it by default. Generate class and sequence diagrams on demand from code or model, and never curate them by hand. Keep generated ones out of review scope; they go stale fastest. Use them to explain one tricky flow, then discard.
Runtime and Deployment Views
Static structure is half the story. Add these views where they earn their place.
| View | Question it answers | When to add | |---|---|---| | Dynamic/sequence | How does one scenario flow, with ordering and failure paths? | Cross-container flows, retries, sagas, auth handshakes | | Deployment | What runs where: environments, regions, cells, failover? | Multi-region, regulated data residency, capacity planning | | Data flow | Where does a data category travel and rest? | Privacy/GDPR reviews, data residency, retention | | Network/trust | Which zones, gateways, and identities exist? | Zero-trust design, partner integrations, audit |
Deployment diagram pattern:
- One diagram per environment class (production, staging), not per cluster.
- Show regions/zones, replication direction, and the failover path.
- Label traffic entry points (CDN, WAF, gateway) and private connectivity (peering, PrivateLink).
- Annotate data residency and backup targets.
- Keep infrastructure-specific detail out; link to infrastructure-as-code instead.
Dynamic diagram pattern:
- Number the steps; show the caller and callee at each step.
- Show timeouts, retries, and compensation only on the paths where they exist.
- Prefer one scenario per diagram; link alternatives.
Diagrams as Code
Never hand-draw final diagrams. Pick one tool per organization and generate views from a model.
| Tool | Model | Strengths | Watch out | |---|---|---|---| | Structurizr DSL | Single workspace model, many views | True model/view separation, C4-native, CLI export to many formats | DSL learning curve; version the workspace file (verify current Structurizr release upstream) | | PlantUML + C4-PlantUML | Per-diagram text | Ubiquitous, easy CI rendering, familiar to many | Each diagram is separate; no shared model unless scripted | | Mermaid (C4 experimental) | Per-diagram text | Renders in many Markdown UIs with zero install | C4 support is experimental; verify current behavior upstream | | D2 | Per-diagram text | Clean layout, good CLI | Less C4-specific convention | | Modeling SaaS (IcePanel and similar) | Hosted model | Collaboration, links to code | Export/portability and pricing; keep a text export in the repo |
Structurizr DSL, minimal workspace:
workspace "Orders" {
model {
customer = person "Customer"
orders = softwareSystem "Orders System" {
api = container "Orders API" "Go, gRPC/HTTP" "Accepts and validates orders"
db = container "Orders DB" "PostgreSQL" "System of record for orders"
bus = container "Event Bus" "Kafka-compatible" "Publishes order facts"
api -> db "reads/writes"
api -> bus "publishes order events"
}
billing = softwareSystem "Billing" "External to the team"
customer -> orders "places orders"
orders -> billing "sends invoices via HTTPS"
}
views {
systemContext orders "Context" { include *; autolayout lr }
container orders "Containers" { include *; autolayout lr }
theme default
}
}
PlantUML + C4 snippet:
@startuml
!include <C4/C4_Container>
Person(customer, "Customer")
System_Boundary(orders, "Orders System") {
Container(api, "Orders API", "Go", "Validates and accepts orders")
ContainerDb(db, "Orders DB", "PostgreSQL", "System of record")
}
Rel(customer, api, "Places orders", "HTTPS/JSON")
Rel(api, db, "Reads/writes", "SQL")
@enduml
Mermaid, minimal (verify C4 support upstream):
C4Container
title Orders System - Containers
Person(customer, "Customer")
System_Boundary(orders, "Orders System") {
Container(api, "Orders API", "Go", "Validates and accepts orders")
ContainerDb(db, "Orders DB", "PostgreSQL", "System of record")
}
Rel(customer, api, "Places orders", "HTTPS/JSON")
Rel(api, db, "Reads/writes", "SQL")
Operational tips:
- Render diagrams in CI and publish to the docs site; reviews diff text, not images.
- Add a freshness date or a
last-verifiedfield; a diagram without one is assumed stale. - Link diagrams to ADRs and services (Backstage or equivalent catalog) so navigation is bidirectional.
- Keep the model in the same repo as the system when possible; a central catalog links out.
Anti-Patterns
- The everything diagram — one container diagram with 40 boxes; split by subsystem or audience.
- Level mixing — classes on a container diagram, infrastructure zones on a context diagram.
- Unlabeled arrows — an arrow without protocol and intent carries no information.
- Hand-drawn source of truth — exported images committed without the source model.
- Diagram-only architecture — pictures without decisions; pair with ADRs (./01-adr.md).
- Aspirational diagrams — showing the target state without labeling it as such; mark "target" versus "current".
- Orphan model — a Structurizr workspace nobody updates; assign an owner per view.
Checklist
- [ ] Each diagram names one audience and one question.
- [ ] Level boundaries respected; no mixing.
- [ ] Every element has a description; every relationship has a verb and protocol.
- [ ] Technology shown at container level only (plus deployment detail where relevant).
- [ ] External systems and their owners included.
- [ ] Sources are text, in version control; renders generated in CI.
- [ ] Freshness metadata present; owner assigned.
- [ ] Context/container views link to relevant ADRs.
- [ ] Deployment and dynamic views added where failure or data residency matters.
Related
- Recording the decisions the diagrams embody: ./01-adr.md
- Enforcing the component dependencies shown: ./05-modularity-boundaries.md
- Publishing and maintaining the docs site: ./10-documentation-governance.md
03-styles
Architecture Styles
Style selection across modular monolith, microservices, event-driven, serverless, and CQRS/event sourcing, with costs and team-size fit.
Style Selection Table
| Situation | First choice | Why | Primary cost | |---|---|---|---| | 1-3 teams, young domain, unclear boundaries | Modular monolith | Fast feedback, cheap refactoring, no distributed failure | Boundaries need discipline to stay real | | Clear bounded contexts, independent release needs, 4+ teams | Microservices | Independent deployability, team autonomy | Distributed systems complexity, platform investment | | High fan-out, temporal decoupling, streaming data, audit needs | Event-driven | Loose coupling, buffering, replay | Eventual consistency, broker operations, ordering | | Spiky or unpredictable load, small team, glue workloads | Serverless / managed | Pay per use, no servers to patch, fast start | Cold starts, runtime limits, lock-in, local dev friction | | Read/write asymmetry, temporal queries, high audit bar | CQRS + event sourcing | Full history, independent read scaling | Modeling difficulty, projections, versioning events | | Mixed: different attributes per capability | Modular monolith as core + selectively extracted services | Pay distributed cost only where it buys something | Two operating models to teach | | Regulatory or latency-bound monolith | Keep monolith, scale vertically and by cell | Simplicity is a feature | Scaling ceiling; plan cellular decomposition |
Default unless a specific driver says otherwise: modular monolith. A well-bounded monolith is the cheapest correct answer for most products, and it keeps the option to extract services later (see ./05-modularity-boundaries.md).
Modular Monolith
One deployable unit, real internal boundaries: modules own their data and expose explicit interfaces; cross-module calls go through those interfaces only.
Practices that make it a real style rather than a folder convention:
- One module per bounded context or capability, each with a public API package and private internals (see ./04-ddd.md).
- Enforced dependency direction with fitness functions in CI (./05-modularity-boundaries.md).
- No cross-module database access: a module owns its tables; others use its API.
- In-process events for decoupling within the boundary where useful.
- One build, one deploy, one database cluster is acceptable; schema-level separation by module makes extraction possible later.
Costs and limits:
- One release train for all modules; feature flags partially compensate.
- A noisy module can degrade the whole process (memory, threads); process-level isolation is absent.
- Scaling is whole-process unless you shard or run multiple deployment cells.
Fits when: fewer than roughly 3-4 teams, domain still churning, operational platform is thin, and time-to-market dominates.
Microservices
Independent services aligned to bounded contexts, each owning its data and deployable on its own cadence.
Prerequisites before splitting (all of them, not most):
- A stable platform: CI/CD per service, centralized logs/metrics/traces, service discovery or gateway, secrets management, IaC.
- On-call ownership per service; a service without an owner decays.
- Contract discipline: versioned APIs and events, consumer-driven tests (see ./07-integration-patterns.md).
- Data ownership rules: single writer per data set; no shared tables (./06-data-architecture.md).
- SLOs per service and error budgets; otherwise nobody knows what "healthy" means.
Decomposition rules:
- One service per bounded context first, not per entity; entities become aggregate boundaries inside a service.
- Split by rate of change and scaling needs, not by layer.
- Prefer wide services (a context) over many narrow CRUD services; nanoservices multiply operational cost without buying autonomy.
- Extract from a modular monolith along module seams once a driver exists: independent scaling, different compliance scope, or release contention.
Costs:
- Partial failure everywhere; every call needs timeout/retry/idempotency.
- Distributed data means sagas, outbox, and eventual consistency.
- Debugging crosses process and team boundaries; tracing is mandatory.
- Per-service overhead: repos, pipelines, dashboards, on-call rotations.
Fits when: 4+ teams sharing a product, distinct release/scaling/compliance needs per context, and the platform team exists to carry the operational load.
Event-Driven Architecture
Components communicate by producing and consuming events (facts) through a broker, decoupling producers from consumers in time and identity.
| Variant | Shape | Use when | |---|---|---| | Event notification | Thin event, consumer fetches details | Low coupling, but chatty and ordering-sensitive | | Event-carried state transfer | Event carries the data consumers need | Consumers must not call back; breaks read coupling | | Event sourcing | Events are the source of truth; state is a fold | Audit, temporal queries, rebuildable projections | | Publish/subscribe fan-out | Many independent consumers | Notifications, projections, integrations | | Stream processing | Continuous transformation/aggregation | Analytics, enrichment, materialized views |
Broker choice shape (verify specific products and versions upstream):
- Log-based (Kafka-compatible, Pulsar, NATS JetStream): ordered per partition, replayable, retention by time/size — good for event streaming and rebuildable consumers.
- Queue-based (SQS-compatible, RabbitMQ-style): per-message ack, competing consumers, simpler routing — good for work distribution and commands.
- Brokerless cloud events (EventBridge-style routers): low ops, coarse guarantees — good for integration fan-out, weak ordering.
Costs: eventual consistency, at-least-once delivery (consumers must deduplicate), schema evolution discipline, replay safety, and a broker that becomes critical infrastructure.
Serverless and Managed Platforms
Functions, managed containers, and managed data services where the platform owns scaling and patching.
Good fit:
- Bursty or low-volume workloads where idle capacity is waste.
- Glue and automation: scheduled jobs, webhooks, file processing, queue consumers.
- Small teams without a platform group.
Costs and cautions:
- Cold starts and execution limits shape design (per-request latency, duration caps, payload sizes); verify current limits upstream per provider.
- Local development and debugging are weaker; emulators drift from production behavior.
- Vendor lock-in is real at the integration layer (identity, queues, storage triggers); isolate with ports and adapters if portability matters.
- Cost can invert at sustained high utilization; model it, do not assume.
- State is the hard part: use managed state deliberately, and avoid functions calling functions in long chains (use queues or workflows).
CQRS and Event Sourcing
| Aspect | CQRS | Event sourcing | |---|---|---| | What it is | Separate write model from read models | State stored as an append-only event log | | Buys | Independent read scaling, tailored read shapes | Audit, temporal queries, replay, projections | | Costs | Projection pipeline, staleness, eventual consistency | Event versioning, snapshots, storage growth, steep learning curve | | Pair with | Read models, caches | CQRS projections, outbox, schema registry | | Avoid when | Simple CRUD, strongly consistent UI needs | No audit/temporal driver, small domain, team unfamiliar |
CQRS rules of thumb:
- Keep one authoritative write model per aggregate; read models are disposable and rebuildable.
- Define the freshness contract for each read model; the UI must handle the lag.
- Version events from day one; events are a public contract.
Event sourcing rules of thumb:
- Events are facts in past tense; never edit or delete them (compensate instead).
- Snapshot for performance, not for correctness.
- Do not expose the raw event store to other services; publish integration events via outbox.
- Treat schema evolution as a first-class problem (upcasting, weak schema, versioned types).
Details: ./06-data-architecture.md.
Team-Size Fit
Approximate guidance; team topologies and domain complexity matter more than headcount.
| Engineering teams (stream-aligned) | Credible styles | |---|---| | 1 team (3-9 people) | Modular monolith, serverless where it fits; one deployable is strength | | 2-3 teams | Modular monolith with strict modules; extract at most 1-2 services with clear drivers | | 4-8 teams | Microservices for domains with independent cadence; keep shared kernel small | | 8+ teams | Microservices plus platform team; enforce context map, service catalog, SLO ownership | | Any size, high audit | Event sourcing/CQRS only where the audit driver is explicit |
Cost and Complexity Comparison
| Style | Runtime coupling | Data consistency | Ops surface | Cognitive load | Time to first release | |---|---|---|---|---|---| | Modular monolith | High (same process) | Strong (local ACID) | Low | Moderate | Fast | | Microservices | Low per pair, high in aggregate | Eventual across services | High | High | Slow without platform | | Event-driven | Very low | Eventual; ordering constraints | Medium-high | Moderate-high | Medium | | Serverless | Very low | Depends on managed stores | Low-medium | Moderate | Fast for small scopes | | CQRS/ES | Medium-low | Eventual for reads | Medium-high | High | Slow |
Migration Pointers
- Monolith -> services: strangler fig with a routing facade; one seam at a time (./08-migration-evolution.md).
- Add events: transactional outbox first; do not dual-write (./06-data-architecture.md).
- Move reads off the write model: CQRS projection behind an existing API, then swap the read path.
- Introducing event sourcing mid-flight: start with one aggregate and a versioned event stream, never a big-bang rewrite.
Anti-Patterns
- Distributed monolith — services that must deploy together and share a database; the worst of both styles.
- Resume-driven architecture — choosing a style to learn it; choose against attributes.
- Nanoservices — one entity per service; the network becomes the call stack.
- Shared database across services — contract-by-table; every schema change breaks callers.
- Event soup — hundreds of event types with no ownership or schema discipline.
- Serverless for sustained high load — cost inversion and limit fights; model the workload.
- CQRS everywhere — read models for simple CRUD add lag and infrastructure for nothing.
- Big-bang rewrite — replacement projects that lose feature parity forever.
Checklist
- [ ] Style justified against ranking quality attributes, not preference.
- [ ] Team topology and size match the operating model the style requires.
- [ ] Boundaries align with bounded contexts and data ownership.
- [ ] Failure and consistency behavior defined for every distributed hop.
- [ ] Platform prerequisites present before splitting (CI/CD, telemetry, IaC, on-call).
- [ ] Migration path incremental and reversible; no dual writes.
- [ ] Cost model covers infrastructure and cognitive load, not just compute.
- [ ] Exit criteria documented for any temporary hybrid.
Related
- Finding the contexts to cut along: ./04-ddd.md
- Enforcing the boundaries inside the chosen style: ./05-modularity-boundaries.md
- Consistency and data ownership mechanics: ./06-data-architecture.md
- Deciding with attributes and risks: ./09-quality-attributes.md
04-ddd
Domain-Driven Design
Bounded contexts, ubiquitous language, aggregates, domain events, context mapping, and when DDD is overkill.
Strategic vs Tactical
DDD has two halves and they are independent.
| Half | Concern | Artifacts | Payoff | |---|---|---|---| | Strategic | Where boundaries are and who owns what | Bounded contexts, context map, ubiquitous language | Alignment of teams and models with the business | | Tactical | How to model inside a boundary | Aggregates, entities, value objects, domain events, repositories | Correct invariants, testable domain logic |
Strategic DDD pays off almost everywhere with more than one team or a non-trivial domain. Tactical patterns (repositories, aggregates, factories) pay off only where business rules are complex enough to justify the indirection. Taking tactical patterns without strategic ones produces ceremony; taking strategic ones without tactical ones is often the right call for CRUD systems.
Ubiquitous Language
One term, one meaning, per bounded context. The language lives in code identifiers, database columns, API fields, events, and conversations — the same word everywhere.
- Maintain a glossary per context, not per company: the same word may legitimately differ between contexts ("customer" in Sales vs Support), and the difference is a boundary signal.
- Name things after the business, not after the schema:
OverdueInvoicePolicy, notInvoiceProcessorManager. - When domain experts and developers need a translation layer every meeting, the model is wrong or the boundary is in the wrong place.
- Ban synonyms in one context: if
client,account, andcustomerall exist, pick one and rename.
Bounded Context
A bounded context is the boundary within which a model and its language are consistent. It is a modeling and ownership unit, not necessarily a deployment unit — one monolith can host many contexts, and one context can span a few services if the team owns them.
Heuristics for finding contexts:
- Linguistic — words keep changing meaning; there is a translation when crossing.
- Capability — the business capability has its own rules, lifecycle, and success metric.
- Data ownership — one team can be the single writer for its data.
- Rate of change — parts of the model change for different reasons at different speeds.
- Regulatory/trust boundary — different compliance or access rules apply.
- Team ownership — a context should not be shared by two teams with equal authority (shared ownership erodes models).
Contexts do not map one-to-one to services, databases, or repositories. Choosing one context per service is common, but a context may start as a module in a modular monolith (see ./03-styles.md, ./05-modularity-boundaries.md).
Context Mapping Patterns
The context map documents relationships between contexts. Name each relationship; unlabeled "we call them" integrations are where coupling hides.
| Pattern | Direction | Use when | Cost | |---|---|---|---| | Partnership | Bidirectional | Two teams must succeed or fail together on shared model changes | Coordination overhead; plan joint releases | | Shared kernel | Bidirectional | A small, stable model subset is genuinely shared | Any change breaks both; keep it tiny | | Customer/Supplier | Upstream -> downstream | Downstream can influence upstream priorities | Needs planning and negotiation | | Conformist | Upstream -> downstream | Upstream will not change; downstream adopts its model | Downstream loses modeling freedom | | Anti-corruption layer (ACL) | Downstream translates | Legacy or foreign model must not leak in | Translation code and tests to maintain | | Open host service | Upstream exposes stable API | Many consumers need the same capability | API versioning discipline required | | Published language | Upstream publishes documented schema | Integration via events/schemas | Schema evolution governance | | Separate ways | None | Duplication is cheaper than integration | Duplicate data and logic |
Rules:
- Draw the map at the same zoom as the C4 context view; link it from architecture docs (see ./02-c4-modeling.md).
- Every relationship has an owner on each side and a change protocol.
- Prefer ACL over conforming to a legacy model when the legacy semantics are wrong.
- Shared kernel must have an explicit change process and be small (a handful of value types, not an entity model).
Aggregates
An aggregate is a consistency boundary: a cluster of objects the domain treats as one unit for invariants. One aggregate is loaded and saved as a whole inside one transaction; the aggregate root is the only entry point.
Rules:
- One transaction, one aggregate. Cross-aggregate consistency is eventual, coordinated by domain events and, across services, sagas.
- Reference other aggregates by identity, never by object reference or foreign-key navigation into another aggregate's internals.
- Size by invariant, not by object graph. If two objects never need to be consistent in the same instant, they are different aggregates. Most aggregates are one root plus a small collection.
- Derive, do not store, what can be computed from the aggregate's own state; store what the invariant needs and what queries need (the latter may live in a read model).
- The root enforces transitions. No setters that let callers violate invariants; expose
intent-revealing operations (
order.submit(), notorder.status = "submitted"). - Keep aggregates in memory only as long as the use case needs; avoid session-long object graphs and lazy-loading webs.
Aggregate checklist for a candidate:
- [ ] Can you name the invariant in business language?
- [ ] Does the invariant need all the members to be checked atomically?
- [ ] Does the unit fit comfortably in one transaction on the expected scale?
- [ ] Are all external references by id?
- [ ] Is there a single root through which all changes flow?
If an invariant spans aggregates, model it as a process (saga) or accept eventual consistency, rather than merging aggregates into a distributed transaction.
Tactical Building Blocks
| Building block | Definition | Notes | |---|---|---| | Entity | Identity that persists across state changes | Equality by id, not attributes | | Value object | Immutable descriptive value | Equality by value; replace, never mutate | | Aggregate root | Entity that guards the consistency boundary | Only allowed handle on the aggregate | | Domain event | Past-tense fact meaningful to domain experts | Name in business terms; version as a contract | | Domain service | Operation that spans objects and has no natural home | Stateless; not a dumping ground for logic | | Repository | Collection-like access to aggregates | One per aggregate root; hides persistence | | Factory | Encapsulates complex creation/invariants | Useful when constructors would take too much | | Policy/Specification | Named business rule object | Useful for configurable rules and tests |
Persistence mapping stays outside the domain model (repository implementations are adapters), keeping the domain free of framework and storage concerns (see ./05-modularity-boundaries.md).
Domain Events
Domain events express what happened in the language of the business: OrderSubmitted,
PaymentCaptured, ShipmentDelivered. They serve three purposes: decoupling within a process,
consistent integration across contexts, and audit/analysis.
- Name with past tense and business meaning; include the aggregate id, occurred-at timestamp, and a schema version.
- Distinguish domain events (internal, fine-grained, may change) from integration events (published, versioned, contract-governed). Translate between them; do not publish raw internal events.
- Publish reliably via transactional outbox or CDC, never dual-write (./06-data-architecture.md).
- Consumers are idempotent and order-aware per aggregate id (./07-integration-patterns.md).
- Enrich events enough for consumers to act without callbacks; keep payloads a stable contract.
Event Storming
A lightweight modeling workshop to discover the domain quickly.
| Phase | Focus | Output | |---|---|---| | Chaotic exploration | Stick every domain event on a timeline | Shared story of the process | | Timelines | Order events, mark alternative paths | Happy path plus branches | | Pain points | Mark hotspots, bottlenecks, unknowns | Questions for domain experts | | Commands/policies | What triggers each event; which rules apply | Commands, policies, aggregates sketch | | Boundaries | Look for linguistic shifts and ownership | Candidate bounded contexts |
Practical guidance: 2-4 hours, domain expert in the room (mandatory), one facilitator, no laptops; use a physical or digital board and keep the result as an artifact. Follow up with a context map and candidate aggregates, then validate against real use cases.
When DDD Is Overkill
Do not pay tactical DDD costs on simple domains.
| Signal | Interpretation | |---|---| | Mostly CRUD with validation | Skip tactical patterns; use a thin service layer and a read model | | One team, simple domain, short lifetime | Strategic sketch is enough; no context map ceremony | | No access to domain experts | You cannot build a real model; lean on data and simple rules | | Rules are configuration, not logic | A rules table/engine beats an object model | | Reporting/WIP analytics systems | Model for queries, not for invariants | | Prototype or spike | Timebox; do not build aggregates for throwaway code |
A pragmatic middle ground: strategic boundaries and ubiquitous language always; aggregates and repositories only where an invariant list justifies them. Simple contexts can use active record/transaction script and still be well-bounded.
Anti-Patterns
- Big ball of mud — no boundaries, shared language drift, everything depends on everything.
- Anemic domain model everywhere — getters/setters plus services holding all logic; DDD costs without DDD benefits, or a sign the domain is simple.
- Entity equals table — one class per table with database-driven modeling; invariants leak.
- Aggregate as whole object graph — loading half the database per transaction; lock contention and memory blowups.
- Shared kernel creep — the "shared" model grows until it is the system.
- Leaky ACL — legacy concepts exposed directly to consumers; translation skipped "for now".
- Event explosion — pulsing every field change as a public event with no versioning or ownership.
- Context equals team org chart — boundaries drawn by reorg rather than domain; they change with every reorganization.
Checklist
- [ ] Ubiquitous language glossary exists per context.
- [ ] Context map names every relationship and pattern.
- [ ] Each context has an owner team and single-writer data.
- [ ] Aggregates justified by named invariants; ids used across aggregates.
- [ ] Domain events are past-tense, business-named, and versioned.
- [ ] Integration events are separated from internal domain events.
- [ ] Event publication is outbox-based, not dual-write.
- [ ] Tactical patterns applied only where complexity warrants.
- [ ] Model validated with domain experts, not just developers.
Related
- Choosing an architecture style for the contexts: ./03-styles.md
- Enforcing context and package boundaries: ./05-modularity-boundaries.md
- Consistent data and event publication: ./06-data-architecture.md
- Contracts and messaging between contexts: ./07-integration-patterns.md
05-modularity-boundaries
Modularity and Boundaries
Module boundary design, dependency direction, package principles, and fitness functions that enforce them in CI.
Why Boundaries
A boundary is a contract about what may change together. Good boundaries let teams change one part of a system without understanding or redeploying the rest. Bad ones are either absent (big ball of mud) or wrong (cut by layer or framework), and both guarantee that every change touches everything.
A real boundary has: an owner, an explicit public surface, its own data (or an explicit delegation), a defined dependency direction, and an enforcement mechanism.
Boundary Heuristics
Cut along lines where change is likely to be local:
| Axis | Cut here | Avoid | |---|---|---| | Business capability | "Pricing", "Fulfillment", "Identity" | "Utils", "Common", "Core" | | Data ownership | One writer per data set | Shared tables between modules | | Rate of change | Volatile UI versus stable domain rules | Mixing slow domain with fast integrations | | Volatility of external dependency | Isolate each vendor behind an adapter | Vendor SDK imported across the codebase | | Compliance/trust | Payment data, PII processing isolated | PII in shared DTOs and logs | | Team ownership | One team accountable per boundary | Joint ownership of the same module | | Scaling profile | Hot path separated from batch/CPU-heavy work | One module doing both |
Common failure: boundaries drawn by technical layer (controllers, services, repositories).
Layer boundaries are deployment conveniences; feature/capability boundaries are the ones that
survive product change.
Dependency Direction
Rules:
- Dependencies point toward stability. Stable, abstract policy must not depend on volatile detail. Domain and application policy sit at the center; transport, persistence, and vendor code sit at the edge.
- No cycles. A dependency cycle between modules means they are one module; break it or merge them.
- Ports and adapters. Domain defines interfaces (ports) it needs; infrastructure implements them (adapters). Inversion, not direction, is the mechanism.
- Cross-boundary calls only through the public surface. No reaching into another module's internal packages, tables, or DTOs.
- Shared kernel is explicit and tiny. If code is used by many modules, either promote it to a versioned library with an owner or accept duplication.
- Data flows through APIs/events, not shared schema. See ./06-data-architecture.md.
Layering reference (adapt per style):
interfaces/transport -> application/use-cases -> domain <- infrastructure adapters
(depends inward) (depends inward) (depends on nothing technical)
Package Principles
Robert Martin's package principles; useful as review questions rather than dogma.
| Principle | Name | Rule | |---|---|---| | REP | Reuse/Release Equivalence | Classes in a package are released together and reusable together; the package is the unit of versioning | | CCP | Common Closure | Classes that change for the same reasons belong together (SRP at package scale) | | CRP | Common Reuse | Classes used together belong together; do not force consumers to depend on things they do not use | | ADP | Acyclic Dependencies | No cycles in the package dependency graph | | SDP | Stable Dependencies | Depend in the direction of stability (fewer incoming/outgoing churn) | | SAP | Stable Abstractions | Stable packages should be abstract; volatile packages concrete |
Tension to manage consciously: CCP and CRP pull in opposite directions (bundle by change vs bundle by use). Resolve per context, not globally; domain-heavy packages favor CCP, utility libraries favor CRP.
Coupling Metrics
Compute these on the module graph in CI and trend them; absolute values matter less than direction.
| Metric | Meaning | Formula |
|---|---|---|
| Ca (afferent) | Modules depending on this one | count of incoming deps |
| Ce (efferent) | Modules this one depends on | count of outgoing deps |
| Instability I | Exposure to change | I = Ce / (Ca + Ce); 0 = stable, 1 = unstable |
| Abstractness A | Ratio of abstract types | A = abstract types / total types |
| Distance from main sequence D | Balance | D = |A + I - 1|; aim small for nontrivial modules |
Use metrics to start conversations, not to gate builds: a high-D module is a question ("is this stable package full of concrete details, or a volatile package full of abstractions?"). Cyclic counts, cross-boundary violation counts, and fan-in of "common" modules are better CI gates than D thresholds.
Fitness Functions
A fitness function is an automated test of an architectural property. Put them in the normal test suite so they fail pull requests, not quarterly reviews.
Java (ArchUnit, 1.x; verify current upstream)
@AnalyzeClasses(packages = "com.acme.orders")
class ArchitectureTest {
@ArchTest
static final ArchRule domain_is_isolated = noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAnyPackage("..infrastructure..", "..interfaces..");
@ArchTest
static final ArchRule no_cycles = slices()
.matching("com.acme.orders.(*)..")
.should().beFreeOfCycles();
}
TypeScript/JavaScript (dependency-cruiser, verify current upstream)
// .dependency-cruiser.cjs
module.exports = {
forbidden: [
{
name: "domain-shall-not-import-infra",
severity: "error",
from: { path: "^(src/[^/]+)/domain" },
to: { path: "^(src/[^/]+)/(infrastructure|http|db)" },
},
{
name: "no-circular",
severity: "error",
from: {},
to: { circular: true },
},
],
options: { tsConfig: { fileName: "tsconfig.json" } },
};
ESLint equivalent for a single rule (no extra tooling):
"no-restricted-imports": ["error", {
patterns: [
{ group: ["**/infrastructure/**"], message: "Domain code must not import infrastructure." }
]
}]
Python (import-linter, 2.x; verify current upstream)
# setup.cfg
[importlinter]
root_package = acme
[importlinter:contract:layers]
name = Layered architecture
type = layers
layers =
acme.interfaces
acme.application
acme.domain
Run in CI: lint-imports for Python, depcruise --validate .dependency-cruiser.cjs src for
TypeScript, and the ArchUnit test as part of the standard test task.
Go
internal/under a parent package is import-restricted by the toolchain.go list -deps ./...plus a small script can assert forbidden edges;arch-goand similar linters add rule configuration (verify current upstream).
Reviews and CODEOWNERS
- CODEOWNERS maps boundaries to reviewers, so cross-boundary changes get the right eyes.
- Multi-repo: use build-system visibility (Bazel
visibility, workspace package boundaries) or a service catalog that records allowed dependencies.
Properties worth enforcing, in rough priority:
- No dependency cycles between top-level modules.
- Domain does not import transport/persistence/framework code.
- Cross-module imports only via the module's public package/path.
- Specific banned edges (for example
billing -> shipping) where the context map says separate ways or customer/supplier only. - No direct datastore access across module boundaries (no shared table names, no shared ORM models).
Public Surface Design
- One module = one public package (or
exportsmap,__all__,api/path). Everything else is internal by convention and by tooling. - Version the public surface with semantic versioning; treat it as an API even inside a monorepo (see the monorepo pack for workspace versioning).
- Export types/contracts, not implementations: interfaces, DTOs, events.
- Avoid leaky abstractions: no framework types in the public signature unless the framework is part of the contract.
- Deprecate in the surface, remove after a published window.
Shared Code Strategy
| Option | Use when | Cost | |---|---|---| | Duplicate | Logic is small and stable; coupling would be wrong | Drift risk; acceptable for < a few hundred lines | | Shared kernel module | Genuinely universal concepts (money, ids, time) | Any change touches all consumers; keep tiny | | Internal library (versioned) | Multiple teams, different release cadences | Release management overhead | | Copy-on-write with codemod | Broad mechanical change | Requires tooling discipline |
Prefer duplication over the wrong abstraction when the shared piece encodes different business
meaning in each context (a Customer in billing is not the Customer in support).
Anti-Patterns
- Layer-only packages —
utils,common,coreas dumping grounds with fan-in from everywhere. - Boundary in name only — folders with no dependency enforcement; the first deadline erases them.
- Distributed boundary — a module that shares tables or transactions with another module.
- Fitness function theater — rules so weak or excluded that they never fail; verify they catch a planted violation.
- Over-modularization — a dozen packages for a small app; navigation and release overhead for no autonomy gain.
- Big shared kernel — half the model lives in
shared; boundaries are cosmetic. - Rules only for juniors — seniors bypass the checks; then the checks stop mattering.
Checklist
- [ ] Boundaries named by capability, each with an owner.
- [ ] Public surface documented and minimal.
- [ ] Dependency direction enforced (no domain -> infrastructure edges).
- [ ] No cycles between modules; trend measured in CI.
- [ ] Fitness functions run on every pull request and can actually fail.
- [ ] Cross-boundary data goes through APIs/events, never shared tables.
- [ ] Shared code decision recorded (duplicate, kernel, or library) per case.
- [ ] CODEOWNERS or equivalent routes boundary changes to owners.
- [ ] New violations are visible in review, not discovered at release.
Related
- Domain boundaries that these modules implement: ./04-ddd.md
- Style choice that determines deployment boundaries: ./03-styles.md
- Extracting a module along an enforced seam: ./08-migration-evolution.md
06-data-architecture
Data Architecture
Data ownership per service, consistency models, sagas, transactional outbox, CQRS read models, and data mesh basics.
Ownership: The Single-Writer Rule
Every data set has exactly one writer — one service, module, or context that owns its schema, its invariants, and its write path. Everyone else reads it through an API, an event stream, or their own derived read model.
- The owner controls schema evolution; consumers control nothing about the schema.
- No shared tables between services, ever. A shared table is a hidden distributed transaction waiting to break release independence (see ./03-styles.md).
- Cross-context reads that need many entities are an argument for a read model or a query service, not for a join across ownership lines.
- Be explicit about derived data: who rebuilds it, from what, and how stale it may be.
- Classify data (PII, financial, operational) at the owner, with retention and residency rules attached to the owning service.
Consistency Model Selection
| Use case | Model | Mechanism | Notes | |---|---|---|---| | Invariants within one aggregate/service | Strong (ACID) | Local transaction | Preferred default; keep transactions short | | Workflow across aggregates, same service | Usually strong | One transaction or saga with domain events | Prefer one transaction where feasible | | Workflow across services | Eventual | Saga with compensations | No isolation; design for intermediate states | | Publish state changes to others | Eventual | Transactional outbox or CDC | Never dual-write | | Fast, denormalized reads | Eventual | CQRS projection | Freshness contract per read model | | Inventory/reservation, money movement | Serialize at one writer | Owner-side reservation or single partition | Avoid distributed locking across services | | Analytics across domains | Eventual, batch or streaming | Data products / lakehouse | Governance and contracts required |
Questions to answer for every eventual path: how stale can it be, what does the user see in the window, and what happens when propagation is late or lost.
Local Transactions and Aggregate Boundaries
- One transaction per aggregate is the default; keep it inside one service and one datastore.
- Transactions should be short: no remote calls inside a transaction, no waiting on user input.
- Isolation level is part of the design: know whether the store gives read-committed or snapshot semantics, and how the code behaves on write conflicts (retry, fail, or merge).
- If a business invariant spans aggregates, model the process explicitly (saga) rather than stretching a transaction across a network.
- Optimistic concurrency (version columns, ETags) is the usual tool for lost-update prevention; use it at aggregate roots.
Sagas
A saga is a sequence of local transactions with compensating actions. Two coordination styles:
| Aspect | Orchestration | Choreography | |---|---|---| | Control | Central coordinator/state machine | Each service reacts to events | | Visibility | Process state queryable in one place | Emergent; requires tracing and event catalog | | Coupling | Services depend on coordinator contract | Services depend on event contracts only | | Complexity | Grows in the coordinator | Grows in the network of interactions | | Best for | Complex, long, visible workflows (money, fulfillment) | Simple, linear, few-step flows and fan-out | | Watch out | Coordinator becomes a god service | Event chains become hard to reason about |
Saga rules:
- Every step must be idempotent and compensable, or explicitly non-compensable with a business-approved fallback (for example manual review queue).
- Compensations are semantic undo, not rollback: a refund is not an un-charge.
- Timeouts and no-response handling are first-class transitions; "stuck saga" is a failure mode with an alert and an operator path.
- Persist saga state durably; the process must survive restarts.
- Keep steps small; one step = one local transaction plus one message.
Minimal orchestration shape (pseudocode):
on OrderSubmitted(orderId):
state[orderId] = RESERVING
send ReserveStock(orderId, items)
on StockReserved(orderId):
state[orderId] = CHARGING
send ChargePayment(orderId, amount)
on PaymentDeclined(orderId, reason):
state[orderId] = COMPENSATING
send ReleaseStock(orderId)
on StockReleased(orderId):
state[orderId] = CANCELLED
Choreography is the same flow expressed as event subscriptions; document the event graph and give each event an owner.
Transactional Outbox
Problem: writing to your database and publishing to a broker are two systems; a crash between them loses or duplicates work. A dual write is a correctness bug, not a tuning issue.
Solution: write the event to an outbox table in the same local transaction as the state change; a relay publishes it and marks it sent. Consumers deduplicate.
CREATE TABLE outbox (
id UUID PRIMARY KEY,
aggregate_id TEXT NOT NULL,
aggregate_seq BIGINT NOT NULL, -- per-aggregate ordering
type TEXT NOT NULL,
payload JSONB NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published_at TIMESTAMPTZ,
attempts INT NOT NULL DEFAULT 0
);
CREATE INDEX outbox_unpublished ON outbox (occurred_at) WHERE published_at IS NULL;
Relay options (verify current tooling upstream):
- Polling publisher — periodic
SELECT ... WHERE published_at IS NULL ORDER BY occurred_at LIMIT nwithFOR UPDATE SKIP LOCKED; simple, adds small lag, needs an index and cleanup. - Log tailing / CDC — Debezium-style connector or managed CDC reads the write-ahead log and publishes changes; lower latency, more moving parts, schema coupling to the outbox table.
- Database-native queue features — some stores offer notification/job tables (Postgres
LISTEN/NOTIFY,pg_notifylimits) — useful as a wake-up signal, not a durable queue alone.
Outbox rules:
- Same transaction as the business write; no exceptions.
- Publish order per aggregate via
aggregate_seq; global ordering is neither needed nor scalable. - At-least-once delivery: consumers must be idempotent by event id.
- Retain until published plus a replay window; archive or purge with a documented policy.
- Monitor lag (oldest unpublished row age) and dead letters; alert on both.
- The outbox relay is infrastructure, not a business process; keep it boring.
Idempotent Consumers (Inbox)
Every consumer stores processed event ids and ignores duplicates.
CREATE TABLE inbox (
consumer TEXT NOT NULL,
event_id UUID NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (consumer, event_id)
);
- Apply the state change and the inbox insert in one local transaction.
- Prune the inbox by retention window tied to the broker's max redelivery horizon.
- Prefer deterministic, idempotent operations (upserts keyed by business id) so replays are naturally safe; the inbox table is the backstop.
- Deduplicate side effects (emails, payments) by a business key, not only by event id.
CQRS Read Models
Read models are denormalized, disposable stores optimized for specific queries. They are projections of events or of source tables.
- One read model per query shape; do not build a general-purpose replica and call it CQRS.
- Freshness contract: state the acceptable lag per read model and expose it where users care ("as of 2 seconds ago" beats silent staleness).
- Projections must be idempotent and rebuildable from the source of truth; keep a rebuild runbook and test it.
- Track projection position per consumer, not globally; different models advance independently.
- Handle out-of-order and late events with per-aggregate sequencing (./07-integration-patterns.md).
- Schema migrations for read models are safe to run offline (drop and rebuild) — that is their advantage; use it.
Event Sourcing (Data View)
State is derived from an append-only log of domain events.
- The event store is the system of record; all other stores (including read models) are derived.
- Events are immutable; corrections are new compensating events.
- Snapshots are a performance optimization only; never the source of truth.
- Version event schemas from the first release; support upcasting on read.
- Do not expose the internal event stream as a public integration API; publish translated integration events via outbox.
- Storage grows forever unless you accept archival with replay-from-archive procedures.
Polyglot Persistence and Storage Choice
Choose per access pattern and ownership, not per fashion.
| Need | Store shape | Notes | |---|---|---| | Transactional core, complex invariants | Relational (ACID) | Default; mature tooling and consistency | | Massive key-value access at low latency | KV store | Design for partition key; limited query flexibility | | Full-text and faceted search | Search index | Derived; rebuildable; never a system of record | | Flexible documents per aggregate | Document store | Validate invariants in code; careful with cross-doc consistency | | Time-series/metrics | TSDB or columnar | Retention and downsampling policies up front | | Analytics/lakehouse | Open table formats (Iceberg/Delta/Hudi class; verify upstream) | Schema evolution and time travel; catalog needed | | Graph relationships | Graph store | Only when traversals dominate; otherwise relational works |
Derived stores must have an owner, a rebuild path, and acceptable-loss classification.
Data Mesh Basics
For analytics at organizational scale, data mesh shifts ownership to domains:
- Domain ownership — business domains own and publish their data products.
- Data as a product — discoverable, addressable, trustworthy, self-describing, with SLAs.
- Self-serve platform — paved road for storage, pipelines, catalogs, access control.
- Federated computational governance — global standards (contracts, lineage, privacy) enforced automatically.
Use it when: many domains, analytics bottlenecked on a central team, strong platform in place. Do not use it as a license for every team to buy its own warehouse; without the platform and governance pillars it degenerates into data sprawl. For a single product team, a single lakehouse with clear ownership beats mesh ceremony.
Anti-Patterns
- Shared database — integration through tables; every schema change is a cross-team break.
- Dual write — DB commit plus broker publish outside a transaction; lost or duplicated events.
- Event-as-command — publishing "do this" events with an assumed single consumer; that is an RPC with extra steps.
- Read model as source of truth — someone writes to a projection; rebuilds destroy data.
- Saga without compensations — steps that fail leave inconsistent state with no operator path.
- Unbounded outbox — no retention, no lag alert, table grows until the relay dies.
- Cross-service joins — fan-out reads to N services per request; use a projection.
- Mesh without platform — each team building its own pipelines; duplication and drift.
Checklist
- [ ] One writer per data set; ownership documented per service.
- [ ] Consistency model named per use case, with staleness windows.
- [ ] Cross-service workflows are sagas with idempotent steps and compensations.
- [ ] All state changes publish via outbox or CDC; no dual writes.
- [ ] Consumers deduplicate (inbox or deterministic upsert); side effects keyed.
- [ ] Outbox lag and dead letters monitored with SLOs.
- [ ] Read models rebuildable, with tested rebuild runbook and freshness contract.
- [ ] Event schemas versioned; compatibility checked in CI.
- [ ] Data classification, retention, and residency rules attached to owners.
- [ ] Derived stores have owners and acceptable-loss classification.
Related
- Style choice and service data ownership: ./03-styles.md
- Delivery guarantees, ordering, and retry semantics: ./07-integration-patterns.md
- Attribute tradeoffs behind consistency decisions: ./09-quality-attributes.md
- Migrating ownership and stores safely: ./08-migration-evolution.md
07-integration-patterns
Integration Patterns
Synchronous versus asynchronous integration, contracts, idempotency, retries, ordering, and choreography versus orchestration.
Sync vs Async
| Need | Choose | Why | Cost | |---|---|---|---| | User waits for the answer | Synchronous request/response | Simplest mental model; immediate errors | Coupling in time; failure propagates upstream | | Work can be deferred | Async command via queue | Buffering, retry, load leveling | Eventual completion; needs status/query path | | Notify many interested parties | Publish event | Producer does not know consumers | Eventual consistency; schema governance | | Consistency across services | Saga (async steps) | No distributed transaction | Intermediate states; compensation logic | | Stream of continuous data | Log/topic with consumer groups | Ordering, replay, high throughput | Operational weight; retention design | | Third-party callback | Webhook with signature | Push instead of polling | Delivery is at-least-once; verify and dedupe |
Decision questions: Can the caller wait? Can the answer be stale? Who owns failure handling? What happens if the callee is down for an hour? Sync chains longer than two hops deserve a hard look — each hop multiplies availability loss.
Interaction Styles
| Style | Payload | Consumer knowledge | Use when | |---|---|---|---| | Request/response (RPC) | Command/query plus result | Caller knows callee | Interactive, low-latency, simple ownership | | Command message | Imperative, targeted | Sender knows the receiver's queue | Work distribution with retries | | Event notification | Fact, minimal data | Producer knows nothing | Decoupling; consumers fetch if needed | | Event-carried state transfer | Fact plus relevant state | Producer knows nothing | Consumers must not call back; read decoupling | | Document message | Full data snapshot | Producer knows nothing | Sync/import, cache hydration | | Stream | Continuous records | Producer knows nothing | Analytics, materialized views, CDC |
Do not publish commands as events. "OrderShipped" is an event; "ShipOrder" is a command that belongs on a queue addressed to an owner.
Contracts and Schema Evolution
Every integration has a contract; the contract outlives the implementation.
| Surface | Contract artifact | Compatibility gate |
|---|---|---|
| HTTP/JSON | OpenAPI 3.1 (verify current) | Schema diff in CI; additive changes only |
| gRPC | .proto files | Reserved fields/numbers; new package for breaks |
| Events | AsyncAPI or schema registry (Avro/JSON Schema/Protobuf; verify upstream) | Registry compatibility mode (backward/forward/full) |
| Webhooks | Documented payload plus signature scheme | Versioned event types; dual-publish during migration |
| File/batch | Schema plus validation rules | Test fixtures in both sides' CI |
Rules:
- Version schemas; never mutate a published schema in place.
- Add optional fields; do not remove, rename, or retype without a version and a migration window.
- Consumers ignore unknown fields (tolerant reader) unless strictness is the point.
- Defaults are contract: an omitted field changing behavior breaks clients.
- Consumer-driven contract tests (Pact-style; verify current tooling) catch drift where the producer cannot know all consumers.
- Schema registry plus CI compatibility checks beats documentation alone.
Delivery Semantics
| Guarantee | How achieved | Reality | |---|---|---| | At-most-once | Fire and forget, no ack/retry | Loss on failure; rarely acceptable | | At-least-once | Ack after durable processing; retry on failure | Duplicates; consumers must deduplicate | | Exactly-once (effectively) | Idempotent consumer plus at-least-once transport, or transactional broker semantics | The practical target; "exactly once" is scoped to a technology pair |
Design for effectively-once: at-least-once delivery plus idempotent processing plus a dedup store. Broker "exactly-once" features (transactional producers/consumers) help within one broker's boundary but do not cover external side effects.
Idempotency
An operation is idempotent if repeating it has the same effect as doing it once.
Techniques:
- Idempotency key — caller-generated unique key; the receiver stores
(key, result)and returns the stored result on repeat. Standard for HTTP POST payments and other unsafe operations. - Natural/business key — upsert by
(order_id, version); replay naturally safe. - Inbox table — record processed event ids in the same transaction as the state change (./06-data-architecture.md).
- Conditional writes —
INSERT ... ON CONFLICT DO NOTHING, compare-and-set by version.
INSERT INTO payment_attempts (idempotency_key, order_id, status, amount, created_at)
VALUES ($1, $2, 'pending', $3, now())
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING id;
Rules: scope keys to an owner and an operation; set a retention window and document it; return the same response for a repeated key within the window, and a conflict error outside it. Key external side effects (emails, charges) by business id, not by attempt.
Timeouts, Retries, and Circuit Breaking
- Timeouts are mandatory and explicit. Derive them from the caller's SLO budget, not from library defaults. Always set a deadline and propagate it downstream (gRPC deadlines, HTTP timeouts).
- Retry only idempotent operations or operations carrying an idempotency key. Retry connection failures, timeouts, and 5xx/429; never retry validation errors (4xx).
- Exponential backoff with full jitter; cap attempts; add a retry budget (retries as a fraction of requests) to prevent retry storms.
delay = min(cap, base * 2^attempt)
sleep = random(0, delay) # full jitter
- Circuit breaker per dependency: open after a failure threshold, fail fast for a cooldown, half-open to probe; prevents pileups and gives the dependency room to recover.
- Bulkhead pools/timeouts per dependency so one slow callee cannot exhaust all threads or connections.
- Hedged requests only for idempotent reads with strict budgets; they add load.
- Retry at one layer only; stacked retries multiply (3 x 3 x 3 = 27 attempts).
- Dead-letter queues with alerting and an operator replay path; retrying forever is not a strategy.
Ordering
Total order across a distributed system is expensive; per-key order is usually enough.
| Need | Technique | |---|---| | Order per aggregate | Partition key = aggregate id; broker preserves order within partition | | Detect gaps/reorder | Monotonic sequence per aggregate on every event | | Tolerate out-of-order arrival | Buffer/state machine keyed by sequence; reject or park older events | | Deduplicate replays | Event id ledger (inbox) plus sequence check | | Global order | Avoid; if truly required, single writer or single partition, with throughput ceiling |
Rules: consumers must not assume global order; producers must not change partition key over the life of an entity (it destroys order guarantees); document the ordering scope in the event contract.
Choreography vs Orchestration
| Criterion | Choreography | Orchestration | |---|---|---| | Coupling | Event contracts only | Coordinator plus step contracts | | Process visibility | Requires tracing/catalog | State machine is queryable | | Change impact | New step = new subscriber | New step = coordinator change | | Failure handling | Distributed; each service compensates itself | Centralized timeout/retry policy | | Fits | Simple flows, fan-out, integration | Complex, long, regulated workflows | | Risk | Hidden chains, cyclic event dependencies | Coordinator god-service, bottleneck |
Pragmatic rule: start choreographed for two or three steps; move to an orchestrated saga when the flow gains branches, timeouts, or operators need visibility. Never mix both for the same process without a documented reason.
Channels, Topics, and Dead Letters
- Queue (point-to-point): one logical consumer group; good for commands/work.
- Log/topic (publish-subscribe with retention): many independent consumers, replay; good for events.
- Naming:
<domain>.<entity>.<event-version>(for exampleorders.order.submitted.v1); owner and version visible. - One topic per event type per bounded context keeps subscriptions and permissions manageable; consumer-specific routing belongs in the consumer, not the topic name.
- Dead-letter topic per consumer group, with metadata (reason, attempts, original headers) and an alert; replay tooling tested before the first incident.
- Retention is a design decision: replay window, cost, and privacy deletion requirements all apply.
CloudEvents-style envelope (verify current spec version upstream):
{
"specversion": "1.0",
"type": "orders.order.submitted.v1",
"source": "/orders/api",
"id": "8f2c1e6a-0d9b-4f1a-9d1e-3f4a2b7c9e01",
"time": "2026-03-11T09:15:00Z",
"datacontenttype": "application/json",
"data": { "orderId": "ord_123", "total": "42.00", "currency": "EUR" }
}
Anti-Patterns
- Chatty calls — N+1 remote requests per user action; batch or cache or use a read model.
- Sync chains — five services in a synchronous call path; availability multiplies downward.
- Retry without idempotency — duplicate charges; the classic production incident.
- Stacked retries — client, gateway, and service each retrying; amplification.
- Global ordering assumption — code that silently depends on cross-partition order.
- Event as RPC — command semantics dressed as a domain event.
- Unversioned events — schema change breaks consumers in production.
- Infinite dead-letter backlog — DLQ with no alert or owner is a data loss bug with extra steps.
- Shared topic free-for-all — no owner, no schema, no permissions.
Checklist
- [ ] Integration style chosen per interaction, with failure behavior defined.
- [ ] Every call has an explicit timeout/deadline.
- [ ] Retries only where idempotent; exponential backoff with jitter and a budget.
- [ ] Circuit breakers and bulkheads isolate dependencies.
- [ ] Delivery semantics documented; consumers deduplicate.
- [ ] Idempotency keys scoped, stored, and retained per contract.
- [ ] Ordering scope documented (per key/aggregate), sequence checks in place.
- [ ] Event schemas versioned and compatibility-checked in CI.
- [ ] DLQ per consumer with alerting and a tested replay path.
- [ ] Choreography/orchestration choice recorded per workflow.
Related
- Reliable publication from a database: ./06-data-architecture.md
- Style-level consequences of async interaction: ./03-styles.md
- Evolving contracts without breaking consumers: ./08-migration-evolution.md
08-migration-evolution
Migration and Evolution
Strangler fig, expand-contract, dual writes, incremental migration plans, coexistence, and decommissioning.
Principles
- Incremental over big-bang. Every migration step ships to production and is reversible.
- Coexist, then cut over, then decommission. Three distinct phases with separate exit criteria.
- Measure parity. Migrated behavior is verified against the old system (shadow traffic, dual-read comparison) before it carries real load.
- Migrate one seam at a time. A seam is a route, an entity, a queue, a capability — small enough to finish and revert in days.
- Decisions first. Record the target, the approach, and the rejected options in an ADR (see ./01-adr.md).
- Decommission is part of the plan. A migration without a removal date is permanent dual maintenance.
Approach Selection
| Situation | Approach | Primary guardrail | |---|---|---| | Replace a legacy system or component | Strangler fig with a routing facade | One seam per iteration; parity metrics visible | | Change a live database schema | Expand-contract (parallel change) | Old and new code both work at every step | | Swap a library or implementation behind a stable interface | Branch by abstraction plus feature flag | Flag owner and removal date | | Migrate data to a new store | Backfill plus dual-read, then cut reads; CDC for continuous sync | Reconcile counts and checksums; never dual-write | | Module to service inside a monolith | Extract along an enforced module seam | Module boundary already tested in CI | | Change an API contract | Additive version plus sunset window | Telemetry on old-version usage before removal | | Moving workloads to a new platform | Shadow traffic, then canary, then progressive rollout | Rollback path and cost comparison per stage | | Full rewrite for a small, low-risk system | Big-bang (rare) | Freeze, migrate, verify, old system read-only |
A rewrite is attractive and usually wrong: the old system embeds years of undocumented rules. If a rewrite is truly chosen, run it behind the same strangler facade as any replacement.
Strangler Fig
Put a facade in front of the legacy system and route traffic incrementally to the new implementation until the legacy system has no callers.
Steps:
- Identify seams. Start with read-only, low-blast-radius routes (reporting, search) before write paths.
- Introduce the facade. A gateway, reverse proxy, or adapter layer that can route by path, header, tenant, or percentage. Keep it dumb: no business logic in the facade.
- Implement the new capability behind the facade, integrated with the legacy system only through APIs/events, never shared tables.
- Dual-run and compare. Shadow traffic to the new path; log differences; fix until parity is acceptable. Compare business outcomes, not just HTTP codes.
- Shift traffic progressively. Internal users, then 1%, then cohorts/tenants, then all.
- Freeze the legacy path. Reject new features on the legacy side; all changes go to the new implementation.
- Decommission. Drain traffic, delete code, drop data per retention rules (see below).
Routing strategies:
| Strategy | Use when | Notes | |---|---|---| | Path prefix | Coarse split, easy to operate | Fast, visible; needs URL stability | | Header/claim (tenant, user agent) | Pilot with selected tenants | Good for enterprise rollouts; easy rollback | | Percentage / weighted | Gradual load shift | Track error/latency parity per cohort | | Feature flag in-app | Migrating internal calls | Dead flags accumulate; enforce removal dates | | Message replay split | Queue/topic consumers | Run old and new consumers on shadow topics |
Facade anti-patterns to avoid: accumulating translation logic (put it in an adapter service), routing rules in multiple places, and no telemetry on which path served a request.
Expand-Contract (Parallel Change)
Any change to data or an interface that old and new callers cannot agree on must pass through three phases.
- Expand — add the new shape without removing the old. New optional column, new field, new event version, new endpoint. Deploy readers/writers that tolerate both.
- Migrate — backfill existing data or dual-write temporarily, verify, then switch reads and writes to the new shape.
- Contract — remove the old shape only after telemetry proves zero usage and the rollback window has passed.
Database example (rename name to full_name):
-- Phase 1: expand
ALTER TABLE customer ADD COLUMN full_name TEXT;
-- application writes both name and full_name; reads prefer full_name when present
-- Phase 2: backfill, then stop writing the old column
UPDATE customer SET full_name = name WHERE full_name IS NULL;
-- Phase 3: contract, after zero reads/writes of name for one full release cycle
ALTER TABLE customer DROP COLUMN name;
Rules:
- Never add a NOT NULL column without a default/backfill plan for existing rows.
- Destructive changes (
DROP, narrowing types, tightening constraints) are contract-phase only. - Test the rollback: at every phase, the previous application version must still work.
- Django/Rails/Alembic-style migration tools run each phase as its own deployable migration. Do not bundle expand and contract in one release.
- For APIs: deprecation and sunset headers plus usage telemetry before removal.
Dual Writes
Writing to two systems without a shared transaction is a correctness hazard: partial failure, out-of-order writes, and divergent state.
| Scenario | Verdict | |---|---| | Application writes DB and publishes an event | Never. Use transactional outbox or CDC | | Application writes old and new database during migration | Avoid. Use CDC/backfill or outbox-driven sync | | Temporary dual-write with reconciliation and a hard end date | Acceptable only with explicit owner, metric, and deadline | | Analytics/projection writes | Not dual writes; derive from the event stream |
When a temporary dual write is unavoidable, make one side authoritative, reconcile continuously (counts, checksums, sampled rows), alert on divergence, and record the end date in the migration plan. Prefer CDC (log-tailing) so the second store is a derived projection, not a peer writer (see ./06-data-architecture.md).
Data Migration Mechanics
| Step | Purpose | Evidence | |---|---|---| | Inventory | Know every table, column, and consumer | Data map with owners | | Backfill | Populate the new store/shape | Rows processed, lag, error rate | | Reconcile | Prove equivalence | Counts, aggregates, checksum samples, business metrics | | Shadow reads | Compare without user impact | Diff rate under threshold | | Cutover reads | Serve from the new source | Error/latency parity dashboards | | Cutover writes | Single writer switches | No dual writes remain | | Freeze legacy | Prevent new dependencies | Access logs show no writes | | Decommission | Remove cost and risk | Retention/legal sign-off |
Cutover strategies: per-tenant (recommended for B2B), per-region, per-entity range, or percentage of traffic. Avoid a global flag-day cutover unless the dataset is small and the rollback is rehearsed.
Coexistence Rules
While old and new run in parallel:
- One authoritative writer per data set at any time. Name it; everyone else reads.
- Contract-first integration: version the interface between old and new; never share tables.
- Data sync direction is one-way or explicitly bi-directional with conflict resolution rules (last-write-wins is a decision with consequences — record it).
- Feature parity is tracked as an explicit list; unparity items have owners and deadlines, not vague "later".
- Cost is dual: monitor both systems' spend and the platform bill increase during coexistence.
- Incident response knows which system is authoritative for each capability; update runbooks.
Decommissioning
The last phase is the one most often forgotten; unfinished decommissioning becomes permanent complexity.
Checklist before removal:
- [ ] Traffic telemetry shows zero real users (not just zero errors) for the agreed window.
- [ ] All consumers migrated and notified; no SDK/API key/queue subscriber remains.
- [ ] Data retention and legal requirements satisfied — archive where required, delete per policy (GDPR/CCPA-style erasure duties), document destruction.
- [ ] Backups and restore procedures updated; old system removed from DR plans.
- [ ] Monitoring, dashboards, alerts, and on-call entries removed.
- [ ] Code, feature flags, routing rules, IaC, DNS entries, certificates, and secrets deleted.
- [ ] Docs, diagrams, ADRs, and the service catalog updated (./02-c4-modeling.md, ./10-documentation-governance.md).
- [ ] Cost center, licenses, and contracts terminated.
- [ ] Post-migration review recorded; lessons fed into the next migration.
Migration Plan Template
| Phase | Deliverable | Exit gate | Rollback | |---|---|---|---| | 0. Decide | ADR plus plan | Stakeholders agree on target and guardrails | n/a | | 1. Prepare | Facade, contracts, telemetry, test data | Parity harness runs in CI | n/a | | 2. Shadow | New path runs on copied traffic | Diff rate under threshold | Disable shadow | | 3. Pilot | Real traffic for a small cohort | Error/latency/business parity | Route back via facade | | 4. Progressive | 1% -> 10% -> 50% -> 100% | Stages hold for the agreed soak time | Weight back to legacy | | 5. Freeze | Legacy read-only | No legacy writes for a release cycle | Re-open if blocked | | 6. Decommission | Systems removed | Legal/retention sign-off | Restore from archive if needed |
Anti-Patterns
- Big-bang cutover — one flag day, no rollback, all-or-nothing risk.
- Dual-write everywhere — divergence and duplicated business logic.
- Facade god-service — translation and orchestration logic accumulates in the routing layer.
- Permanent coexistence — two systems maintained forever because nobody owns removal.
- Parity by proxy metric — comparing only HTTP 200 rates while business outcomes differ.
- Shared database between old and new — integration through tables; schema changes break both.
- Decommission without retention review — legal exposure and unrecoverable data.
- Feature parity not tracked — gaps discovered by customers after cutover.
Checklist
- [ ] ADR records the migration approach and rejected options.
- [ ] Seams are small, ordered by risk, and each step is reversible.
- [ ] Facade has telemetry (which path served what) and no business logic.
- [ ] Expand-contract used for every non-additive schema or API change.
- [ ] No dual writes; propagation is outbox or CDC.
- [ ] Backfill reconciled with counts, checksums, and business metrics.
- [ ] Cutover is progressive with soak times and rollback rehearsed.
- [ ] One authoritative writer named during coexistence.
- [ ] Decommission checklist completed, including legal retention and docs.
- [ ] Post-migration review recorded.
Related
- Recording target state and approach decisions: ./01-adr.md
- Where to cut seams in the existing structure: ./05-modularity-boundaries.md
- Reliable data movement during migration: ./06-data-architecture.md
- Contract evolution for APIs and events: ./07-integration-patterns.md
09-quality-attributes
Quality Attributes and Tradeoffs
Quality attribute scenarios, lightweight ATAM, CAP/PACELC, consistency/latency/availability tradeoffs, and risk registers.
Quality Attribute Scenarios
A quality attribute is not reviewable until it is a scenario with a measurable response. Use the six-part form: source, stimulus, artifact, environment, response, response measure.
| Attribute | Scenario (abbreviated) | Response measure | |---|---|---| | Availability | A zone fails during peak traffic | Checkout stays available; error rate < 0.1% for 10 minutes | | Performance | 95th percentile user submits an order | p95 < 400 ms, p99 < 1 s server-side | | Scalability | Marketing campaign multiplies traffic 10x for 2 hours | No manual action; p95 within budget; cost cap held | | Consistency | Customer updates address on mobile, checks desktop | Read-your-writes within 2 s | | Durability | Disk/database node loss | Zero acknowledged writes lost; RPO 0, RTO 15 min | | Security | Credential stuffing burst at 100x normal login rate | Rate limit and lockout; no account takeover; p95 login < 1 s | | Operability | Deploy a routine change during business hours | Zero-downtime, automatic rollback under 5 min | | Cost | Steady-state month at current traffic | Cost per 1k orders under target; no more than planned growth | | Compliance | Erasure request for one user | All copies removed within 30 days; audit record kept | | Modifiability | Add a payment provider | First provider plugin takes < 2 weeks; no core changes |
Guidance:
- Write 10-20 candidate scenarios with stakeholders, then rank; only the top 3-5 become architecture drivers.
- The measure must be observable in production (SLO, business metric) or a test fixture.
- "Fast", "highly available", and "scalable" without numbers are aspirations, not attributes.
- Attach each driver to the decisions it constrains; if no decision changes, it is not a driver.
Elicitation and Prioritization
Lightweight process that fits a half-day workshop:
- Gather stakeholders: product, operations/on-call, security, one or two senior engineers.
- Brainstorm scenarios per attribute category (the table above is a prompt list).
- Rank by business impact and difficulty; force a top 3-5, explicitly parking the rest.
- Build a utility tree: root = "utility", children = top attributes, leaves = scenarios with (business value, technical difficulty) ratings H/M/L.
- Walk each high-value/high-difficulty leaf through the architecture: can the current design satisfy it? What would have to change?
- Record the drivers, decisions, and gaps; feed them into ADRs and the risk register.
| Business value \ Technical difficulty | Low | Medium | High | |---|---|---|---| | High | Do now; likely cheap | Plan and design now | Spike, then decide; top risk | | Medium | Backlog | Schedule | Consider simpler target | | Low | Ignore | Ignore unless free | Do not gold-plate |
Lightweight ATAM
Architecture Tradeoff Analysis Method, scaled to a few hours rather than a multi-day audit.
| Step | Activity | Time | Output | |---|---|---|---| | 1 | Present business goals and drivers | 30 min | Prioritized attribute list | | 2 | Present the architecture (C4 context/container) | 30-45 min | Shared understanding | | 3 | Map scenarios onto the architecture | 60 min | Sensitive points, tradeoff points | | 4 | Identify risks, non-risks, and assumptions | 45 min | Draft risk register | | 5 | Prioritize scenarios with stakeholders | 30 min | Top drivers confirmed | | 6 | Re-analyze against prioritized scenarios | 45 min | Updated risks and decisions | | 7 | Report and assign owners | 30 min | ADRs, action items, review date |
Vocabulary:
- Sensitive point — a design choice that strongly affects one attribute (connection pool size, sync versus async, replica reads).
- Tradeoff point — a choice that helps one attribute and hurts another (consistency versus availability, caching versus freshness).
- Risk — an unresolved threat to a driver; non-risk — a driver that is clearly satisfied.
- Architectural runway — planned work that keeps near-term delivery possible.
Rules for credibility: the architecture must be presented honestly (current, not aspirational), at least one stakeholder from outside the team participates, and outputs have owners and dates.
CAP and PACELC
CAP: under a network partition, a distributed system can preserve consistency (linearizable operations) or availability (every request gets a non-error response), not both. Partition tolerance is not optional in a real network, so the choice is what to sacrifice during a partition.
PACELC extends this: even when there is no partition (Else), the system still chooses between Latency and Consistency. This is the daily tradeoff: synchronous replication costs latency; asynchronous replication accepts staleness.
| System posture | During partition | Normal operation | Typical choice | |---|---|---|---| | CP | Refuse writes / stay linearizable | May be slower | Money movement, inventory reservation | | AP | Accept writes, converge later | Fast, may read stale | Feeds, presence, catalogs, analytics | | Tuned per operation | Mixed | Mixed | Most real systems: strong for writes, stale reads for feeds |
Practical rules:
- Do not apply CAP to a single-node database; it is a distributed-systems property.
- Name the specific operation and store: "orders primary: CP for writes; search index: AP."
- CAP does not cover latency — use PACELC and SLOs for that.
- Partition behavior needs a tested failure mode: what does the UI do when the store refuses writes? Decide before the incident.
Consistency, Latency, Availability, Cost
| Decision | Favors | Costs | |---|---|---| | Synchronous replication / strong consistency | Correctness, RPO 0 | Latency, availability during partition | | Asynchronous replication / eventual consistency | Latency, availability | Staleness, conflict handling | | Read replicas and caches | Latency, read scale | Staleness, invalidation complexity | | Multi-region active-active | Availability, latency for users | Consistency conflicts, cost, complexity | | Single-region | Consistency, cost, simplicity | Availability during region outage | | Retries and queues | Availability perception, load leveling | Duplicates, ordering, DLQ operations | | Denormalized read models | Read latency | Rebuild tooling, storage, staleness | | Managed services | Operability, time to market | Lock-in, cost curve, less control |
Method: state the driver ranking first, then choose. A decision that cannot name what it sacrifices is untested. Record the tradeoff in the ADR; revisit when the ranking changes.
Availability and Latency Budgets
Availability arithmetic (composed dependencies multiply):
| Availability per dependency | Two in series | Five in series | |---|---|---| | 99% (3.65 d/yr down) | 98.01% | 95.1% | | 99.9% (8.77 h/yr) | 99.8% | 99.5% | | 99.99% (52.6 min/yr) | 99.98% | 99.95% | | 99.999% (5.26 min/yr) | 99.998% | 99.995% |
Implications:
- Every synchronous hop lowers composite availability; async decoupling removes a hop from the critical path.
- Redundancy and graceful degradation (cached/stale responses, read-only mode) buy availability where replication does not.
- Nines are per user journey, not per component; define the journey.
- SLOs: choose a service level objective, an error budget, and an alert burn-rate policy (fast burn pages, slow burn tickets). Review budgets monthly; freeze feature work if burned.
- Latency should be expressed as percentiles (p50/p95/p99), never averages, and measured where the user is, not only server-side.
Risk Register
Keep a living, short register; it is the ATAM's main durable output.
| ID | Risk | Likelihood | Impact | Attribute affected | Mitigation / owner | Review date | |---|---|---|---|---|---|---| | R-01 | Outbox relay lag grows under peak | Medium | High (stale reads) | Consistency, availability | Load test; lag SLO; fallback read path — owner: orders team | 2026-06 | | R-02 | Single-region database outage | Low | Critical | Availability | DR rehearsal quarterly; RTO 15 min — owner: platform | 2026-05 | | R-03 | Vendor SDK lock-in blocks multi-cloud | Medium | Medium | Portability, cost | Adapter boundary plus conformance tests — owner: architecture | 2026-09 | | R-04 | Team lacks Kafka operational experience | High | Medium | Operability | Managed service plus training; runbook — owner: platform | 2026-04 |
Rules: one owner and one review date per risk; update likelihood/impact at review; convert accepted risks into ADR consequences; never keep a register longer than a page or two — if it grows, prioritize and close.
Anti-Patterns
- Attribute-free architecture — decisions justified by "best practice" with no driver.
- Everything critical — no ranking, so nothing guides tradeoffs.
- ATAM as audit — multi-day ceremony producing a document nobody uses; keep it timeboxed.
- CAP as slogan — citing CAP without naming the operation and store.
- Average latency — hides the tail that users feel; use percentiles.
- Risk register as archive — entries never closed or reviewed.
- Ignoring cost — treating cost as non-architectural; it is an attribute with a budget.
- Aspirational diagrams in review — analyzing a design that does not exist.
Checklist
- [ ] Top 3-5 quality attributes ranked and written as measurable scenarios.
- [ ] Each driver maps to concrete decisions (ADRs) and enforcement (tests/SLOs).
- [ ] Failure modes designed and tested per dependency (partition, timeout, overload).
- [ ] CAP/PACELC choices named per store and operation.
- [ ] Tradeoffs stated: what is sacrificed for what, and where it is recorded.
- [ ] SLOs and error budgets exist for critical user journeys.
- [ ] Risk register has owners and review dates; risks updated, not accumulated.
- [ ] Review outputs (ADRs, actions) tracked to closure.
Related
- Recording the decisions that satisfy drivers: ./01-adr.md
- Style-level attribute comparisons: ./03-styles.md
- Consistency mechanisms behind the tradeoffs: ./06-data-architecture.md
- Publishing review outcomes: ./10-documentation-governance.md
10-documentation-governance
Documentation and Governance
Docs-as-code, architecture reviews, tech radar, decision logs, and keeping documentation alive.
Docs-as-Code
Documentation lives in the same repository and workflow as the code it describes: Markdown in version control, reviewed in pull requests, built and published by CI.
Principles:
- Same review flow as code; changes to structure and docs ship in one pull request.
- Docs have owners and freshness expectations, like code has maintainers and tests.
- Diagrams are generated from text sources; rendered artifacts are build products, not committed images (./02-c4-modeling.md).
- Searchable, internally linkable, and available where developers already are (repo, docs site, editor, portal).
- No private wiki islands for decisions; if it matters, it lives in the repo.
Typical toolchain (pick one path per org; verify current versions upstream):
| Need | Common choices | |---|---| | Static site | MkDocs Material, Docusaurus, Antora, VitePress | | Diagram rendering | Structurizr CLI, PlantUML, Mermaid, D2 in CI | | ADR rendering | log4brains, adr-viewer, Backstage ADR plugin, custom MkDocs generator | | Developer portal / catalog | Backstage-class portal with TechDocs and a software catalog | | Linting | markdownlint, Vale, lychee/link checkers | | Search | Site-native search or an external index for large doc sets |
Document Types
Separate types by purpose; mixing them produces documents nobody can use. Diátaxis is a useful frame.
| Type | Purpose | Architecture examples | |---|---|---| | Tutorial | Learning by doing | "Set up a new service from the template" | | How-to | Solve a specific task | "Add an event to the outbox", "Run a projection rebuild" | | Reference | Look up facts | Service catalog entry, API/schema docs, config keys | | Explanation | Understand why | ADRs, context map, quality attribute analysis |
Architecture document set for a system:
| Artifact | Answers | Owner | Review cadence | |---|---|---|---| | ADR log | Why decisions were made | Decision owners | On each decision; audit yearly | | C4 context/container diagrams | What the system is and how it is wired | System architect | Quarterly or on change | | Context map | How domains relate | Domain leads | On boundary change | | Quality attribute scenarios and SLOs | What "good" means | Product plus platform | Quarterly | | Risk register | What could go wrong | Architecture owner | Monthly | | Runbooks | How to operate and recover | Service team | On incident and quarterly | | Service catalog entries | Owner, tier, dependencies, SLOs | Service team | On change, verified quarterly | | arc42-style system doc (optional) | Full system description | System architect | On significant change |
arc42 remains a solid outline for a comprehensive system description (context, constraints, solution strategy, building blocks, runtime, deployment, crosscutting concepts, decisions, quality, risks). Link sections to the artifacts above rather than duplicating them.
Architecture Reviews
Review early, small, and often; a review is a conversation with an artifact, not a gate.
| Review type | Trigger | Scope | Duration |
|---|---|---|---|
| Design review | Before building a significant capability | One design, against drivers and boundaries | 60 min |
| Decision review | ADR in proposed state | One decision, options and consequences | 30-45 min async or 30 min live |
| Pull-request review | Any change touching a boundary | Concrete diff and fitness rules | As needed |
| Post-incident architecture review | After a severe incident | Failure mode, not blame | 60 min |
| Periodic health review | Quarterly per system | Drift, debt, risks, SLOs | 90 min |
| Cross-team brown bag | Monthly | Share patterns and lessons | 45 min |
Lightweight design-review agenda:
- Context and drivers (5 min) — which attributes, which constraints.
- Proposed design (15 min) — C4 container plus the key flows, failure modes.
- Tradeoffs and alternatives (15 min) — rejected options and why.
- Boundary and data ownership check (10 min) — owners, single writer, dependencies.
- Risks and decisions (10 min) — what ADRs are needed, what risks to register.
- Actions and owners (5 min).
Roles: one facilitator (keeps time), one scribe (records decisions and actions), reviewers with boundary expertise, and an accountable owner for the outcome. No status presentations, no solutions designed in the meeting.
Review checklist:
- [ ] Drivers and ranked attributes stated, not implied.
- [ ] Current versus target state clearly separated.
- [ ] Data ownership and single-writer rules hold.
- [ ] Failure modes addressed: timeout, retry, partition, overload, data loss.
- [ ] Operational story: deploy, rollback, observe, on-call.
- [ ] Cost impact estimated.
- [ ] Security and compliance implications named.
- [ ] Decisions captured as ADRs; risks registered with owners.
- [ ] Actions have owners and dates; follow-up scheduled.
Technology Radar
A radar communicates technology guidance without policy documents: what to adopt, trial, assess, or hold.
Structure:
- Quadrants — techniques, tools, platforms, languages and frameworks.
- Rings — Adopt (proven, default), Trial (use in real projects with support), Assess (spike only), Hold (do not start new work).
- Entries — name, ring, one-line rationale, movement (new/moved in/out), owner.
| Quadrant | Entry example | Ring | Rationale | |---|---|---|---| | Techniques | Transactional outbox | Adopt | Required for reliable event publication | | Techniques | Event sourcing | Trial | Strong audit value; steep learning curve | | Tools | Structurizr DSL | Adopt | Model once, render many views | | Platforms | Managed Kafka-compatible broker | Adopt | Operational burden removed; verify limits | | Languages/frameworks | Serverless functions for glue | Trial | Great for spiky loads; watch cold starts | | Tools | Hand-drawn architecture diagrams | Hold | Stale immediately; use diagrams-as-code |
Process:
- Collect nominations from teams (with use evidence).
- A small architecture group (or guild, not a gate) reviews and assigns rings.
- Publish with dates and rationale; revisit quarterly; record ring moves.
- Rings communicate guidance, not permission: teams may deviate with an ADR explaining why.
- Never let the radar outlive its rationale; publish review dates and archive stale entries.
Decision Logs
ADRs cover significant decisions. Everything else still needs a discoverable home:
- Meeting decisions — record decisions, actions, and owners in the meeting notes; promote any decision with lasting architectural impact into an ADR within the same week.
- Small technical choices — a "decisions" section in the relevant design doc or module README is enough; do not inflate them into ADRs.
- Deviations — a short ADR explaining the exception to a standard or radar "hold".
- Deprecations — announce with date, replacement, and a migration path; keep a deprecation page or ADR; track usage until zero.
Log hygiene: one searchable location per repo; dated entries; links between meetings, ADRs, PRs, and incidents; no decision buried in a chat thread.
Keeping Docs Alive
Doc rot is the default state; counter it with ownership and automation.
- Owner per document or section, recorded in front matter or the catalog.
- Freshness SLA — for example: runbooks quarterly, diagrams quarterly or on change, ADRs on change plus yearly audit, catalog entries verified quarterly.
- Automated checks in CI: markdown lint, Vale style, link checking (internal and external), Mermaid/PlantUML render, ADR index and status validation, diagram source changed alongside code when boundaries change.
- Doc tests: run the commands in how-tos in CI where feasible (template generation, seed scripts); at minimum, extract and compile code samples.
- Review hooks: pull requests that add a dependency, service, or integration require the catalog/diagram update in the same change (enforced by template or CI).
- Archive policy: move superseded docs to an
archive/area with a date; delete what has no historical or legal value; keep ADRs forever, delete stale how-tos. - Findability: consistent naming, one entry point (docs homepage or portal), search that covers ADRs and catalog.
Metrics that indicate health (use for trends, not targets to game):
| Metric | Healthy signal | |---|---| | % services with an owner and SLO in catalog | 100% | | ADRs created per quarter and superseded correctly | Non-zero, current | | Mean doc age for runbooks/diagrams | Within freshness SLA | | Broken-link count in CI | Zero | | Onboarding time to first production change | Falling | | Post-incident actions closed on time | Rising |
Anti-Patterns
- Wiki graveyard — docs in a separate tool that nobody updates; decisions live only in chat.
- Screenshot diagrams — committed PNGs with no source model; stale on first deploy.
- Review as gate — approval theater late in delivery; teams route around it.
- Radar as policy — using rings to forbid rather than advise; innovation dies at "Assess".
- Docs for everything — writing a design doc for a config change; signal drowns.
- ADR inflation — dozens of trivial ADRs; readers stop trusting the log.
- Ownership vacuum — no owner per doc, so freshness depends on goodwill.
- Metrics as goals — optimizing doc count or review count instead of decisions made and time to change.
Checklist
- [ ] Docs live in the repo and ship with code changes.
- [ ] Each artifact has an owner and a freshness SLA.
- [ ] CI lints, link-checks, and renders diagrams and ADR indexes.
- [ ] Review types and cadences are defined; reviews are timeboxed with actions tracked.
- [ ] Decisions from reviews and meetings become ADRs when significant.
- [ ] Tech radar is published, dated, and reviewed quarterly; deviations use ADRs.
- [ ] Catalog is complete: owner, tier, dependencies, SLOs for every service.
- [ ] Archive policy exists; superseded docs are moved or deleted deliberately.
- [ ] Onboarding path uses the docs and is tested with new joiners.
Related
- The decision records this governance keeps alive: ./01-adr.md
- Diagrams as code and freshness: ./02-c4-modeling.md
- Review inputs from quality attributes and risks: ./09-quality-attributes.md