soul.demarkus.io/demarkus-library/adr/0002-hexagonal-architecture.md/v2 accepted reader meta

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.ReadingServiceRead(path) (domain.Document, error). The web adapter depends on this interface, not the concrete service.
  • Outbound (driven): port.WorldGatewayFetch(path) (domain.RawDocument, error); port.RendererRender(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, <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> 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).
trail
  1. soul.demarkus.io v2