Module register as the source for the architecture gate and table ownership¶
Context and Problem Statement¶
The server is a modular monolith with hexagonal modules (ADR 0004, ADR 0008). Every table belongs to exactly one module, only this module reads and writes it via SQL (03-data-model.md, principle 12), and every action belongs to exactly one module (02-architecture.md, section 4.1, rule 6). ADR 0008 chose go-arch-lint as the tool for package dependencies. Still open: which file holds the facts about modules, which checks are generated from it, and how is table ownership enforced, which go-arch-lint does not see?
Decision Drivers¶
- One source for module boundaries: tables, actions, allowed dependencies, third-party libraries.
- Table ownership is checked, not only described (principle 12, B7: one truth for permissions).
- New packages, tables and actions stand out when they are not assigned to a module.
- The tables in 02 (sections 4.2, 4.4, 4.6) must not diverge from the code.
- Many build sessions work in parallel; violations must show up in the local gate (ADR 0038).
- A Unit of Work spans several modules in one transaction (02, section 8).
Considered Options¶
- Register
services/server/architecture/modules.yamlas the single source; the configuration of go-arch-lint and all further checks are generated from it - Hand-written go-arch-lint configuration plus separate lists in the docs
- Enforce table ownership in PostgreSQL: one schema and one database role per module
- Declarations in Go code per module, read by a tool
Decision Outcome¶
Chosen option: "Register modules.yaml as the single source", because it holds all module facts
in one readable place and every check in the local gate is generated from it. Format and example:
02-architecture.md, section 7.1.
- Per module the register names
tables,actions,events,ephemeral_events,ws_ops,depends_onandadapter_vendors; in addition the kernel packages (kernel.core,kernel.ports), one entry per platform package withdepends_onandvendors, the entrieswire,cmdandapiwith their vendor groups and the listvendorswith the import paths per third-party library. Technical tables (River in the schemajobs.schema, the version table of goose; 03 section 4.13) belong to no module and are not listed undermodules; a query of a module that names them is a finding of the table ownership check. - Generated from it are:
.go-arch-lint.ymlwith four components per module (port,domain,app,adapters, ADR 0008), one component per platform package and the componentswire,cmd,api; only in this way can a third-party library be limited to exactly one platform package (02 section 7.2);- the table ownership check: every SQL file under
internal/<module>/adapters/postgres/queries/names only tables of this module; - the completeness check: every table of the migrations and from 03 is in the register exactly
once, every action of the catalogue exactly once (set comparison with
docs/spec/actions.yaml, ADR 0049); - the route check: the handler of an operation lies in the module that owns its action;
- the direction of the in-tx subscriptions: a module subscribes only to events of modules it
depends on (golden file in
internal/wire, 02 section 8.3); - the docs reconciliation: the tables 4.2, 4.4, 4.6 and 4.7 in 02 match the register.
- A new package without a component makes go-arch-lint abort ("not attached to any component"); this is intended (02, section 7.2).
Consequences¶
- Good, because a change of the module boundaries changes exactly one file and all checks follow.
- Good, because table ownership violations stand out before the merge, with file and table, instead of in review.
- Good, because completeness is checked against the migrations, that is against the real schema.
- Bad, because the table ownership check reads SQL files and is not PostgreSQL: it does not see a table that is reached only through a view or a function. Mitigation: views and functions are listed in the register like tables.
- Bad, because the generator and the checks must be maintained; the fallback for go-arch-lint is generating depguard rules from the same register (ADR 0008).
- Neutral, because sqlc works per module (
sqlc.yaml, one entry per module) and the register can provide the list of modules for it.
Confirmation¶
make arch(go-arch-lint, exit code 0) andmake codegen-check(generated.go-arch-lint.ymlequal to the generator result) in the targetgates.go test ./tools/archgen/...with fixture modules (PLAN M1-05, form with platform components):TestArchLintSkeletonClean(clean skeleton, exit code 0),TestArchLintDomainImportsDriver(domain imports a driver),TestArchLintAppImportsForeignPort(app imports a foreign port),TestArchLintAppImportsForeignApp(app imports a foreign app),TestArchLintPackageWithoutComponent(package without component); the four violations fail with exit code 1 and the package path (02-architecture.md, section 7.2).TestArchLintTestSupport:test/**may use everything, no production component may use it.TestArchLintChapter02Layout: the layout from 02 with several packages per component (sqlcgen,pgtest, subpackages,api/routescallsapi/rest) passes;TestArchLintLayersStayClosed: the self entries open no other edge;TestArchLintVendorPatternIsLiteral: a vendor pattern allows only the named libraries.- Generator:
TestGenerateGolden,TestGenerateIsDeterministic,TestRegisterRejects(one case per check rule of the register),TestImportPathRule(vendor patterns only as a Go import path),TestRunRejectsExcludePrefixOfOtherDirectories(excludeacts as a string prefix),TestRepositoryRegisterIsValidandTestRepositoryConfigIsCurrent(the checked-in.go-arch-lint.ymlequals the generator result fromarchitecture/modules.yamlbyte for byte),TestChapter02ExamplesMatchGenerator(register example and generated excerpt in 02, sections 7.1 and 7.2, match the generator). - Fixture for table ownership: a query in module A names a table of module B; the check fails with file and table.
- Completeness check against the migrations, 03 and
actions.yaml; docs reconciliation with 02. - Build ticket M1-05 in PLAN.md.
Pros and Cons of the Options¶
Register as the single source¶
- Good, because people and tools (Go generator, Python docs checks) read the same file.
- Bad, because the generated configuration is large; it is never edited by hand.
Hand-written configuration and separate lists¶
- Good, because no generator is needed.
- Bad, because three lists (tool, 02, 03) drift apart and table ownership is not checked at all.
Schema and role per module in PostgreSQL¶
- Good, because the database rejects another module's access at runtime.
- Bad, because a Unit of Work runs several modules in one transaction on one connection
(02, section 8.1); roles per module would need
SET ROLEbefore every statement or separate connections and thus separate transactions [derived]. - Bad, because the phase hooks (permission version, event log, audit) write other modules' tables in every transaction; they would need exceptions that weaken the rule again.
Declarations in Go code¶
- Good, because they stand next to the code.
- Bad, because the facts are spread over many files and docs tools would have to evaluate Go source code.
More Information¶
- 02-architecture.md, sections 4, 6 and 7; decision R-AR-19.
- Related: ADR 0004 (modular monolith), ADR 0007 (sqlc and migrations), ADR 0008 (hexagonal modules, go-arch-lint), ADR 0049 (action catalogue).