Docs/Decisions/ADR-0001 Modular Monolith

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-jobs in 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:

  1. One codebase, one image, several shapes. api, jobs (and its pools), worker and migrate run the same modules, selected by Spring profile.
  2. 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.
  3. Scale by workload class and replica, not by module (ADR-0014), in this order: more api replicas; job pools for background work (ingest, webhooks, intake, billing); cells (a full stack per tenant group) when one cluster is saturated.
  4. 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-sandbox and services/mcp-stdio-runner are the existing examples.
  5. 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.
flowchart LR subgraph reactor["core/ — Maven reactor (~40 modules)"] tenancy["tenancy"] & security["security"] & chat["chat"] & workflow["workflow"] & billing["billing"] end reactor --> app["core/app — the one deployable JAR"] reactor -.-> worker["core/app-worker"] & jobs["core/app-jobs"] app --> pg[("Postgres — schema-per-tenant")]

Alternatives considered

OptionWhy 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

Negative / costs

Evidence & references