When a new product starts, someone usually suggests microservices. The argument sounds reasonable: small services scale independently, teams own their own code, and failures stay contained. Those benefits are real, but they solve problems that a new product does not have yet.
A product in its first year has uncertain requirements, a small team and almost no traffic. What it needs most is the ability to change its mind cheaply. That is what a monolith gives you.
What a monolith makes easy
- One deploy. There is one pipeline, one set of logs and one thing to roll back.
- Real transactions. When an order and its payment record must succeed or fail together, a database transaction does it. Across services you need sagas, retries and compensating actions.
- Cheap refactoring. Moving a function between modules is a code change. Moving it between services is a migration with versioned APIs.
- Simple local development. One command starts everything.
The risk is a ball of mud
The usual objection is that monoliths rot. That happens when any code can call any other code and every table is shared. The fix is not a network boundary. The fix is a boundary in the code, enforced by tooling.
A modular monolith splits the codebase into modules by business area, such as billing, accounts and notifications. Each module has one public entry point, and nothing else may be imported from outside.
// The only file other modules may import.
export { createInvoice, markInvoicePaid } from "./service";
export type { Invoice } from "./types";
// Not exported: the database queries, the internal helpers,
// the tables. Other modules must go through the functions above.Then make the rule fail the build, so it does not depend on good intentions. Most linters can restrict imports by path.
{
"rules": {
"no-restricted-imports": [
"error",
{ "patterns": ["**/modules/*/!(index)", "**/modules/*/internal/**"] }
]
}
}When to split a module out
Splitting is a response to a specific pain, not a milestone. These are the signs we look for.
| Signal | What it means |
|---|---|
| One part needs far more capacity than the rest | Scale that part separately, for example an image processing queue |
| A different runtime is required | A machine learning model in Python next to a TypeScript app |
| Separate teams keep blocking each other | Give each team a deployable it fully owns |
| One part must stay up when the rest fails | Isolate it so an outage elsewhere does not reach it |
If none of these is true, a split adds network calls, deployment work and failure modes in exchange for nothing.
What we do
We start every new product as a modular monolith with the boundaries above, deployed from day one. When a real signal appears, the module with a clear public interface and its own tables lifts out in days instead of months. Starting simple is not a shortcut. It is how you keep the option to grow into something more complex when the evidence calls for it.