Vellum/docs/decisions/ADR-004-modular-monolith.md
mithral 051169a18a Documentation, and make a fresh clone actually work
Docs
- docs/ARCHITECTURE.md: how the pieces fit and why. The request path, the six
  enforced layers, the data model, the scanner, the job queue, direct-play
  playback, and the three protocol surfaces over one application.
- docs/decisions/: seven ADRs with the alternatives that were rejected —
  SQLite-with-a-seam, direct-play-only, mark-missing-never-delete, the modular
  monolith, the one item-access chokepoint, the table-backed queue, and
  fixed-width timestamps.
- README: the layout section was stale (it predated catalog, home, scanner,
  providers, engine, probe, playback, apitoken, arch and the dto packages) and
  now lists what exists, plus links to the new docs.

Two real bugs, both found by verifying the docs instead of trusting them

1. The CLI could not run on a fresh install. `Config.Validate` created the data
   and config directories, but the CLI never called it, so `vellum user add` —
   the recovery path when you are locked out — failed with "unable to open
   database file" until the server had booted at least once. Split the directory
   creation into `Config.EnsureDirs`, call it from the CLI, and keep the media
   root check out of it: an admin command must not fail because a library is
   unmounted. The CLI tests now point DBPath inside DataDir, exactly as
   LoadConfig derives it, so they exercise the fresh-install path; removing
   EnsureDirs fails three of them.

2. A fresh clone could not build at all. `//go:embed all:dist` fails to compile
   when web/dist is absent, and web/.gitignore expected a `dist/.gitkeep` that
   was never created. Added it, so a clone builds and the server logs
   "web UI not embedded; run the frontend build to include it" — the intended
   behaviour for someone who only wants the API.

Also: the httpapi tests depended on a built frontend and FAILED rather than
skipping, so the suite was red on any fresh clone. findAsset and readAsset now
skip with a message naming the missing step. They still RUN when a bundle is
present (verified: zero skips here), so they have not been quietly disabled.

Verified from a simulated clone of tracked files only: go build exit 0, 25
packages pass, the server boots and applies all 12 migrations, and the CLI
creates a user and takes a backup. gofmt clean, vet exit 0, svelte-check 0/0.
2026-10-12 00:18:11 -04:00

2.4 KiB

ADR-004: A modular monolith — one binary, not services

Status

Accepted

Date

2026-10-12

Context

The goal was a codebase that is easy to contribute to and easy to debug. "Split it into services" is the usual answer to that, and it was explicitly proposed.

Decision

Keep one process. Get modularity from enforced package boundaries, not from network boundaries.

Alternatives Considered

Split into microservices (a scanner service, a metadata service, an API service)

  • Pros: independent deploys; each service small enough to read in one sitting.
  • Cons: every call becomes a network call that can time out, retry, duplicate or partially fail. Debugging a bug becomes "which service dropped this", and the stack trace is replaced by distributed tracing.
  • Rejected: it would make the stated goal — easier to find and fix bugs — harder, not easier. Every bug found in this codebase so far has been a code bug, and none would have been easier to locate across a network boundary.

One package per concern, no rules

  • Pros: simplest to start.
  • Cons: without enforcement, boundaries erode. An upward import does not fail to compile; it just quietly welds two layers together until nothing can be moved.
  • Rejected: documented rules that nobody enforces are rules that break within a month.

Consequences

  • 33 packages in six layers, with the import rules enforced by tests in internal/arch. A violation fails the build.
  • Seven rules, each deliberately broken and confirmed to fail: no upward imports; core depends only on migrations; catalog knows no HTTP; modules do not import each other; every package must be in a layer; the database/sql importers are an explicit list; packages on the storage seam must not drift back.
  • Rule 5 — every package must be placed in a layer — is the one that keeps the others alive. Without it, a new package quietly has no rules at all. It caught two unlayered packages during the Phase 2 splits.
  • cmd/vellum/main.go is the single wiring point. Nothing else imports a module; modules register themselves.
  • Deployment stays one binary, which keeps the operational story simple: no service discovery, no partial deploys, no version skew between services.
  • Cost accepted: a change that spans layers needs a deliberate interface, which is slower than reaching across. That is the point.