0026. Modular monolith¶
| Status | Accepted |
| Date | 2026-10-10 |
| Deciders | Stuart Meeks |
Context¶
Signboard is built and run by one part-time maintainer. Its core workflow, quote to sales order to job to invoice, is a chain in which one action changes several areas at once: accepting a quote creates a sales order and jobs.
Several decisions already assume one database:
- tenant isolation by row-level security (ADR 0004);
- RQL queries across related objects (ADR 0016);
- audit triggers on every table (ADR 0019);
- the email outbox written in the same transaction as the change that causes it (ADR 0023).
Microservices solve an organisational problem: many teams that need to build and deploy without coordinating. Each service adds a pipeline, a deployment, monitoring, version compatibility and failures between services. A monolith avoids all of that, but without firm internal boundaries it decays into code where everything depends on everything.
Decision¶
Signboard is a modular monolith: one codebase and one database, divided into modules with enforced boundaries.
Modules¶
- The modules are the API's modules (ADR 0020):
accounts,catalog,sales,production,billing,notificationsandsystem. - Each module is its own .NET project, owns its own tables, and exposes a public interface to other modules.
- A module never reads or writes another module's tables. It calls the other module's interface.
- Boundaries are enforced by architecture tests (with a library such as ArchUnitNET or NetArchTest). A dependency that crosses a boundary any other way fails the build (ADR 0012).
- Work that spans modules, such as accepting a quote, runs in one database transaction, coordinated through the modules' interfaces.
Deployables¶
One codebase produces a few deployables:
| Deployable | Role |
|---|---|
| UI | Static files behind a CDN (ADR 0015) |
| API | Serves /api on every business's host |
| Background worker | Email sending, accounting sync, imports and other long-running jobs. Same code and image as the API with a different start-up, so it scales and restarts independently |
| Identity service | id.signboard.works (ADR 0021). Kept separate so security-critical code is smaller, easier to review and easier to lock down |
All of them share the one PostgreSQL database, each with its own database role and only the permissions it needs.
When to split a service out¶
A separate service is justified only by a concrete need that the monolith cannot meet, for example:
- a part with radically different technical needs, such as heavy processing of artwork or print files;
- a capability that must be isolated for security or compliance, such as payments;
- several teams that need to deploy independently.
Splitting one out needs its own ADR. Because modules already talk only through interfaces, extracting one is contained work rather than a rewrite.
Options considered¶
- Modular monolith with a few deployables: one transaction for cross-module work, one database for every earlier decision that relies on it, one pipeline to run; boundaries kept by tests. Chosen.
- Microservices: independent deployment and scaling per service; distributed transactions (sagas) for the core workflow, and a large operational load for a single maintainer.
- Plain monolith without enforced modules: least structure to set up; boundaries erode, and any later split becomes a rewrite.
- One process for everything: simplest deployment; long-running jobs compete with requests, and the identity service shares its attack surface with the whole API.
Consequences¶
- The module list and each module's public interface are set out in the architecture design, before the first module is built.
- Architecture tests are part of the build from the first commit.
- Every deployable is built from the same codebase and released together, so there is no version compatibility between them to manage.
- The database is a single shared dependency. Its availability, backups and performance are the service's, and are designed with hosting.