ADR-0001: Modular monolith over microservices
- Status
- accepted
- Decided
- 2026-07
- Deciders
- Platform Engineering
- Amended
- 2026-10-15: the ~7-service decomposition target is withdrawn; the target architecture below replaces it
- Related
- ADR-0014 horizontal scaling, Architecture overview, Structural audit; see
shape-api/shape-jobsin the Glossary
Context
The platform spans agent runtime, chat, workflow execution, billing, tenancy, channels and governance — domains that share transactions constantly. A chat turn touches guardrails, knowledge retrieval, usage metering and audit inside one request; tenant provisioning writes across the control plane and a fresh tenant schema in one shot.
The team is very small. Every additional deployable would carry its own pipeline, on-call surface, schema coordination and observability wiring. The platform needed clean internal boundaries for a future decomposition, but could not afford to pay the microservices operating tax up front.
Decision
The backend ships as one Spring Boot 3.4 deployable,
core/app, assembled from a ~40-module Maven reactor
(core/pom.xml; the platform overview counts the satellite assemblies
for a headline figure of 42). Contracts are enforced by Maven module boundaries:
each module is its own artifact with explicit dependencies — audit,
billing, crypto, secrets,
storage, speech, mcp, mcpserver,
security, admin, sharing,
providers, tenancy, chat,
eventbus, approval, knowledge,
guardrails, google, sarvam,
imagegen, elevenlabs, sdlc,
eval, graphmemory, codeintel,
documentgen, agents, quality,
voice, omnichannel, devices,
workflow, platform, rl,
templates, app-config — plus the three deployable
assemblies app, app-worker and
app-jobs. Several modules are additionally gated behind Spring
profiles (security, knowledge, workflow,
graph-memory, temporal), which is how a deployment can
shed a capability without forking the codebase.
This is a documented decision, not an accident of growth. The monolith is the target architecture, not a waypoint on the way to microservices.
Target architecture (amended 2026-10-15)
An earlier internal plan named a capability-grouped split into ~7 services
(identity-gateway, agent-runtime,
workflow-engine, knowledge-service,
omnichannel-gateway, integrations-service,
platform-admin) under a strangler-fig migration. That target is
withdrawn. Most of those services would sit on the agent-turn path (auth,
guardrails, tools, retrieval, the LLM call, metering, audit), where a network hop
adds latency to every turn and a new partial-failure mode to every stream
(AGENTS.md rule 6). No work had started on it.
The target is a modular monolith with satellite services:
- One codebase, one image, several shapes.
api,jobs(and its pools),workerandmigraterun the same modules, selected by Spring profile. - Ports and adapters inside each module. A module exposes a
service interface and DTOs; its repositories, entities and vendor adapters stay
behind that port. Other modules and controllers call the port, never its
repositories. Maven module edges and the frozen ArchUnit baselines
(
StructureRulesTest,ModuleBoundaryRulesTest) enforce this, and the baselines only shrink. - Scale by workload class and replica, not by module
(ADR-0014), in this order:
more
apireplicas; job pools for background work (ingest, webhooks, intake, billing); cells (a full stack per tenant group) when one cluster is saturated. - Extract a satellite service only when one of these holds:
the work is asynchronous or background; it runs untrusted code that needs
isolation; it needs a different runtime (Python ML, Node); or it must fail or
scale independently and is off the turn path. Being a separate "domain" is not
a reason.
services/sidecar,services/code-intel,services/tool-sandboxandservices/mcp-stdio-runnerare the existing examples. - Extracting a service needs its own ADR that names which criterion it meets, the port it replaces, and how the turn path stays in-process.
Alternatives considered
| Option | Why it lost |
|---|---|
| Microservices from day one | ~20 module-sized services would mean ~20 pipelines, databases and on-call surfaces for a team measured in single digits — and distributed transactions across agent/chat/workflow/billing where the monolith today just commits. Rejected as unaffordable, not merely unnecessary. |
| One microservice per Maven module | The wrong granularity: services too small to justify their own deployment and on-call. |
| Capability-grouped services (~7) via strangler fig | Adopted as the eventual target in 2026-07, withdrawn 2026-10-15. Most of the groups (agent-runtime, identity-gateway, knowledge-service, integrations-service) are called inside an agent turn, so splitting them puts network hops on the hot path. ADR-0014 meets the scaling need with shapes, pools, replicas and cells instead. |
| Unstructured monolith (one module, packages only) | Package-private boundaries are unenforced and rot immediately under multi-agent development. Maven module boundaries are compiler-enforced contracts, and they keep a satellite extraction cheap when one of the criteria above is met. |
Consequences
Positive
- One deploy, one Flyway chain, one healthcheck path — the whole operational
surface fits a single
corecontainer (Compose) or Deployment (Helm). - Cross-domain operations (chat → guardrails → billing → audit) are ordinary transactions, not saga choreography.
- Module boundaries are real: the reactor enforces them at compile time and
ArchUnit baselines keep them from regressing. Heavy work that needs its own
scaling curve already has a home without a split: knowledge ingestion runs in the
core-ingestpool and delegates ML to the Python sidecar. - Spring profiles give a partial "which capabilities run" dial without
splitting the deployable (e.g.
workflowwas dropped fromSPRING_PROFILES_ACTIVEin some deployments).
Negative / costs
- Scaling is per shape and pool, not per module: the
apishape scales as a whole, and a workload that needs a different runtime or isolation must still become a satellite service, which is whyservices/sidecar/,services/code-intel/and the sandboxes run outside the monolith. - One bad bean can take the whole platform down — a real failure mode seen
when
TemporalConfig#temporalActivityExecutor(anExecutorService) made Boot's auto-configuredapplicationTaskExecutorback off and the app failed to boot; fixed by declaring the bean explicitly inCoreApplication. - A signature change in a shared module is only safe when verified by the
full reactor build the Dockerfile runs (
mvn -pl app -am package), not by-plon the touched modules — learned after a stale caller incore/app/.../quicktask/surfaced only inside the image build. - Cold start is heavy (about a minute of Spring + Flyway boot on a hosted instance), so scale-to-zero is not practical; first boot of a fresh database takes longer (all migrations).
Evidence & references
README.md§1 — "A modular monolith, not microservices — a deliberate, documented decision."core/pom.xml— the module list;core/appas the assembly.core/app-worker,core/app-jobs— the sibling assemblies from the same codebase.