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):
domainimports 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
worldadapter; the core never sees aprotocol.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,
<domain>_handlers.go / <domain>_routes.go naming (inside the web adapter),
env-driven AppConfig, slog JSON.
Consequences
- Phase 1's broker MCP gateway is a new
outbound/<adapter>implementingport.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
worldadapter 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).