On the first day of a new project, standing at the architecture whiteboard, someone always asks the same question: “How are we going to split the services?” My answer is usually the same: “For now, we’re not.”

This is not procrastination or laziness. I deliberately start almost every new project as a modular monolith: one codebase, one deploy, one database — but clean module boundaries inside. This post is the case for why.

What does “modular monolith” mean?

Three things need to be kept apart:

  • Classic monolith — no boundaries. Everything reaches everything, and over time it becomes a “big ball of mud”.
  • Microservices — there are boundaries, but a boundary is also a network call.
  • Modular monolith — there are boundaries, but the boundary is inside the process; a method call.

Here’s the real point: drawing a module boundary and putting it in a separate service are two separate decisions. Microservice architecture compresses them into one — a boundary automatically means separate deploy, separate database, network. The modular monolith pulls them apart: draw the boundary today, do the deployment when you actually need to.

How boundaries look in code

The layout I use on the Laravel side is roughly this:

app/
└── Modules/
    ├── Billing/
    │   ├── Domain/            # entities, value objects — module-specific
    │   ├── Application/       # use cases
    │   ├── Infrastructure/    # repositories, external service adapters
    │   └── BillingApi.php     # the module's ONE public surface
    ├── Catalog/
    └── Notification/

The rule is simple: a module calls only the *Api.php class of another module. Everything inside Domain and Infrastructure is private to that module — invisible from the outside.

The second rule is in the database: a module never runs a direct SELECT against another module’s table and never binds a foreign key to it. If Catalog needs a product, it goes through BillingApi; it does not reach into the billing_invoices table. This discipline looks tedious — that’s exactly where its value is.

Why I start here

Five concrete reasons:

  1. The cost of drawing a boundary wrong is low. In the first week of a project you don’t fully understand the domain. If a module boundary turns out wrong, fixing it in a modular monolith is a refactor — the IDE’s “move” command and a few hours. Fixing the same mistake in microservices involves the API contract of two services, two deploy pipelines, and migrating live data. Early on, the boundaries will definitely be wrong; choose the structure where that’s cheap to fix.

  2. One transaction. I can update two modules consistently inside a single DB transaction. In microservices the same thing means saga, outbox, eventual consistency. These are real tools — but a complexity you should defer until you genuinely need it.

  3. The operational load is almost zero. One deploy, one log stream, one target to monitor. Inter-service retries, distributed tracing, contract versioning — none of it. For a three-person team, that’s days saved per week.

  4. Refactoring tools work. “Rename”, “find usages”, static analysis — they all operate inside the process boundary. A method call is verified by the compiler; a network contract is verified by no one.

  5. It makes the move to microservices easier. This is the most often-missed point: because the boundaries are already clean, extracting a module into a separate service becomes a mechanical job later. A modular monolith is not the alternative to microservices; done right, it’s the cheapest path to them.

The discipline that keeps the boundary standing

The modular monolith has a single weak point: if the boundaries aren’t enforced, the structure quietly rots back into an old-style monolith. Every direct call made “just this once” pokes a hole.

Four practices keep the boundary standing:

  • Static dependency rules. Enforce a rule like “Catalog cannot reach Billing’s Domain layer” in CI with deptrac or a similar tool. A violation should break the build.
  • One public surface. Each module’s outward-facing API is a single class; everything else is internal. Narrow visibility as far as the language allows.
  • A data boundary. Relationships between modules are built with an ID plus a public call, not a foreign key.
  • One question in review. The most important question to ask in a code review: “Does this change cross a module boundary without permission?”

If the boundary isn’t enforced by CI, what you have isn’t a modular monolith — it’s just a monolith with tidy folder names. All the value is in the discipline.

When do I split into a separate service?

I wait for a measured reason before extracting a module into its own service. Any one of four signals is enough:

  1. The scaling profile diverges. The Notification module is CPU-bound, the rest of the system is I/O-bound. If I want to scale one independently of the other, splitting now pays off.
  2. Ownership diverges. The module has moved under a separate team’s responsibility, and independent deploys are no longer a technical need but an organizational one.
  3. A different runtime is needed. A heavy computation is far cheaper in Go, while the rest should stay in PHP.
  4. Fault isolation is mandatory. A module crashing really must not bring down the rest of the system, and I can’t guarantee that inside the process.

Until one of these emerges through measurement, splitting is premature optimization. This is a direct extension of my case for boring architecture: defer complexity until the evidence that forces it arrives. Because the boundaries are clean from the start, when that day comes the split is a planned step, not a terrifying migration.


Microservices are not an architectural style, they are a deployment decision. There is no reason to make that decision on the first day of a project — the moment you know the least about the domain. The modular monolith gives me the luxury of deferring that decision, and being ready when the time comes.

Draw the boundaries early, deploy them late.