Syntax Kitchen
All kitchen notes

Start with a modular monolith

Most new products do not need microservices. A single deployable with strict internal boundaries is faster to build, easier to change, and leaves the door open to split later.

Syntax Kitchen, , 3 min read

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.

modules/billing/index.ts
ts
// 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.

eslint boundaries (excerpt)
json
{
  "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.

SignalWhat it means
One part needs far more capacity than the restScale that part separately, for example an image processing queue
A different runtime is requiredA machine learning model in Python next to a TypeScript app
Separate teams keep blocking each otherGive each team a deployable it fully owns
One part must stay up when the rest failsIsolate 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.

Next noteStatic or server rendered? How we choose for Next.js sites

Take a seat.

Tell us what you are hungry for. We reply within two working days.