# 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.