How to Build a Monolith You Can Split Later
Quick answer: A modular monolith is one deployable application divided into modules with enforced boundaries. One rule decides whether a later split stays possible: every module owns its own tables, and no module reads or joins another module's schema. Modules talk through an explicit public interface and in-process domain events — the same events that become real messages after a split. Enforce the rules at build time rather than by discipline. Get that right and extracting a service is contained work instead of a data migration.
"We'll start with a monolith" is a decision most teams get right and then fail to act on. It is made in a meeting, and nothing about the codebase changes as a result. Eighteen months later, every query joins across every domain and "monolith" has stopped meaning "one deployable" and started meaning "we can't change anything without breaking something else."
That is not what a monolith costs you; it is what an unstructured one costs you, and the difference is a handful of rules applied from the first week. We covered why your first architecture should be a monolith separately — this is the follow-through, on the same running example: a cross-border money transfer product where GBP leaves London and EUR arrives in Lisbon.
What actually makes a monolith modular?
Not folders. Every codebase has folders. A module is a unit with three properties:
- It owns data that nothing else touches.
- It exposes a small public interface, and everything behind it is unreachable from outside.
- Those two facts are checked by a machine, not remembered by a person.
Miss the third and the first two decay within a quarter. Miss the first and the others are decoration: beautiful interfaces over data that is welded together.
Why does table ownership decide everything?
Because interfaces are cheap to change and data is not.
Suppose you need a transfers list screen showing amount, recipient, the sender's verification status and their current wallet balance. The obvious query writes itself:
sql SELECT t.id, t.amount, t.recipient_name, k.verification_status, SUM(e.amount) AS balance FROM transfers t JOIN customers c ON c.id = t.customer_id JOIN kyc_checks k ON k.customer_id = c.id JOIN ledger_entries e ON e.account_id = c.wallet_account_id WHERE t.customer_id = ? GROUP BY t.id;
One query. Fast. Correct. And it has just welded four domains together permanently.
Nothing about that is visible in your module diagram; the document still shows four neat boxes. But KYC can no longer rename verification_status or derive it from several checks, because a query in transfers depends on the current shape. The ledger can no longer change how entries are stored. And when someone proposes pulling KYC into its own service — the likeliest first extraction in a regulated product — the work is not "move some code." It is: find every query touching those tables, rewrite each as an API call, migrate the data to a separate store, all while transfers keep running.
The rule that avoids this is short: a module reads and writes only its own tables. Everything about another domain arrives through that domain's interface.
| The tempting version | What it should be |
|---|---|
JOIN kyc_checks to show verification status |
kyc.getVerificationStatus(customerIds) returns a status per customer |
SUM(ledger_entries) to show a balance |
ledger.getBalances(accountIds) returns balances per account |
Foreign key from transfers.customer_id to customers.id |
Store the ID as a plain column; no cross-module FK constraint |
| A reporting query joining six domains | A reporting module with its own read models, fed by events |
One shared users table everyone writes to |
Each module stores the facts it owns, keyed by customer ID |
Two practical notes. Give each module its own database schema, or at minimum a table prefix, so ownership is visible and can later be enforced with per-module credentials. And the obvious objection — three interface calls instead of one join — is answered by shape, not caching: design module APIs to take collections, so nobody ever calls getVerificationStatus(customerId) in a loop. Batched interfaces are also the ones that survive becoming network calls.
One genuine benefit you keep: with a single database, a cross-module operation still runs in one transaction — the main reason to be a monolith at all. Just know which flows depend on it, because those are the ones that get expensive if a module is extracted.
What does a module's public interface look like?
Narrow, and expressed in the language of the domain rather than the database: a handful of operations, not one per table. ledger exposes record(journal), getBalances(accountIds) and getEntries(accountId, period), not CRUD over entries. kyc exposes getVerificationStatus(customerIds) and startVerification(customerId, level). Everything else — schemas, repositories, internal services, ORM entities — is private and physically unreachable.
Three rules keep it honest:
- Never return persistence types. No ORM entities, rows or lazily loaded graphs crossing a module edge — only small, serializable structures. This is most of what makes an interface survive becoming a network call.
- No module reaches into another's internals, even for the admin screen. Admin screens are how boundaries die.
- Dependencies point one way. Transfers may depend on the ledger; the ledger should not know transfers exist. Two modules that need each other usually mean one should publish an event instead of calling.
Why design domain events before you need them?
Because after a split, every cross-module interaction becomes either a synchronous API call or a message — and the ones that become messages are the ones you should already be modelling as events.
An in-process event bus is small: a publish method, a registry of handlers, a naming convention. Events are past tense and factual — TransferAccepted, PayoutSettled, VerificationApproved — carrying identifiers plus the facts a consumer needs.
The payoff arrives before any split. When a transfer settles, four things must happen: the ledger records settlement entries, notifications tell the customer, reporting updates a read model, compliance logs an outcome. As direct calls, transfers depends on four modules and gains another dependency every time someone adds a consequence. As one published event with four subscribers, it depends on none of them.
Two decisions worth making per event, early:
- Inside the transaction, or after commit? Ledger entries usually belong in the same transaction as the state change. Notifications and reporting do not — nobody should be blocked from sending money because an email template failed to render. The handlers you run after commit become asynchronous messages later.
- Events for facts, interfaces for answers. If the caller needs a result to continue, that is a method call. If it is announcing something that happened and does not care who listens, that is an event.
Extraction then means swapping the in-process dispatcher for a broker and an outbox table, while event names, payloads and handlers stay as they are. The outbox pattern and idempotency keys are the other half of that story.
How do you enforce boundaries without relying on discipline?
You don't rely on discipline. It degrades under deadline, fastest in the week you can least afford it. A failing build does not degrade.
Enforcement has two layers. Structure makes the right thing natural: one top-level package or project per module, internals in a package the language itself can hide, and modules as separate build units declaring their dependencies. Checks make the wrong thing impossible to merge.
| Stack | Enforcement mechanism | What it catches |
|---|---|---|
| Java / Kotlin | ArchUnit rules run as ordinary tests; Gradle or Maven subprojects with declared dependencies | An import from transfers into ledger.internal, or a cycle between modules |
| .NET | A project per module; NetArchTest rules in the test suite | A project reference or namespace dependency that shouldn't exist |
| TypeScript | ESLint import-boundary rules; workspace project references or monorepo tags | A relative import that climbs out of a module directory |
| Python | import-linter contracts run in CI | A forbidden import path between packages |
| PHP | Deptrac layer and ruleset definitions | A class depending across a declared boundary |
| Go | internal/ packages |
Enforced by the compiler itself — nothing outside the parent can import it |
| Ruby | Packwerk | A reference to another package's private constant |
Add one check most teams miss, because it guards the rule that matters most: a CI step scanning queries and migrations for another module's table names. A grep over SQL files, a lint rule on repository classes, or per-module database credentials in the test environment — any of them turns "don't join across modules" from a convention into a build failure. Convention lasts until the first Friday afternoon.
Where do the boundaries go in a payments product?
A starting decomposition for a cross-border transfer product, ownership made explicit:
| Module | Owns | Must ask another module for |
|---|---|---|
| Transfers | Transfer records, lifecycle state, routing decisions, payout instructions | Balances and entries (ledger), verification status (KYC), a quote (pricing/FX) |
| Accounts / wallets | Which wallets exist, currency, status, ownership | The money in them (ledger) |
| KYC | Verification cases, document references, check outcomes, screening results | Nothing — it is deliberately a sink |
| Ledger | Ledger accounts, journals, entries; the only source of balances | Nothing — it knows about money, not about products |
| Pricing / FX | Rate sources, rate snapshots, quotes and their expiry, fee rules | Nothing |
| Notifications | Templates, delivery attempts, channel preferences | Nothing — it consumes events and calls out |
| Reporting | Its own read models, built from events | Nothing at query time |
The reasoning behind those lines matters more than the lines:
- Accounts and the ledger are separate on purpose. A wallet record ("this customer has a EUR wallet, status active") changes for product reasons. A balance is a derived fact with strict invariants — append-only, sums to zero. Merging them is how you end up with a
balancecolumn, the most expensive mistake in a fintech schema. - KYC is separate because its change driver is different. It moves when regulation or a provider moves, not when the product does, and it holds the most sensitive data in the system. It is also the likeliest candidate for real extraction, so clean ownership pays twice.
- Notifications and reporting are consequences, not participants. Reporting is where table ownership usually dies — someone needs a cross-domain view, writes the join, and the scheme unravels. Give it read models fed by events and the pressure disappears.
- Pricing/FX and transfers stay adjacent but distinct, with the caveat from part one: quoting and routing converge as you learn which rails can honour which rates. Expect to revisit that edge.
Note what is not a module. No users module everything writes to — "user" is not a domain, it is an ID several domains attach facts to. No payouts module: a payout is a stage of a transfer. And no layer modules — controllers, services, repositories — because layers cut across every feature, so a layer boundary blocks nothing.
How do you tell whether your modules are real?
Six checks you can run this week.
- Describe each module in three sentences without naming another module's tables. If you can't, the boundary doesn't exist yet.
- Delete a module's schema in a scratch database and rebuild. Only that module should fail; anything else that breaks was reaching in.
- Grep for each module's table names outside its own directory. Every hit is a leak — cheap now, expensive later.
- Count the public types each module exposes. Dozens means a namespace, not an interface.
- List the events each module publishes, from memory. If nobody can, they aren't designed.
- Ask which call sites would need a timeout if this module moved to another process tomorrow. "Hundreds, everywhere" means the interface is too chatty to extract, however clean the imports look.
None of these needs a rewrite to fix, and all get harder every month you postpone.
What if you draw a boundary in the wrong place?
You will. Boundaries are discovered by building, and a first version is the moment you know least about your own domain.
The point of a modular monolith is not to be right. It is to make being wrong cheap. Inside one deployable, moving a concept between modules is a refactor: move the files, merge two interfaces, fold one schema into the other in a single migration, fix the call sites the compiler flags. An afternoon, one pull request, nothing in flight. The same correction between two services is versioned contracts, a dual-write period, a data migration and a coordinated release — with live transfers running through the code you're changing.
A heuristic follows: when unsure, make fewer and larger modules. Splitting one later is straightforward — the internals are in one place and the compiler helps. Disentangling two that grew into each other is not.
Our founder spent two and a half years on Wise's core payment platform team, responsible for payment creation and the full payment lifecycle, and we have since built cross-border crypto-to-fiat transfer products and a real-time market surveillance platform for regulated markets. The systems that split well were never the ones with the cleverest diagrams — they were the ones where each piece already owned its data.
FAQ
Is a modular monolith just microservices in one repository?
No. It is one deployable, one database and one transaction boundary. It borrows data ownership and explicit interfaces from microservices, and declines the network between them along with every failure mode that comes with it.
Can modules share any tables at all?
Reference data nobody owns operationally — country codes, currency definitions — is a fair exception, ideally a read-only shared module. Anything with a lifecycle belongs to exactly one module. "Just the users table" is the exception that ends the scheme.
How do I run a report that needs data from five modules?
Give reporting its own module with read models updated from domain events, and query those. A little more code, and it removes the biggest source of boundary erosion.
Doesn't calling through interfaces instead of joining hurt performance?
In-process, the call itself costs almost nothing; N+1 access is what costs. Design module APIs to accept and return collections. If a screen genuinely needs a wide view, that is a read model, not a reason to join across schemas.
When should a module actually become a service?
When something real forces it: a compliance or security boundary, a different reliability target, a different runtime, or two teams blocked on one release. "The codebase feels big" is not a reason. A team boundary makes it partly a hiring decision.
Does this change what regulators or auditors need from us?
Architecture makes obligations easier or harder to satisfy; it doesn't define them. Segregation, retention and safeguarding requirements depend on your jurisdiction and licence — confirm those with a qualified lawyer, not a module diagram.
Planning the first version?
Module boundaries are almost free in week one and painful in month eighteen. They deserve an afternoon of deliberate discussion — who owns which data, what each module may ask for, which rules the build enforces — before anyone opens an editor.
GPO-Tech builds commercial software and connected products from Tallinn, Estonia, inside the EU and in European working hours. Our payments background is first-hand: two and a half years on Wise's core payment platform team, plus cross-border crypto-to-fiat transfer products and a real-time market surveillance platform for regulated markets. We have extracted services from well-bounded modules and untangled the other kind.
Send us your domain and we'll sketch the module boundaries with you. Request a quote → or book a call with our engineers →.
GPO-Tech designs and builds connected products and commercial software end to end — from one team in Tallinn, Estonia.