Skip to main content

ADR-0008: Structure ai-coach as ports and adapters, enforced by Nx module boundaries

Accepted

Status: accepted · 2026-10-24

Context​

The first build split the code by feature (vault-client, sparring-partner, external-judge, scheduler, coach), and business rules sat next to Ollama, Gemini and MCP code. Swapping a model or the vault transport would have touched the rules, and nothing stopped a rule from importing an SDK. The restructure followed ten references on hexagonal architecture, DDD and Nx boundaries (Cockburn, Graça, Sairyss's domain-driven-hexagon, the Netflix case study, Angular Atlas, Marmicode, the Nx docs and others).

Decision​

  1. The workspace is an Nx monorepo on npm workspaces, TypeScript, Vitest and ESLint, with seven projects:

    ProjectTagsHolds
    domaintype:domainRules that guard themselves: SparringRound, sparring rules, buzzword and verbatim checks, the review ladder, the verdict rule, tier2Payload()
    applicationtype:applicationUse cases (Sparring, SparringConversation, Judging, Review, Filing) and the ports they need
    adapter-vault-mcptype:adapter, adapter:vault-mcpMcpVault over kb-template's MCP server
    adapter-ollamatype:adapter, adapter:ollamaOllamaSparringPartner, prompts and the loopback guard
    adapter-geminitype:adapter, adapter:geminiGeminiJudge and the judge protocol
    adapter-local-statetype:adapter, adapter:local-stateThe pending-judgement queue and the clock
    clitype:appTerminal, config, commands and composeCoach, the composition root
  2. Dependencies point inward. Ports are TypeScript interfaces shaped by what the use cases need; there is no DI container, so the composition root binds them by hand.

  3. Adapters translate technology failures into application errors (UnavailableError, UnusableReplyError, VaultRefusedError); the CLI maps those to messages and exit codes.

  4. @nx/enforce-module-boundaries enforces it on lint: inward-only tags, no adapter importing another adapter, no circular dependencies, and an allowedExternalImports whitelist per tag (none for domain and application; only its own SDK, zod and Node for each adapter).

  5. no-restricted-imports bans Node built-ins in domain and application, which the whitelist does not cover.

  6. ARCHITECTURE.md describes the layout; this ADR records why.

Consequences​

  • A new model or transport is a new adapter plus one line in composeCoach.
  • Each boundary rule was checked by planting a violation and seeing lint fail.
  • CI already runs lint, so the rules hold there too.