# Architecture Decision — Two-Axis, Complexity-Matched

Reference for `/gsd:recommend-architecture`. Recommends an architecture that **matches complexity** — explicitly avoiding both over- and under-engineering. Consumes `DOMAIN-MODEL.md` (subdomain complexity). Recommends; the user decides.

## Core principle: two INDEPENDENT axes

Keep these separate — conflating them is the #1 architecture error.

- **Axis A — domain-logic organization:** Transaction Script → Domain Model → (Hexagonal/Clean wrapper) → CQRS → Event Sourcing. Driven by **domain complexity** (decide *per subdomain*, using DOMAIN-MODEL).
- **Axis B — deployment topology:** Modular Monolith → Microservices. Driven by **team structure + NFRs + ops maturity** — *not* by complexity.

The common sweet spot is a **Domain Model inside a modular monolith**. High scale does not imply a Domain Model; rich domain logic does not imply microservices.

## Axis A — domain logic (decide per subdomain)

Use the core subdomain's complexity from DOMAIN-MODEL. Apply per subdomain: the complex core may warrant a Domain Model (± Hexagonal); supporting/generic subdomains stay Transaction Script.

**Strategic vs tactical DDD (don't conflate).** *Strategic* DDD — ubiquitous language + subdomain classification (core/supporting/generic) — is cheap and **universal**; it's done in `model-domain` regardless of the rung chosen here (Evans himself regretted over-emphasizing the building blocks). The rungs below are *tactical* (aggregates, value objects, repositories, domain events) — they earn their place **only in a genuinely complex core subdomain**, never as a default.

### The floor — the cheap baseline, even for simple projects

Below the rungs sits a baseline that applies **even to simple/short-lived projects** and is explicitly **NOT full hexagonal**: **dependency inversion at *true external boundaries only* (DB, 3rd-party APIs, clock/IO) + a Functional Core / Imperative Shell shape (pure logic separated from side-effecting glue) + strong, independent tests.** This is the lighter discipline the senior voices on *both* sides of the "always hexagonal?" debate converge on — it delivers the pro-camp's real prize (day-one isolated testability) at a fraction of hexagonal's cost, with **no internal port ceremony**. Extract a real internal port only "when you feel the second adapter appearing."

Transaction Script is the domain-logic *organization* at the floor — it still sits **behind** this seam discipline. **Do not read "Transaction Script floor" as "no seams":** a CRUD app that reaches straight into the DB/3rd-party from everywhere, untestable without the real services, is **under-engineered even though it is simple** — it skipped the floor. The floor is the cheap minimum; the rungs below are about how much *domain-logic structure* to add on top of it.

| Rung | Move up when | Over-engineering tell |
|------|--------------|-----------------------|
| **Transaction Script / simple layered CRUD** (floor) | "validate → persist → return"; few rules; supporting/generic subdomains | — |
| **Domain Model** | business rules multiply and tangle; the same invariant is duplicated across scripts; rich conditional behavior; long-lived core | rich aggregates wrapping what is really CRUD; anemic getter-bag "domain" objects |
| **Hexagonal / Clean wrapper** (orthogonal — wraps either above) | a **current, concrete** second adapter or delivery mechanism (DB/queue/3rd-party swap, second front-end); or a genuinely pure core worth isolating for test speed | ports/interfaces with exactly one forever-implementation; DTO-mapping boilerplate around a CRUD endpoint; a wrapper claimed on lifespan or abstract "testability" alone; **layering hexagonal/clean on top of an opinionated framework (NestJS/Spring/Rails) that already supplies IoC + interface-DI + module boundaries — when the domain has no real behavioral complexity or volatile dependency, the framework IS the architecture; full hexagonal there only duplicates it** |
| **CQRS** | read and write models genuinely diverge; reads ≫ writes; write model under strain | separate read/write stacks where one model serves both fine |
| **Event Sourcing** | audit/temporal history is a hard requirement (finance, compliance, "reconstruct past state") | ES on a simple entity with no audit/temporal need |

## Axis B — deployment topology

- **Modular Monolith — the DEFAULT for greenfield.** One team; domain still being learned; few moving parts; fast to change. This is the recommended floor. Enforce internal module boundaries (separate schemas, dependency rules). Modules come from DOMAIN-MODEL: **modules = bounded contexts** when mapped, else subdomain groupings; flagged polysemes resolve to one owning module each; an **ACL applies now** to any third-party/legacy integration whose model differs from yours — not only at a future split.
- **Microservices — only when ALL "you must be this tall" gates pass:**
  1. **Multiple independent teams** needing independent deploy cadence (Conway / Team Topologies).
  2. **CD / monitoring / DevOps maturity** already in place.
  3. **Bounded contexts well-understood** already (not still being discovered).
  If **any** is "no" → recommend **modular monolith and stop on the microservices question**, regardless of complexity — the per-component Hard-Parts scan below still runs when a single component shows divergent pressure (deferred, not forbidden — record the promotion trigger; see *Evolving the topology* below). (The "microservice premium": below a complexity+org threshold the distributed tax is pure loss.)
- **Component-level split (Hard Parts):** for a specific component, score the **6 disintegrators** (low cohesion · divergent volatility · divergent scalability · fault isolation · differential security · independent extensibility) against the **4 integrators** (ACID across the data · tightly-coupled workflow · heavy shared code · tight data relationships). Net disintegrators ≫ integrators → extraction **candidate**: extract now only if the pressure is **current (not projected)** and the CD/ops gate passes — otherwise it becomes that component's promotion trigger. Integrators dominate → keep it in the monolith.
- **Distributed monolith** (services that can't deploy independently) is the failure mode — you pay the premium and get none of the autonomy. Avoid.

The "modular monolith — stop on the microservices question, regardless of complexity" rule is about *not splitting prematurely* — it is **not** "never split." It means the split is deferred until a gate flips, and the modular boundaries are built now so the split is cheap later (a **sacrificial / evolutionary** architecture). Record the **promotion trigger** — the concrete future signal (a second team forms, a component's scaling diverges, a bounded context stabilizes) that would justify revisiting Axis B.

## Evolving the topology — decomposition & migration (when a gate later flips)

When a promotion trigger fires and a component genuinely warrants extraction, the *data* is the hard part — splitting logic is easy, splitting a shared database is not. Recommend, in order:

- **Strangler Fig** — route new behavior to the new component while the old path keeps serving, shrinking the monolith incrementally. Never a big-bang rewrite.
- **Anti-Corruption Layer (ACL)** — a translation seam at the new boundary so the extracted component's model isn't polluted by the legacy/shared schema's vocabulary. The ACL is also the right tool when integrating a third-party/legacy system whose model differs from yours.
- **Data decomposition** — pull the component's tables behind its own schema/owner first (enforce "no cross-module DB access" as a fitness function *before* extracting), then separate the datastore. Identify the data that must move vs. the data that stays shared (and gets an API/ACL instead).
- **Sagas / outbox for cross-service consistency** — once a transaction spans two services you lose ACID; replace it with a **saga** (a sequence of local transactions + compensating actions) and the **transactional outbox** pattern for reliable event publishing. If a workflow genuinely needs one ACID transaction, that's an *integrator* — a reason to **keep it together**, not split it.

The same tools run in reverse for a brownfield monolith you're decomposing — strangle, wrap legacy in an ACL, decompose the data behind module boundaries first.

## Non-functional drivers (quick matrix)

| Driver | Low → | High → pushes toward |
|---|---|---|
| Domain complexity | Transaction Script | Domain Model (+ Hexagonal) |
| Read/write asymmetry | single model | CQRS |
| Audit / temporal requirement | normal persistence | Event Sourcing |
| Scale (uniform) | monolith + replicas | still monolith — scale ≠ microservices |
| Scale (divergent per component) | modular monolith | extract that component (disintegrator) |
| Availability / fault isolation | monolith | isolate failure-prone component |
| Differential security | one trust boundary | separate the stricter-security component |
| Integration count (adapters) | direct calls | Hexagonal ports & adapters |
| Expected lifespan | short → keep simple (sacrificial) | long → Domain Model + fitness functions (Hexagonal only with a real second-adapter/delivery signal) |
| Team count / ops maturity | 1 team / low → monolith | many independent teams / high → microservices viable |
| Tenancy isolation (multi-tenant) | shared schema + tenant-scoped RLS (the default) | contractual/regulatory isolation mandate → schema-per-tenant → DB-per-tenant |
| High-volume ingestion / pipeline | normal tables | decide the pipeline shape (buffer/queue, backpressure, retention) — the rung covers logic only |
| Async work inside the monolith | direct in-process calls | in-process events / job queue (+ outbox once events must cross a process boundary) |

## Over- AND under-engineering

**Over-engineering tells:** one-implementation ports; rich aggregates over structural CRUD; CQRS/ES with no asymmetry or audit need; "microservice envy"; distributed monolith; elaborate config/abstraction "wanting all options all the time."

**Under-engineering tells:** the same invariant duplicated across many transaction scripts; a big-ball-of-mud monolith with no enforced module boundaries; a complex/regulated domain modeled as thin CRUD; **no seam at the true external boundaries (DB/3rd-party reached into from everywhere), so the code can't be tested without the real services — the Functional-Core/Imperative-Shell floor was skipped, even on a simple app**; no audit trail where compliance needs it; no ADRs / no fitness functions.

## The AI-coding era moves the FLOOR up a notch (not the ceiling)

The evidence (agent-reliability studies) shifts *where the floor sits*, not whether the higher rungs are universal:
- **Seams pay from the first agent session.** Agent reliability collapses as codebase scale and files-touched-per-change rise, and the bottleneck is **architectural legibility, not context-window size** (bigger windows don't rescue large tangled repos). Clean boundaries help the *agent* navigate and verify — so the Functional-Core/Imperative-Shell floor + strong independent tests are worth more now, even on simple projects.
- **Get the *seams* right early; defer *feature* speculation.** AI is **bad at adding architectural seams later** (agent success on compound/architectural refactors is low — well under half in the agent-reliability studies) but **good at adding features later** — so cheap-AI *weakens* YAGNI for core seams (build them now) and *strengthens* it for speculative features (defer them).
- **Don't over-generate structure.** AI writes structure cheaply but **maintains it worse** (duplication rises, drift) — so this moves the floor *up a notch*, NOT toward always-hexagonal. Prefer **deep modules, not shallow many-file layering** (the "design for agents" consensus converges with classic good design on tests/interfaces/naming/deep-modules; it diverges only by penalizing indirection *depth*).
- **Tests must be *independent*.** Because agents reward-hack tests, the lever is test *independence + quality* (human-owned/agent-non-editable assertions; verify beyond green), not test-*first ordering*. See `test-strategy.md` / `ai-test-quality.md`.

Net: raise the floor (seams + independent tests earlier), keep the ceiling calibrated (Domain Model / full hexagonal / CQRS / ES still require their concrete signals — the originators themselves reserve them for the complex core).

### The meta-tell

**The meta-tell (use this to settle every rung *above the floor*; the floor itself is always-on and exempt — it is the baseline, not a rung):** if you cannot point to a **current, concrete** requirement — a real second adapter or delivery mechanism, a real divergent-scaling component, a real second team, a real audit mandate, a real tenant-isolation mandate, a genuinely pure core isolated for test speed — that justifies a rung, you are **over-engineering**. If such a requirement exists and you ignored it, you are **under-engineering**.

## Default baseline (when in doubt)

**The floor (Functional-Core/Imperative-Shell + dependency inversion at true external boundaries + strong independent tests) everywhere — then modular monolith + Domain Model only in the complex core bounded contexts + Transaction Script in the simple/CRUD ones + ADRs + boundary fitness functions.** This is the modern consensus starting point: a cheap testable floor for all, rich structure only where complexity earns it.

## Always

- Record the decision as an **ADR** — capture the *why*, the trade-offs, and the alternatives rejected (*why* matters more than *how*).
- Recommend **fitness functions** to enforce the chosen boundaries (e.g., "no cross-module DB access," module dependency rules) so a modular monolith doesn't rot.
- Frame as a **sacrificial architecture** where appropriate (a monolith you may later split is fine if it gets you to market and teaches the boundaries).
- **Recommend, don't dictate.** Present the trade-offs and your recommended option with rationale; the user has context you lack — let them choose.

## Consumes / produces

- **Consumes** `DOMAIN-MODEL.md` (core/supporting/generic + complexity) → Axis A per subdomain. If absent, ask the complexity questions directly (and suggest running `/gsd:model-domain` first).
- **Produces** `.planning/adr/NNNN-architecture.md` (the decision) + an architectural-decisions table. Feeds `testing-strategy` (test shape follows architecture) and `plan-phase`.
