Skip to content

ADR-0006 — Adopt Hexagonal Architecture (Ports & Adapters)

Accepted2026-04-28

The bot’s core domain (money, ledger, transactions) is small but load-bearing — every functional requirement passes through it. The external dependencies are larger and noisier: Telegram (aiogram), PostgreSQL, Prometheus, eventually TigerBeetle.

We have one architectural commitment from ADR-0001: the ledger backend will swap from PostgreSQL to TigerBeetle later. That swap must not require changes to use-cases or domain types — otherwise the deferral isn’t worth it. We need a structure that makes this swap mechanical.

We also want the domain testable without spinning up Telegram or Postgres. Unit tests should run in milliseconds, with no I/O.

OptionSummaryProsConsOutcome
A — Classic 3-tier (UI → service → repository → DB)Layers stacked top-down, each layer knows the one belowFamiliar, fast to scaffoldService layer reaches into ORM-shaped repos; swapping the persistence layer means rewriting service signatures; testing the service requires mocks of the ORMrejected
B — Functional core / imperative shellPure functions for domain, all I/O in a thin shellMaximum testability of core; Haskell-flavored elegancePython’s strength isn’t pure FP; team familiarity is lower; the shell tends to grow into a Big Ball of Imperativerejected
C (chosen) — Hexagonal (Ports & Adapters)domain/ is pure; ports/ declares Protocol interfaces; application/ orchestrates use-cases against ports; adapters/ provides concrete implementationsDomain has zero infrastructure imports; one adapter swap (Postgres → TigerBeetle) is a one-file change in adapters/ledger/; bootstrap.py is the only place that wires concrete adapters to ports; trivial unit tests via fakesRequires discipline: the layering rule must be enforced (can’t be done at Python language level; we use mypy strict + code review + a future fitness test)selected

We will structure src/finance_bot/ as four concentric layers, with imports allowed only inward:

adapters/ → ports/ → application/ → domain/

Concretely:

  • domain/ — pure types: Money, models (User, Account, Category, Transaction, Budget), domain errors. No imports from outside domain except stdlib.
  • ports/typing.Protocol interfaces: LedgerPort, repository protocols, Clock. Imports domain types only.
  • application/ — one file per use-case (record_expense, record_transfer, void_transaction, get_today_stats, …). Imports domain and ports only.
  • adapters/ — concrete implementations: adapters/ledger/postgres.py, adapters/ledger/tigerbeetle.py (future), adapters/repositories/postgres/*, adapters/telegram/{handlers,middlewares,parsers}/, adapters/observability/{logging,metrics}.py. Imports domain, ports, and external libraries.
  • bootstrap.py — composition root. The ONE place allowed to import both ports and concrete adapters. Wires them together for __main__ to consume.
  • __main__.py — entry point. Calls Bootstrap(settings).build() and starts the dispatcher.

Enforcement:

  • mypy --strict is enabled for finance_bot.{domain,ports,application}.* in pyproject.toml. Adapters use the relaxed default profile because aiogram introduces Any-typed surfaces.
  • A CI fitness test (Plan 7) will assert that no module under domain/ or application/ imports anything under adapters/.
  • Code review checklist: cross-layer import → reject.
  • The TigerBeetle swap (ADR-0001 trigger) is a adapters/ledger/ change only — Plans 2-6 will not have to revisit that boundary.
  • Domain unit tests are pure: no Postgres, no aiogram, no asyncio event loop required for what should be fast tests.
  • Telegram-specific behavior (parsers, FSM if needed, middlewares) lives behind adapters/telegram/ and never leaks into use-cases — which means a future web/CLI front-end is also a one-adapter add.
  • The CQRS-lite split (write through LedgerPort; reads through TransactionReadRepo) sits naturally inside Ports: LedgerPort stays the swap-target, the read repo stays Postgres-forever.
  • More files than a flat layout. ~9 source files for a feature that could be 3 in a script. Worth it because the structure is the contract, not just decoration.
  • Newcomers reading the code top-down see indirection — they have to follow application/record_expense.pyports/ledger.pyadapters/ledger/postgres.py to see what actually runs. The payoff is that “what actually runs” is replaceable.
  • Python doesn’t enforce the layering at the language level. Without mypy + review + the fitness test, an adapters import could sneak into application and silently work. The enforcement triangle exists for that reason.
  • Plan 7: write the architecture-fitness test asserting inward-only imports.
  • If application/ ever accumulates duplicated orchestration, introduce an application/_shared helper module (still no adapter imports).
  • Reconsider this layout if domain stays trivially small through v1 — there is no virtue in hexagonal scaffolding around 50 lines of business logic. Current trajectory (Tier-2 → Tier-3 multi-currency) outgrows that condition.
  • Alistair Cockburn, Hexagonal Architecture (original 2005 essay).
  • ADR-0001 — TigerBeetle deferral; the only planned cross-module replacement.
  • ADR-0004 — Modular monolith; Hexagonal is how the modules are bounded.
  • src/finance_bot/__init__.py and the layout under it — physical realization of this decision.