Skip to content

ADR 0001 Modular Monolith

Tiana_ edited this page May 30, 2026 · 1 revision

ADR-0001: Modular monolith with extraction roadmap

Status: Accepted Date: 2026-04-25 Decider: Maintainer

Context

FinCore Engine spans multiple bounded contexts (Ledger, Payments, Compliance, Decision Engine, Webhooks, Outbox). Two architectural extremes:

  1. Microservices from day one - each context is a separate service, separate repo, separate deployment, separate database. Industry's go-to story for "scalable" architecture.
  2. Pure monolith - single application, single codebase, no boundaries. Fast to build, painful to scale or extract later.

For a v0.1 OSS release prioritizing developer adoption (target: docker compose up works in 30 seconds) and built by a single maintainer, both extremes are wrong:

  • Microservices from day one means: 6+ services to operate, distributed sagas, gRPC tooling, eventual consistency surprises, debug-across-services overhead. Adopters give up trying to run it locally. The OSS dies in obscurity.
  • Pure monolith means: when extraction is needed (Y1 H2 onward as services need independent scaling), the boundaries are entangled and refactoring is a multi-month nightmare.

We need the simplicity of a monolith with the boundary discipline of microservices.

Decision

Adopt modular monolith for v0.1 with an explicit roadmap to polyrepo + microservices when adoption demands it.

Concretely:

  • All bounded contexts live as separate Gradle modules in the same repository
  • Each module has its own DB schema (logical separation in single Postgres instance)
  • Modules talk to each other only through published interfaces in a api-prefixed module - no direct entity imports
  • Module-private classes use Kotlin internal visibility
  • ArchUnit tests in CI prevent cross-module entity leaks
  • Inter-module calls in v0.1 are synchronous in-process Kotlin function calls
  • Same interfaces drop-in replaceable with REST/gRPC clients post-extraction
  • Outbox pattern + Kafka API used for cross-context events even in monolith mode (no shortcuts)
  • After extraction (per-service repos tiana-code/ledger-service, etc.), Helm umbrella chart in tiana-code/fincore-helm-charts orchestrates deployment

Triggers for extracting a service to its own repo:

  • Independent scaling needs (one service hits CPU/memory ceiling separately)
  • Independent deploy cadence (one service ships weekly, another monthly)
  • Independent team ownership emerges
  • Standalone library demand (e.g., decision-engine as a Drools-replacement consumer wants only that JAR)

Consequences

Positive

  • docker compose up brings the whole system up in 30 seconds, single JVM process
  • Local development is fast (no inter-service HTTP)
  • Cross-context refactoring is one PR, one CI run
  • Developers can read the entire codebase without context-switching across repos
  • DB transactions span multiple aggregates when business invariants require it (e.g., Payment + Ledger Transaction in one tx for v0.1; saga later)
  • Testing is fast - no need for service mesh in tests

Negative

  • Tight coupling within JVM - careless changes can leak across contexts. Mitigated by ArchUnit tests + module visibility rules.
  • Single deployment unit means one service's bug can affect all others. Mitigated by bulkhead patterns (Architecture-Resilience#bulkheads).
  • Scaling means scaling everything. Acceptable until v1.x; horizontal scale of single process is fine for adopter workloads up to ~5000 tx/sec.

Neutral

  • "Microservices" is the trendy answer. We'll get pushback in some Hacker News comments. Many practitioners have written about modular monolith being the right starting point. Cite when responding.
  • Extraction story is well-trodden - Sentry, Stripe, Shopify all started monolith-then-extracted. Frame as "we know how this evolves."

Alternatives considered

Pure microservices (separate repos from day one)

  • Rejected: developer experience too heavy for solo maintainer + early adopters
  • Rejected: docker-compose with 6+ images takes 90+ seconds to start
  • Rejected: distributed tracing is required but not yet implemented
  • May reconsider for v2.x when team grows beyond 1 engineer

Pure monolith (no module boundaries)

  • Rejected: boundaries are easier to enforce at design time than refactor in
  • Rejected: Drives single-team thinking; we want polyrepo as eventual destination

Lambda / serverless decomposition

  • Rejected: cold starts hurt p99 latency
  • Rejected: vendor lock-in conflicts with self-hosting goal
  • Rejected: financial systems benefit from long-running JVM processes (caches, connection pools)

Validation

  • Each release tag verifies: ArchUnit tests pass, all 6 contexts can be deployed independently from current code (dry-run extract)
  • v0.5 milestone includes extract-decision-engine.md runbook proving extraction is mechanical
  • Performance benchmarks demonstrate single-instance handles target load (1000 tx/sec sustained)

Related

  • ADR-0003 - outbox preserves cross-context event flow
  • ADR-0004 - Hibernate works across modules without complication
  • Architecture-Overview - visual diagrams of the architecture
  • Future ADR-0010 - when sagas replace cross-module DB transactions post-extraction

Clone this wiki locally