---
title: "Why I Start Projects With a Modular Monolith"
description: "The case for drawing clean module boundaries inside a single deployable app before jumping to microservices — and when I stop doing it."
url: https://sade.dev/en/journal/why-i-start-with-a-modular-monolith/
lang: en
author: "Muhammet Şafak"
published: 2026-05-23
updated: 2026-09-06
section: Journal
tags: ["architecture","modular-monolith","decisions","opinion"]
summary: "Drawing a module boundary and putting it in a separate service are two separate decisions; microservice architecture compresses them into one. A modular monolith pulls them apart — the boundary is a method call today, a network call only when a measured signal demands it. The whole value sits in enforcement: if CI does not break the build on a boundary violation, what you have is a monolith with tidy folder names."
---

# Why I Start Projects With a Modular Monolith

> Drawing a module boundary and putting it in a separate service are two separate decisions; microservice architecture compresses them into one. A modular monolith pulls them apart — the boundary is a method call today, a network call only when a measured signal demands it. The whole value sits in enforcement: if CI does not break the build on a boundary violation, what you have is a monolith with tidy folder names.

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 an invoice, 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 do 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](/en/journal/why-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.

## Frequently asked

**What is a modular monolith?**

One codebase, one deploy, one database, with real module boundaries inside the process. A module calls only another module's single public API class, never its internal Domain or Infrastructure code. The second rule lives in the database: a module never runs a direct SELECT against another module's table and never binds a foreign key to it.

**What keeps the module boundary from rotting?**

Four practices. Static dependency rules enforced in CI with deptrac or a similar tool, so a violation breaks the build. One public surface class per module, with visibility narrowed as far as the language allows. A data boundary built on an ID plus a public call rather than a foreign key. And one question in every code review: does this change cross a module boundary without permission?

**When do I split a module into its own service?**

When one of four signals is measured: the scaling profile diverges, ownership moves to a separate team, a different runtime is genuinely required, or fault isolation is mandatory and cannot be guaranteed inside the process. Until one of them emerges through measurement, splitting is premature optimization.


## Sources

- [MonolithFirst](https://martinfowler.com/bliki/MonolithFirst.html) — Martin Fowler
- [Don't start with a monolith](https://martinfowler.com/articles/dont-start-monolith.html) — Stefan Tilkov
