# ADR 0016 — Hexagonal (ports & adapters) architecture for the oikos backend Status: accepted (2026-08-15) · Plan: plans/2026-08-15-hexagonal-architecture.md ## Context The oikos backend grew as delivery-layer packages with business logic inside them. `internal/httpapi` (~3500 LOC) and `internal/mcp` (~2100 LOC, 67 tools) each embed raw SQL next to validation, policy, and audit writes — two parallel silos re-implementing the same use-cases. The scheduler is a ~1100-LOC monolith mixing probe dispatch, health aggregation, signal transitions, and metric writes. There is one accidental port/adapter pair (`secrets.Backend`) and one inverted dependency (`internal/db` imports `checkdefaults`). Use-cases have no home: every new consumer (REST handler, MCP tool, scheduler pass) copy-pastes query + policy + audit logic, and behavior drifts between paths that must agree — the run/approve/execute lifecycle exists in three variants. A staff-level review of the refactor plan settled the open questions: one hexagon (not per-binary), nomos stays an external agent client over the wire, aggregate-scoped repository methods instead of a UnitOfWork port, reads bypass services via a shared `ReadModels` port, and the client (web SPA + desktop) moves to its own repository before the backend churn starts. ## Decision Adopt ports & adapters across the backend, delivered in ten shippable phases: - `internal/core/domain` (pure model, stdlib only), `internal/core/app` (application services = use-cases), `internal/core/ports` (driven-port interfaces). Core imports nothing from adapters; adapters and `cmd/*` import core. - All I/O behind named ports implemented by adapters under `internal/adapters/`: postgres (repositories), ssh (CommandExecutor), probes/* (one Checker per check kind), remote (TargetResolver), secrets, events (EventPublisher/SSE), plus the driving adapters httpapi, mcpserver, scheduler, execworker, cli. - One repository method = one transaction = one aggregate's atomic boundary (`§3.6` of the plan). Inputs carry audit/event entries and derived checks; no `pgx.Tx` crosses into core; no UnitOfWork port. - Commands flow through `core/app` services; invariant-free reads flow adapter → `ReadModels` → presenter with no service hop (CQRS-lite). - REST and MCP keep their existing wire formats and become thin driving adapters over the same services; presenters stay per-adapter. - nomos never links core: it consumes oikos exclusively through MCP/REST. Its Phase 8 cleanup defines nomos-local ports (`LLMClient`, `HomelabClient`, `SessionStore`) outside `core/ports`. - The Gitea `webhook` receiver and the desktop shell are leaf utilities — documented, not restructured. - Dependency rules are enforced with `depguard` from Phase 0 (rule 3, the `internal/nomos` bans, activates when that package exists in Phase 8). ## Consequences - No API, MCP-tool, DB-schema, or wire-format changes ride along; behavior parity is guarded by the existing handler contract tests plus a repository conformance suite asserting atomicity and check-then-act per command method. - `internal/db` shrinks to connection/migrations/sqlcgen inside the postgres adapter; the `db → checkdefaults` inversion disappears with `SeedService`. - Each phase merges green (`make lint test generate-check`, `make test-db` where repositories change) with a patch version bump; per-phase abort criteria revert a merge that breaks transaction semantics. Minor bump at Phase 9 completion. - Cost accepted: ~10 phases of import churn, sqlc path updates under `make generate-check`, and a temporary period where old and new package locations coexist (depguard denies core→adapter imports from Phase 0 so the new tree can never grow the old inversions). - Risk trade-off recorded in the plan: a repository method whose tx span is too narrow is a bug class this design makes possible; the conformance suite is the mitigation, and the check-then-act sites enumerated in the plan (approval decide, entity transition, check derivation, execution claim) are its first assertions.