Architecture
Make the change easy, then make the easy change. Start by understanding the domain language, workflows, and boundaries before adding structure.
Decision Tree
What kind of change is this?
|-- Simple script/tool
| `-- Use strong types, clear parsing at boundaries, and a small imperative shell
|-- New substantial behaviour
| `-- Sketch domain types, workflow, ports, and observable outcomes first
|-- Existing code is hard to test
| `-- separate decisions from effects; introduce purpose-named ports
|-- Domain states are unclear
| `-- model states explicitly and parse untrusted input at boundaries
`-- Failures are unclear
`-- make domain/application errors explicit and translate at the shell
Functional Core, Imperative Shell
Keep business decisions in a pure core where practical. Put I/O, framework objects, clocks, random IDs, environment access, and network/database calls at the shell.
Use the sandwich:
gather data and dependencies -> decide with pure values -> perform effects
Challenge effects that appear to need interleaving. Often the code can fetch eagerly, decide purely, then act once. Have the core return a value describing what should happen — a decision or a list of events — and let the shell perform it. Returning events rather than publishing them keeps the core pure and lets a test assert on the returned value.
Durable-execution engines (Temporal, Step Functions, Restate) enforce this rule at runtime: deterministic workflow code as the pure control flow, with IO, clocks, and randomness confined to activities (Temporal: "workflow code must be deterministic ... put non-deterministic operations in Activities"). They are also the sanctioned way to run genuinely interleaved effects safely - the case that relaxes "act once" when a flow is long-running (timers, human approval, retries over days), rather than a smell to design away.
Ports And Adapters
Define ports in the application's language, not the technology's language.
- Good:
Orders,Receipts,EmailDelivery,Clock - Weak:
PostgresClient,S3Helper,HttpManager
Adapters implement ports with specific technology. Application logic depends on ports and values. If tests for application decisions require real infrastructure, a boundary is probably missing.
Use fakes for owned ports in application tests. Use contract/integration tests to prove adapters fulfil the port.
Before adding a new adapter, audit existing ones: reuse through a narrow port, then extend an existing adapter when the capability fits, then create a new one only when reuse and extension would force bad coupling. Record a meaningful new adapter and its rejected alternatives where decisions are kept.
After sketching layers and adapters, classify the boundaries by enforcement
surface. A direct "X must not import Y" rule belongs in the lint stack; a
transitive "domain must never reach runtime/DB/routes" rule belongs in a graph
architecture test. The architecture skill owns the boundary language and
trade-offs; mechanical-enforcement owns the exact rule and hook.
Module Depth
Pull complexity downward. A module has more callers than authors, so a simple interface over a complex body beats the reverse: absorb the hard cases inside rather than exposing flags and knobs to callers. A layer whose interface is about as complex as its body is shallow — a pass-through method or thin wrapper that hides nothing adds interface cost for no gain, so merge or delete it.
This red flag targets abstraction layers that hide nothing — not deliberate
ports/adapters, nor pure pipeline steps kept for substitution or isolated
testability, which earn their seam. The typescript skill owns the mechanics
(deep, cohesive modules; the deletion test).
Domain Modelling
Parse, don't validate repeatedly. Convert untrusted inputs at the boundary into typed values that internal code can trust. Treat every inbound boundary this way — including your own database and configuration: parse rows and settings back into domain types on the way in rather than trusting them.
Store the input to a business rule, not the value it derives. Persist the raw
fact (dateOfBirth) and compute the derived value (age) on read, so it tracks
current rules; a stored verdict couples the model to today's rules and forces a
migration when they change. The exception is a decision you acted on: snapshot
its output as an immutable fact (charged price, order total, applied discount,
tax) precisely because it must survive rule changes — the same instinct as
OrderPlaced events.
Keep look-alike types separate. Two concepts that share fields today — billing vs shipping address, a validated vs a priced line — diverge under new requirements. Coincidental structural sameness is not a reason to unify; resisting DRY here lets each evolve independently.
Prefer:
- discriminated unions / ADTs for state machines
- wrapper types for meaningful primitives such as
EmailAddress,OrderId, orCustomerId— distinct even when the representation is identical (anOrderIdmust never be assignable where aCustomerIdis expected) and worthwhile even with nothing to validate, purely to stop mix-ups - precise names from the domain language
- bounded contexts with explicit translation between models
Avoid generic names like data, info, manager, and helper when the domain
has better words.
Make illegal states unrepresentable: model meaningful lifecycle states as
discriminated unions, not bags of isX/isY flags, so invalid combinations
cannot be constructed and need no runtime check. Avoid boolean blindness — no
boolean parameters that switch behaviour; use named options or domain types.
Booleans are fine as predicate return values.
Prefer strong types at boundaries and avoid type-system escape hatches unless
the project has a documented reason. Use mechanical enforcement for stack-level
rules such as no any, no non-null assertions, and strict type checking.
Error Handling
Use explicit error values in domain and application logic. Exceptions are fine at the imperative shell; catch and translate them there.
Triage every failure into one of three kinds:
- domain errors — expected business outcomes; model them as typed values in the domain language
- panics — bugs and impossible states; throw and let them crash, caught once at the top
- infrastructure errors — timeouts, auth, outages; handle per architecture, and promote to a domain error when the business outcome changes (then ask a domain expert what should happen)
Make expected failures part of the use-case flow. Preserve causes when wrapping unexpected infrastructure failures. Keep the happy path readable without hiding failure handling.
Define errors out of existence where the domain allows. Before adding an error
branch, try broadening the operation so the awkward input has an ordinary result
— model "no selection" as an empty range, make remove ensure-absent rather than
fail on a missing key. A deleted branch beats a well-handled one: illegal states
made unrepresentable, applied to behaviour rather than data. This is not licence
to swallow real failures — if a domain expert would want the edge surfaced, it is
a domain error: keep it a typed value and let the triage stand.
Observability
Design system boundaries with observability in mind:
- structured logs over free-text logs
- operation names
- relevant entity IDs
- request/correlation IDs
- timing and outcome at HTTP, database, queue, and external-service boundaries
The core should decide what happened. The shell should record it with the context needed to debug production behaviour.
Across a message boundary the same discipline needs trace context propagated in
message headers rather than carried on a call stack; the
event-driven-architecture skill's "Reaching Out" covers async tracing (W3C Trace
Context, OTel messaging conventions).
Configuration And Lifecycle
Parse configuration at startup, or the earliest boundary, into typed values with useful failure context. Do not read environment or settings throughout the code.
Avoid top-level side effects outside true entrypoint/bootstrap code: modules
should not open connections, read configuration, register handlers, or start
servers at import time. Own resource creation and cleanup explicitly in the
shell. Inject clock and randomness into dependency-bearing code; let pure
functions take time and random values as arguments. The env/clock/rng bans are
mechanically enforceable — see the mechanical-enforcement skill's purity rules.
Wire dependencies in one composition root in the bootstrap/entrypoint and pass them inward as explicit arguments. That single wiring point is also the one place to substitute every dependency with a fake in tests, which beats patching imports. A port can be a plain function for a single-method dependency — reserve a richer interface for a genuinely multi-method one. Reach for manual injection once you have more than one adapter, and for a dependency-injection framework only when dependencies have their own dependencies (chained graphs); below that it is overengineering.
Workflows as Pipelines
Reach for this apparatus — workflows-as-pipelines, aggregates, domain events, bounded contexts — where business complexity and domain-expert collaboration justify it: the core domain. For technical, generic, or simpler subdomains, plain functions, a single transaction, and strong types are enough; don't impose the ceremony. DDD is not appropriate for all software — match the modelling style to the domain (see Scale Rule).
Model each use case as one workflow: a command in, a list of domain events out,
contained in a single bounded context. Name events as past-tense facts
(OrderPlaced), distinct from the command that requests them — a command may
fail; an event is a fact that happened.
Compose a workflow from small single-purpose steps wired output-to-input. Give each step a typed input, a typed output that includes its failure case, and explicit dependencies. Keep each step stateless and pure so it is testable in isolation; push I/O to the ends.
Two weights of "events": returning events as values from the core — a list of
what happened, instead of a void mutation — is cheap and broadly worthwhile,
even in simple code. Event sourcing and async event choreography across services
are heavy; use them only when the coordination genuinely warrants it, not as a
default. Cross-context scenarios are then choreographed by events, not one giant
function. Once an event crosses a process or service boundary, the
event-driven-architecture skill owns the mechanics — propagation, reliable
publication, delivery semantics, and versioning.
Workflows, Transactions, Idempotency
An aggregate is both the consistency boundary and the unit of persistence: route all changes through its root, and update only one aggregate per transaction. Link aggregates by id, never by embedding one in another. When an operation seems to need two aggregates atomically, suspect a missing entity (model the operation itself) or use eventual consistency. Across services, prefer async events to distributed transactions, with an explicit recovery path — reconcile or compensate. Eventual consistency is not optional consistency; it must still converge.
Use a plain call or a single database transaction for simple single-boundary
operations. Reach for a saga or durable workflow when the process needs retries,
compensation, idempotency, resumability, timers, human approval, or coordination
across services and multiple transaction boundaries. Both buy ACD, not ACID - you
get atomicity, durability, resumability, and compensation, but not isolation, so
intermediate states stay visible; design the countermeasures the
event-driven-architecture skill's topology.md lists (semantic locks, a
pending status, re-reads).
Do not hold a database transaction open across network calls or long-running
work. Any command, job, or step that may be retried needs an explicit
idempotency strategy — idempotency key, natural unique constraint, deduplication
record, state-machine guard, or transactional outbox/inbox (see
event-driven-architecture for the outbox/inbox mechanism and idempotent
consumers). Do not rely on "probably safe" repeated side effects.
Concurrency control is distinct from idempotency: idempotency makes a retry safe;
concurrency control stops two simultaneous writers clobbering each other — the
lost update. Hold the consistency boundary under concurrent writes by versioning
the aggregate (optimistic locking): bump a version on write, let one transaction
commit, and make the loser reload and retry. Reach for pessimistic locks
(SELECT ... FOR UPDATE) when conflicts are frequent and a retry is expensive,
minding deadlocks; raising the isolation level to SERIALIZABLE lets the database
enforce the rule but is slower, so prefer a targeted version check. Pick by
conflict rate and the cost of a lost update.
Make the transaction boundary safe by default: the only path that commits is total success plus an explicit commit, and any exception or early exit rolls back. Design the default to change nothing and require a positive act to persist.
Reads And Writes
Separate reads from writes. At the call level, a function either changes state or
answers a question, never both (command-query separation). At system scale the
same split is CQRS: the write model is shaped by invariants, not by how screens
query it — a domain model is not a data model — so reads need not travel through
the aggregate. Default to the same store and repository for both. Reach for a
separate read model — a denormalised view keyed for the query, kept fresh from
the domain events the write side already emits — only when the read shape
genuinely diverges or a performance wall demands it. This is an in-process read
model fed by your own events; a consumer in another service keeping its own
replica from your published events is event-carried state transfer — a different
thing with its own contract, see event-driven-architecture. Treat CQRS as a
last resort, not a default: splitting read-only views out from command handlers
captures most of the benefit without a second store.
Scale Rule
Scope the investment by domain, not just by size. For simple scripts, strong types and a clear gather/decide/act flow are enough. For a substantial core domain — the part that differentiates the business — use explicit ports, typed domain models, aggregates where consistency demands them, and a walking skeleton that proves one end-to-end use case before expanding. For supporting subdomains, model lightly; for generic ones (auth, billing, search, notifications), buy or adopt an existing solution rather than modelling it yourself.