# ADR 0002 — Hexagonal (ports & adapters) architecture **Status:** accepted (2026-06-10). Refines ADR 0001 (Echo + bulwarkauth idioms stay; the overall structure becomes hexagonal). ## Context Phase 0 first shipped as a bulwarkauth-style layered app (`api/`, `internal/ library`, `internal/view`). Fritz directed that the architecture be **hexagonal**. The reading room has a clean port shape: it is driven by HTTP and it drives two outbound dependencies (a demarkus world over QUIC, a markdown renderer) — exactly what ports & adapters models. Phase 1 will swap the transport (direct QUIC → MCP over the broker) and add an OAuth session; isolating the core from transport makes that a one-adapter change. ## Decision Ports & adapters. Dependencies point inward only (adapters → ports → core). ``` cmd/demarkus-library/ composition root (wires adapters into the core) internal/core/ domain/ document.go entities + sentinel errors; NO external imports port/ port.go inbound + outbound port interfaces (domain types only) service/ reading.go application core; implements inbound ports via outbound ports internal/adapter/ inbound/web/ Echo handlers/routes/view (driving / primary adapter) outbound/world/ demarkus QUIC fetch client → port.WorldGateway outbound/markdown/ goldmark + bluemonday → port.Renderer ``` Ports: - **Inbound (driving):** `port.ReadingService` — `Read(path) (domain.Document, error)`. The web adapter depends on this interface, not the concrete service. - **Outbound (driven):** `port.WorldGateway` — `Fetch(path) (domain.RawDocument, error)`; `port.Renderer` — `Render(markdown string) (string, error)`. Rules enforced (also encoded in `.coderabbit.yaml` path_instructions): - `domain` imports only the stdlib + other core packages — no Echo, no demarkus client/protocol, no html/template, no goldmark. - Ports speak domain types only (never `protocol.Response`, `fetch.Result`, `echo.Context`, `template.HTML`). - Transport status → domain error translation lives in the `world` adapter; the core never sees a `protocol.Status`. - Sanitized-HTML-as-string crosses the renderer port; the web adapter alone marks it `template.HTML`. - Each implementation carries a `var _ port.X = (*T)(nil)` assertion. bulwarkauth idioms retained: Echo v5, `NewX(...)` constructors returning values, `_handlers.go` / `_routes.go` naming (inside the web adapter), env-driven `AppConfig`, slog JSON. ## Consequences - Phase 1's broker MCP gateway is a new `outbound/` implementing `port.WorldGateway` (plus an OAuth session) — swapped at the composition root, core untouched. - The core is testable with fakes for both outbound ports (no live world); the `world` adapter is tested for status mapping with a fake fetch client. - Slightly more packages/indirection than a flat layered app — justified by the imminent transport swap and the strict XSS/transport boundary. - CodeRabbit enforces the dependency rule per-path; PR scopes are the layer names (`domain`, `port`, `service`, `web`, `world`, `markdown`, `cmd`).