Blueprint 01 · Structural
The Modular Monolith with a Pure Domain Core
One thing to deploy. Rigid layering inside. The business rules that matter most live as pure, side-effect-free functions — testable in isolation, swappable in place, and ready to be carved into services the day scale demands it.
A monolith you won't regret
Microservices are an organisational tool that is frequently paid for in distributed-systems tax — network failures, eventual consistency, deployment choreography — long before a team is big enough to need them. This blueprint takes the opposite bet: keep one deployable, but build it as if it were already several.
The trick is internal discipline. Each feature is a self-contained module with the same three-layer shape, every module talks to the database through a single repository boundary, and the genuinely important logic is pulled out into a pure core that knows nothing about HTTP, the ORM, or the framework. The result feels like a small system to run and a large one to reason about.
The expensive part of a service split is not the network plumbing — it's untangling business logic that has leaked into controllers, jobs, and database triggers. If the logic is already isolated and pure, the split becomes mechanical: lift a module, give it its own process, and replace an in-process call with a network call. The hard work was done up front, cheaply.
Three layers, one pure core
Thin transport layer
Controllers / route handlers do only transport work: validate input, resolve identity and tenant from the session, enforce roles, then delegate. No business logic lives here.
Pure domain core
Scoring engines, workflow state machines, and rule evaluators are pure functions: feed them a context object, get a decision back. No database, no clock, no network — so they are trivially unit-testable and fully deterministic.
Single repository boundary
Every database access goes through one repository / storage abstraction per module. Domain code persists only through it, so the ORM is mockable in tests and swappable in production.
One contract, defined once
A recurring multiplier in these systems is a single source of truth for the data contract. The database schema is declared once; validation schemas and language-level types are derived from it; and both the server and the client import the same definitions. There is no second place for the shape of an entity to drift. When a column changes, the type error shows up everywhere that matters at compile time, not in production.
When the domain is a multi-step process with gated transitions — intake, review, approval, closure — the pure core is best expressed as a finite state machine. The application layer hydrates a transition context from the database; the FSM is a pure function that returns either a valid next state or a rejection. Only valid transitions are ever persisted, and the riskiest logic in the system has zero I/O to mock.
How the pieces sit together
flowchart TB
subgraph client["Client tier"]
SPA["Single-page app / installable client"]
end
subgraph deploy["Single deployable process"]
direction TB
RT["Transport layer
routes · validation · authZ"]
subgraph mods["Feature modules"]
direction LR
M1["Module A"]
M2["Module B"]
M3["Module C"]
end
CORE["Pure domain core
engines · FSM · rules
(no I/O)"]
REPO["Repository boundary
(single SQL surface)"]
end
SHARED["Shared contract
schema → types → validators"]
DB[("Relational database")]
SPA -->|HTTP / JSON| RT
RT --> mods
mods -->|"call with context"| CORE
CORE -->|"decision / result"| mods
mods --> REPO
REPO --> DB
SHARED -.imported by.-> SPA
SHARED -.imported by.-> RT
SHARED -.shapes.-> REPO
Component responsibilities
| Component | Owns | Must never |
|---|---|---|
| Transport layer | Input validation, session/identity/tenant resolution, role checks, response shaping | Contain business rules or touch the ORM directly |
| Feature module | Orchestrating a use case: load state, call the core, persist results, emit events | Reach into another module's tables |
| Pure domain core | The decision itself — scoring, transitions, rule evaluation | Perform I/O, read the clock, or import the framework |
| Repository boundary | Every read and write for its module; the only place SQL/ORM lives | Encode business logic or cross module lines |
| Shared contract | Canonical schema, derived types and validators used by all tiers | Have a second, hand-maintained copy anywhere |
The path of a single operation
sequenceDiagram
participant C as Client
participant T as Transport layer
participant M as Feature module
participant K as Pure core
participant R as Repository
participant D as Database
C->>T: Request + session cookie / token
T->>T: Validate input · resolve tenant & role
T->>M: Delegate use case
M->>R: Load current state
R->>D: SELECT
D-->>M: State
M->>K: evaluate(context)
K-->>M: Decision (pure, deterministic)
M->>R: Persist result + audit entry
R->>D: INSERT / UPDATE (one transaction)
M-->>T: Result
T-->>C: JSON response
Derived values — deltas against a baseline, threshold alerts, reconciled scores — are calculated inline at write time by the pure core rather than by a separate background worker. Until throughput genuinely demands asynchronous processing, this keeps the system to a single moving part and makes every result reproducible from its inputs.
Five alternate use cases
Loan origination engine
Eligibility, risk scoring and offer generation are pure functions over an applicant context; the monolith orchestrates KYC, underwriting and disbursal as gated FSM stages — auditable and easy to certify.
Order & fulfilment workflow
An order moves placed → picked → packed → shipped → delivered as a state machine. The pure core validates every transition; the repository keeps each warehouse's data behind one boundary.
Assessment & grading platform
Rubric scoring and progression rules live in a deterministic core that can be replayed and unit-tested against fixtures, while the surrounding monolith handles enrolment, submissions and reporting.
Permit & licence issuance
Application review with mandatory approval steps maps cleanly to a gated FSM; the single-deployable footprint suits constrained on-premise or state-data-centre hosting.
Subscription & entitlement service
Plan changes, proration and feature gating are pure calculations over an account context; one deployable keeps early operating cost low while the boundaries pre-stage a later billing-service split.
What this costs you
| Risk | Why it happens | Mitigation |
|---|---|---|
| Boundary erosion | Under deadline pressure a module reaches into another's tables, quietly recreating a distributed ball of mud inside one process | Enforce module isolation with import/path-alias rules in CI; one repository per module; code review on cross-module access |
| Single scaling unit | A hot path forces you to scale the whole process, paying for idle capacity in cold modules | Scale horizontally behind a load balancer first; the pre-drawn seams let you peel off only the hot module when that stops being enough |
| Deploy blast radius | One bad change can take down every feature at once | Strong typing from the shared contract, comprehensive pure-core tests, and progressive rollout / health checks |
| "Flexible column" sprawl | Schema-light JSON sub-documents avoid migrations but can drift into undocumented, unvalidated shapes | Validate JSON sub-documents against derived schemas; promote stable fields into real columns over time |
Getting trust right inside one process
Because everything shares a process, it is tempting to trust data passed between layers. Don't. Resolve the acting user, role and tenant from the verified session at the transport edge and pass them down explicitly; never accept them from the request body. Apply ownership and tenant checks in the handler, not just at login.
- Hash credentials with a slow algorithm; strip secrets from every response shape at the boundary.
- Make auditing a cross-cutting concern: write an append-only entry — ideally with before/after snapshots — in the same transaction as the mutation, so the trail can never disagree with the data.
- Beware the convenient defaults a monolith accumulates: fallback session secrets, insecure cookie flags, seeded default passwords, and destructive "reset" endpoints guarded only by a confirmation string. Gate all of these behind real environment configuration before production.
When to reach for it — and when not to
Use it when
- A small team needs to ship a feature-rich product fast without operating a fleet of services.
- The domain has clear, valuable business rules worth isolating and testing exhaustively.
- You want the option to split into services later without betting the architecture on it now.
- Operational simplicity and low cost matter — constrained hosting, early-stage budgets.
Avoid it when
- Different parts genuinely need independent scaling, languages, or release cadences today.
- Multiple autonomous teams will contend over one codebase and one deploy pipeline.
- Components have wildly different availability or compliance isolation requirements.
- The team lacks the discipline to keep module boundaries honest — without it, this degrades fast.
Want this pattern applied to your build?
These are the structures we reach for. Tell us the system you're trying to build.