Skip to content

ADR-0004 — Adopt a modular monolith for MVP-1

Accepted2026-04-28

MVP-1 has two real users (the author and his partner) and a Tier-2 feature scope (expenses, income, transfers, budgets, simple stats). Throughput is < 100 messages/day. The system must run on a single VPS under docker-compose (ADR-0013) with one operator.

The codebase is brand-new and has to ship within ~4 weeks of part-time work. The portfolio narrative has to support a credible answer to “why this topology”, not “we chose microservices because everyone does”.

OptionSummaryProsConsOutcome
A — MicroservicesBot, ledger, reports as separate servicesIndependent deploys; clean fault isolation; trendy on CV3× ops overhead; inter-service calls add latency and failure modes; no real reason to split at 100 msg/day; portfolio narrative becomes “I cargo-culted Netflix”rejected
B — Plain monolithOne Python module with no internal boundariesFastest to writeRefactoring later is painful; ledger swap to TigerBeetle becomes a rewriterejected
C (chosen) — Modular monolithOne process, hexagonal layout (domain, ports, application, adapters); all run in one containerAll upsides of (B) for ops; (A)‘s clean module boundaries baked in; ledger backend swap is a one-file change in adapters/ledger/Requires self-discipline (importing adapters.* from domain.* is forbidden — enforced by mypy & code review)selected

We will run all MVP-1 functionality as one Python process packaged in one Docker image, with internal modules separated by hexagonal boundaries (domain, ports, application, adapters). Cross-module calls happen via Python imports, never network.

Concretely:

  • One container in compose.yml named bot.
  • One entry point: python -m finance_bot.
  • bootstrap.py is the only place that wires concrete adapters to ports; everything else depends on interfaces.
  • A future split into services is a non-goal; if traffic ever justifies it, the candidate seam is application/adapters/telegram/ (split the bot front-end from the domain backend), not splitting the domain itself.
  • One artifact to build, push, deploy, and observe.
  • Lowest cognitive overhead for solo development.
  • Clean swap to TigerBeetle (the only hard module-replacement on the roadmap) is a one-adapter change — no service contract negotiation.
  • A bug in one module can crash the whole bot. Acceptable for MVP-1 (Telegram redelivers updates after restart).
  • The hexagonal discipline must be enforced by mypy boundaries and code review; the language doesn’t enforce it natively.
  • Add an architecture-fitness test in CI (Plan 7) that asserts no module under domain/ or application/ imports anything under adapters/.
  • Sam Newman, Monolith to Microservices, Ch. 1 — when to split.
  • TigerBeetle docs/coding/data-modeling.md — adapter swap is the only planned cross-module replacement, and that’s a library swap, not a service split.