ADR-0006 — Adopt Hexagonal Architecture (Ports & Adapters)
Status
Section titled “Status”Accepted — 2026-04-28
Context
Section titled “Context”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.
Considered alternatives
Section titled “Considered alternatives”| Option | Summary | Pros | Cons | Outcome |
|---|---|---|---|---|
| A — Classic 3-tier (UI → service → repository → DB) | Layers stacked top-down, each layer knows the one below | Familiar, fast to scaffold | Service layer reaches into ORM-shaped repos; swapping the persistence layer means rewriting service signatures; testing the service requires mocks of the ORM | rejected |
| B — Functional core / imperative shell | Pure functions for domain, all I/O in a thin shell | Maximum testability of core; Haskell-flavored elegance | Python’s strength isn’t pure FP; team familiarity is lower; the shell tends to grow into a Big Ball of Imperative | rejected |
| C (chosen) — Hexagonal (Ports & Adapters) | domain/ is pure; ports/ declares Protocol interfaces; application/ orchestrates use-cases against ports; adapters/ provides concrete implementations | Domain 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 fakes | Requires 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 |
Decision
Section titled “Decision”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 outsidedomainexcept stdlib.ports/—typing.Protocolinterfaces:LedgerPort, repository protocols,Clock. Importsdomaintypes only.application/— one file per use-case (record_expense,record_transfer,void_transaction,get_today_stats, …). Importsdomainandportsonly.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. Importsdomain,ports, and external libraries.bootstrap.py— composition root. The ONE place allowed to import bothportsand concreteadapters. Wires them together for__main__to consume.__main__.py— entry point. CallsBootstrap(settings).build()and starts the dispatcher.
Enforcement:
mypy --strictis enabled forfinance_bot.{domain,ports,application}.*inpyproject.toml. Adapters use the relaxed default profile because aiogram introducesAny-typed surfaces.- A CI fitness test (Plan 7) will assert that no module under
domain/orapplication/imports anything underadapters/. - Code review checklist: cross-layer import → reject.
Consequences
Section titled “Consequences”Positive
Section titled “Positive”- 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 throughTransactionReadRepo) sits naturally inside Ports:LedgerPortstays the swap-target, the read repo stays Postgres-forever.
Negative / trade-offs
Section titled “Negative / trade-offs”- 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.py→ports/ledger.py→adapters/ledger/postgres.pyto 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
adaptersimport could sneak intoapplicationand silently work. The enforcement triangle exists for that reason.
Neutral / follow-ups
Section titled “Neutral / follow-ups”- Plan 7: write the architecture-fitness test asserting inward-only imports.
- If
application/ever accumulates duplicated orchestration, introduce anapplication/_sharedhelper 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.
References
Section titled “References”- 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__.pyand the layout under it — physical realization of this decision.