One binary, one process, one deployment unit. Inside it, the code is split into modules with explicit boundaries so that a module can be understood, tested, and later extracted without untangling it from the rest.
This is deliberately not a set of microservices. A secret store is a single consistency domain: a read must see the version that was just written, and a revocation must take effect immediately. Splitting that across a network buys distribution problems and pays for nothing.
cmd/marsec/ process entry point, flag and signal handling only
internal/
app/ composition root: owns the module list and the middleware chain
modules/ one directory per bounded capability
health/
platform/ shared infrastructure with no domain knowledge
config/
httpx/
logging/
scripts/
docs/
cmd ──▶ app ──▶ modules ──▶ platform
│ └──────────────▶ platform
└──▶ client ──────────────▶ platform
Six rules, enforced by make arch-check:
- A module must not import another module.
- A module must not import
internal/app. internal/platformmust not importinternal/modulesorinternal/app.- Everything may import
internal/platform. internal/clientmust not import modules or the composition root. It speaks to the store over HTTP like any other caller, so sharing types with the server would let a server change quietly become a client change.- The server must not import
internal/client. If a module ever needs to call the API it serves, something is wrong with the design rather than with the import.
Rule 1 is the one that keeps the monolith modular. When two modules need each other, the dependency goes through an interface that the consumer declares and the composition root satisfies — never through a direct import.
type Module interface {
Name() string
Register(mux *http.ServeMux)
}The interface is declared in internal/app, and Go's structural typing means modules satisfy it
without importing it. A module therefore has no compile-time knowledge that a composition root
exists. Adding a module is one line in app.New.
A module owns its own routes. There is no central routing table listing every path in the system, because that file becomes a merge-conflict magnet and a place where a route can be registered without the owning module noticing.
A module also owns its own schema. It declares its migrations and passes them to sqlite.Migrate
under its own name, for the same reason there is no central routing table.
Modules share a database but not each other's tables. A foreign key from one module's table into
another's would make the two schemas one, and a migration in either could then break the other. So
policy_bindings records an identity by its identifier with no foreign key to auth_identities: a
binding left behind by a deleted identity is inert, because an identity that cannot authenticate is
never evaluated.
A module satisfies the contract only once it has an HTTP surface to expose. internal/modules/secret
is a module with a Go API and no routes, and it stays that way until authentication exists in M3.
- Create
internal/modules/<name>/. - Expose a type with
Name() stringandRegister(mux *http.ServeMux). - Keep HTTP handling, business rules, and storage in separate files inside the module.
- Add it to the slice in
app.New. - Test it through
httptestagainst its ownhttp.ServeMux, not through the full application.
Step 5 matters: a module test that boots the whole application is an integration test wearing a unit test's clothes, and it stops telling you which boundary broke.
Request identifiers, security headers, access logging, and panic recovery live in the middleware
chain in internal/app, not in individual handlers. A module cannot forget to apply them, and a
module cannot opt out of them.
Storage, sealing, authentication, authorisation, leases, and auditing. Each arrives as its own module or platform package as the roadmap progresses. The boundaries above exist now so that those modules land in a structure rather than creating one.
internal/platform/crypto exists and has no HTTP surface. It is reachable only from Go code, which
is deliberate: a secret endpoint that predates authentication would be an unauthenticated secret
endpoint, however briefly.