# There Is No Wrong Architecture, Only a Wrong Fit

> A monolith is a deployment shape, not a design failure. What a poor implementation actually is, how to make a boundary you can prove, and the bill it sends every month.

- Canonical URL: https://laskov.dev/posts/module-boundaries/
- Published: 2026-09-19T00:00:00.000Z
- Author: Hristo Laskov
- Section: ARCHITECTURE
- Keywords: architecture, laravel, php, modular-monolith, microservices
- Tags: architecture, laravel, php, modular-monolith, microservices
- Site index for agents: https://laskov.dev/llms.txt

---

Recently [Freek Van der Herten](https://freek.dev/) shared a piece by Wendell Adriel -
[modular monoliths and creating real boundaries before reaching for microservices](https://wendelladriel.com/blog/modular-monoliths-creating-real-boundaries-before-reaching-for-microservices).
I read it on a Saturday morning and it annoyed me in the productive way.

Not because it is wrong. It is mostly right. It just left open the one question I
actually care about, and chasing that question put me down a rabbit hole. I read
everything I could find on the matter - the whole canon, the company write-ups, the
recent papers. They are in #13 if you want them.

They do not agree with each other. Some of them flatly contradict each other, and the
people who wrote them had each shipped the thing they were describing. That is not a
gap in the literature. That is the finding.

**There is no right architecture and no wrong one. No good pattern, no bad pattern.
There is a poor choice for a given use case, and there is a poor implementation of a
perfectly fine choice.** That is the whole list.

So the rest of this is my own answer, out of our own code: what a poor implementation
actually is, how to build a boundary you can prove rather than assert, and what that
costs us every month.

## 0. TL;DR

- **A poor choice is a shape whose costs you are not equipped to pay.** And the costs
  are mostly not money. They are on-call nights, calendar time, the hiring you would
  have to do, and the attention of the people you already have.
- **A poor implementation is a boundary nothing can catch you crossing.** A monolith
  with a `Contracts/` folder nobody enforces and twelve services sharing one database
  are the same defect. One of them just has a network in the middle.
- **The fix, in either shape, is to stop relying on people noticing.** A boundary that
  lives in a reviewer's memory is gone the week they are on holiday. Write it down as
  something automatic: a test that fails on a forbidden import, a build step that
  refuses the merge.
- **Enforcement works in layers, the way security does.** No single check is the
  defence; each one is another thing a mistake has to get past. More layers really is
  stronger. The only question is how many a given project is worth paying for.
- **Prove it by deleting a module.** Everyone says their modules are independent; almost
  nobody checks, because checking means ripping one out. Make the default for every
  cross-boundary interface a version that does nothing, and the test becomes: switch the
  module off, does the app still start?
- **It costs four files to move one string.** A field one module needs from another
  touches the object that carries the data, the interface that hands it over, the
  do-nothing version, and the real one. That is the daily tax, and most days it is fine.
- **The nastier cost is one the design creates.** Because doing nothing is the default,
  forgetting to register the real implementation is not an error, it is an answer.
  Nothing crashes; the screen just renders empty. #9 has the two-line test for it.
- **Choose for the codebase in front of you, not for your CV.** One of the two
  applications below sits on the simplest level, and that is not a backlog item. Moving
  it up would make it slower to change and harder to read, for guarantees nobody there
  needs.

## 1. The two ways to be wrong

If there is no wrong architecture, there have to be wrong *outcomes*, or the claim is
empty. There are two, and separating them is the most useful thing I have to offer.

| | poor implementation | good implementation |
| --- | --- | --- |
| **good fit** | Right shape, no real boundaries. Independent on the diagram; changing one still means releasing three others. | It works. The shape suits the constraints and the boundaries are real. |
| **poor fit** | Wrong shape, and no boundaries either. Nothing stops a change here breaking something over there. | Good boundaries, wrong problem. Paying daily for guarantees nobody uses. |

*Fit is an economic question you can answer in a meeting. Implementation is an engineering one you can only answer by trying to test it.*

**A poor choice** is picking a shape whose costs you are not equipped to pay, for
benefits you do not currently need.

The word doing the work is *equipped*, and the costs are mostly not money. An invoice is
the easiest cost to see and the least likely to be what stops you. The real currencies
are **nights** - somebody carries the pager for every process you add; **calendar** - a
six-month migration is six months of not shipping what a customer asked for;
**attention** - every service is a thing your team keeps a model of in their heads, and
that budget is fixed and small; and **hiring** - a platform needs somebody whose job is
the platform, and if nobody has that job then everybody has it badly.

Most teams who regret a split could afford the money. What they could not afford was the
attention.

**A poor implementation** is a boundary that nothing can falsify. It is legible only
after you try to test it, and - this is the part I want to insist on - it is
**shape-independent**. A monolith whose modules import each other freely and twelve
services sharing one database are the same defect, same cause, same fix. One of them
just has a network in the middle, which makes it more expensive to debug.

Everybody argues about the first. Almost everybody bleeds from the second.

## 2. What a poor implementation actually is

A boundary is a **claim**. "The billing module must not reach into the catalogue" is a
sentence, and a sentence is worth exactly as much as the thing that catches its
violation.

> **A poor implementation is a boundary whose violation nothing can detect.**
>
> Not one that gets violated - every boundary gets violated. One whose violation
> produces no signal: no failing build, no failing test, no compiler error, nothing but
> a diff that looks fine at 5pm on a Friday.

Boundaries do not erode because engineers are careless. They erode because erosion is
free.

### 2.1 What can actually enforce a rule

Four things can enforce a boundary, and they are not close to equally good.

| | Mechanism | Catches a violation | Fails when |
| --- | --- | --- | --- |
| 1 | Nothing - a rule in someone's head or a wiki | never | immediately |
| 2 | Review - a human notices the import | sometimes | the reviewer is away, or it is four files deep in a 900-line PR |
| 3 | A test - CI rejects the import | every push | nobody wrote the assertion for that pair |
| 4 | The loader or the type system - the class is not reachable | at author time | this is the one microservices give you free, and charge you for |

Most modular-monolith writing stops at 2 and gestures at 3. **Microservices jump straight
to 4**, and that, stripped of everything else, is what they are selling: the boundary is
not better because it is remote, it is better because it is harder to circumvent.

The interesting space is between 3 and 4, and you move through it by changing **what a
module is allowed to know**, not by adding rules about what it may do. Rules about
behaviour need a policeman. Rules about knowledge are enforced by the absence of a name
to type.

### 2.2 The ways a boundary actually fails

Below are the failures I have either caused or cleaned up. Each one gets a name, because
a name is something you can point at in a review: "that is the decorative contract
again" ends an argument faster than "I don't love this".

Read each row as a sentence: **this goes wrong**, because **of this**, and **this** would
have caught it before it shipped.

**Inside a single codebase:**

| What goes wrong | Why it happens | The check that catches it |
| --- | --- | --- |
| **Folders by layer.** `Services/` holds 300 files from every part of the business | the folder names describe the framework, not the business, so there is no line to cross | none exists yet; you have to draw a boundary before you can enforce one |
| **The decorative contract.** A `Contracts/` directory, and twelve imports that go around it | the interface is a convention, and nothing stops you skipping it | an architecture test asserting module A never references module B |
| **The universal back-channel.** Every part of the app imports `App\Models\User` | one shared, mutable model that everything can reach and change | move what other modules need behind a read-only interface returning plain values |
| **The cycle.** A depends on B depends on C depends on A | every single edge was reasonable on the day it was added | a test asserting the dependency graph stays acyclic |
| **The shared table.** Two parts of the app write the same rows | nobody ever said which one owns that table | name one owner per table; everyone else asks it to write |
| **The hand-edited generated file.** Someone "just fixed" a line in a file a tool produces | the tool owns that file and nobody wrote that down | regenerate it in CI and fail the build if the output differs |

**Across services:**

| What goes wrong | Why it happens | The check that catches it |
| --- | --- | --- |
| **The distributed monolith.** Services that always have to ship together | the coupling survived the split, in a shared schema or a shared library | deploy one service on its own to staging; if anything else breaks, they were never separate |
| **The shared database.** Two services, one schema | the code was split and the data was not | give each service its own credentials, with access only to the tables it owns |
| **Shared-library hell.** The same library at six different versions | the library became the coupling that the network was supposed to remove | a CI check that fails when services drift more than one version apart |
| **Nano-services.** A hundred of them, one per noun | the unit was picked by naming things, not by what fails independently | a written cost per service - repo, pipeline, alerts, on-call - paid before it is created |
| **The chatty read.** Six network calls to render one screen | the boundary was drawn straight through a join | a latency budget on the endpoint, failing the build when it is exceeded |
| **The orphan service.** Nobody is on call for it | a boundary was drawn without assigning anyone to it | a required owner field that CI checks against the team directory |

Now compare the middle columns. Every row in both tables is the same sentence: two things
were meant to be separate, nothing was checking, so they stopped being separate.

The monolith version and the microservices version are not two problems. They are one
problem, and the network changes only the price of finding it.

### 2.3 How to avoid it

One rule, three ways:

1. **Make the violation impossible to write.** Remove the name. If a module cannot name
   another module's class it cannot import it, and no discipline is required of anyone.
2. **Where you cannot remove the name, make the violation fail the build.**
3. **Make it something you can check on demand.** Not "we are fairly sure these parts
   are independent" - switch one off and watch whether the rest still runs.

The rest of this is what those cost, in two of the Laravel applications my team runs.
One is deliberately on the simplest level. Both are correctly placed, which is the thesis
again, in code this time.

## 3. Boundaries come in layers, not steps

There are three levels of enforcement, and I am going to walk through all three. Before
that, one thing about how they fit together, because it decides how you read everything
after it: **they are layers, not steps.** You do not move up to the next one and leave
the last one behind. Each level keeps everything underneath it and adds one more layer
on top.

Security has worked this way for decades, and it is worth borrowing the reasoning.
Nobody protects anything serious with a single control. There is a firewall, and behind
it the network is carved into segments, and the services inside those segments run
without admin rights, and the data underneath is encrypted anyway, and the passwords
expire on a schedule regardless. Not one of those is *the* defence. Each is one more
thing an intruder has to get past, and the whole arrangement assumes that sooner or
later one of them will fail.

Module boundaries work the same way, except the intruder is an ordinary colleague on a
deadline, including you. Naming the folders after the business makes the right place to
put things obvious. An architecture test catches the import that ignored the obvious. A
package boundary means the wiring has to be declared instead of assumed. Removing the
class name out of a module's reach means the wrong line cannot be typed at all. And the
last layer lets you switch a module off and watch whether anything actually breaks.

Each layer keeps the ones before it. So more layers really is stronger - there is no
version of this where having fewer checks catches more mistakes. The only question is
how many layers a codebase is worth paying for, which is the same question a security
team asks about a garden shed and a bank vault. Not "is the vault door better" -
obviously it is - but "is this a shed".

> **Diagram: The layers, outermost first. Each one keeps everything inside it, and none of them is the defence on its own. Select a layer for what it buys and what it costs.** Interactive version at https://laskov.dev/posts/module-boundaries/

```mermaid
flowchart TB
  R0["Level 0. Folders by technical layer."]
  R1["Level 1. Folders by business capability."]
  R2["Level 2. Capability as a package."]
  R3["Level 3. The contract lives outside every module."]
  NET["Above the ladder: the process boundary."]
  R0 -->|"name things after the business"| R1
  R1 -->|"give each one its own wiring"| R2
  R2 -->|"move the contract out"| R3
  R3 -.->|"a deployment decision, not a rung"| NET
%% note R0: Knowledge removed: none. Enforcement: none possible. This is a filing system, not a boundary.
%% note R1: Knowledge removed: none, every class may still name every class. Enforcement: arch tests, one per asserted pair. You gain a readable acyclic graph. You do not gain encapsulation.
%% note R2: Knowledge removed: none, but wiring becomes optional. Enforcement: the above, plus an on/off registry. You gain own migrations, own provider, switchability.
%% note R3: Knowledge removed: a module may not name another module. Enforcement: every ordered pair, zero exceptions, plus a stub that proves absence works. You gain deletability. You pay four files to move one string.
%% note NET: Available at any time, and it sits above this ladder rather than at the end of it. Buy it for scaling profiles, compliance boundaries and release cadences.
```

Running something as a separate service is not one of these layers. That is a different
decision, sitting outside all of them, and #11 is about when it is worth making.

## 4. Level one: name the folders after the business

Take the smaller of the two applications I want to use here. Each part of the business
is a directory. One `composer.json`, one namespace, nothing clever.

```text title="eight business areas, one namespace"
app/
  Domain/
    CRM/          ┐
    Licensing/    │  eight business areas, one directory each.
    Reference/    │  Reference is the bottom of the pile: everything
    Messaging/    │  is allowed to use it, it uses nothing.
    Admin/        ┘
  Models/         shared Eloquent models
  Http/           controllers, middleware, Data objects
```

Any class here can import any other class, including another area's Eloquent models, and
they do. So it would be easy to dismiss this as just folders. What makes it more than
folders is a pair of tests that fail the build when the wrong thing imports the wrong
thing:

```php title="tests/Architecture/DomainBoundariesTest.php"
arch('Reference depends on no other domain')
    ->expect('App\Domain\Reference')
    ->not->toUse([
        'App\Domain\Licensing',
        'App\Domain\CRM',
        'App\Domain\Messaging',
        'App\Domain\Admin',
    ]);

arch('Reference models are reached only through repositories')
    ->expect('App\Domain\Reference\Models')
    ->toOnlyBeUsedIn('App\Domain\Reference\Repositories');
```

The first test says `Reference` is not allowed to know anything else exists. That is what
keeps it usable by everybody: the moment it reaches back up into `Licensing`, you have a
loop, and once areas can call each other in circles you no longer have eight areas, you
have one large one with eight folder names.

Notice what these tests do *not* do. Nothing stops `CRM` importing `Reference`'s models
directly. That is allowed and it happens daily. These tests are not hiding anything
inside anything; they are only making sure the arrows all point one way. It is a small
promise, and for this codebase the right one, because a loop is the failure that actually
hurts and it costs a handful of lines to rule out.

The second test stops a habit that quietly removes the point of the first. If any class
can write `Textbook::query()` and go straight to the database, the repository layer
stops being a way in and becomes a folder some people use. So the test says: only
repositories may touch these models. Everyone else asks a repository.

**What this level does not give you** is any real privacy. Every area shares the same
user model, so a change to the user reaches all eight and nothing warns you.

It also does not scale, for a reason that is worth doing the arithmetic on. Every rule
here is one hand-written line saying "A may not use B". With eight business areas there
are 56 possible pairs somebody might write; with sixteen there are 240; with
twenty-four, 552. Nobody writes 552 assertions, and nobody maintains them:

```text
   business areas        possible pairs to police   (n² - n)
          8                      56
         16                     240
         24                     552
```

At eight areas we do not write all 56. We write the handful that matter, and accept that
the rest are unguarded, which is a reasonable trade at that size. At twenty-four it
stops being reasonable, because "the handful that matter" is no longer a handful and
nobody can hold the list in their head. That is the moment you need a rule that covers
every pair without anyone typing them out, and that is the next level but one.

## 5. Level two: each part becomes a package

**Leha** is a SaaS ERP for landscaping companies, and there each part of the application
is a Composer package that happens to live in the same repository. A couple of dozen of them. Each one carries its own service
provider, routes, migrations, translations and tests, and a single file lists which are
switched on:

```json title="modules_statuses.json"
{
    "Users": true,
    "Organizations": true,
    "Invoicing": true,
    "Warehouse": true
}
```

At level one, removing a part of the application is a refactor. Here it is editing one
word from `true` to `false`, and that part's routes, migrations and translations stop
loading. The part is no longer a region of the application; it is a thing the
application *has*.

The first time we flipped one of those to `false` we thought we had proved something.
We had not. The module stopped loading, and the application died three seconds later,
because a dozen other modules still mentioned its classes by name. All we had really
built was a switch that moved the crash slightly earlier.

That was the useful lesson, and it is why there is a third level. This one tidies up how
things are wired together. It does nothing about who depends on whom.

The other thing this level taught us was smaller and more expensive. Somebody tidied a
capital letter in a config file, everything worked on all four of our laptops, and
nothing worked in production. Macs treat `Modules` and `modules` as the same folder;
the servers do not. We lost most of a day to a letter. Since then I have a reflex about
anything that is case-sensitive in one environment and not the other, and it has saved
us at least twice.

## 6. Level three: move the contract out of the module

This is the step I have not seen written down much, and it is the one that does the real
work.

Say the billing module publishes an interface for everyone else to use, and keeps it in
its own folder. Any module that wants to use billing has to write
`Modules\Billing\Contracts\Billing` somewhere in its own code. That is better than
passing database models around, but the dependency is still there: the catalogue module
now contains the word `Billing`. Delete billing and the catalogue stops compiling.

So do not keep the interface in the module. Put it somewhere both sides can see and
neither side owns.

> **Diagram: Where the interface lives decides whether you can delete the module behind it.** Interactive version at https://laskov.dev/posts/module-boundaries/

```mermaid
flowchart TB
  A2["Catalog module"]
  B2["Billing module, with its own Contracts directory"]
  DEL2["Delete Billing and Catalog stops compiling"]
  A3["Catalog module"]
  B3["Billing module"]
  K3["Shared kernel, outside every module: Contracts, Snapshots, Events, Stubs"]
  DEL3["Delete Billing and the application still boots"]
  A2 -->|"imports Modules Billing Contracts"| B2
  B2 --> DEL2
  A3 -->|"uses"| K3
  B3 -->|"implements"| K3
  K3 --> DEL3
%% note B2: The coupling is narrower than an Eloquent model, but the dependency graph still has an edge with a module on both ends.
%% note K3: No module names another module. The kernel sees no module, and every module may see the kernel. The exception list in the architecture test is empty, and it is a named variable so that adding the first entry is an uncomfortable, reviewable act.
%% note DEL3: This is the only definition of a boundary I trust. Everything else is a directory with good intentions.
```

Every interface that crosses between modules lives in one shared folder that belongs to
no module. Modules are allowed to look at it; it is not allowed to look at them. Then one
test says no module may mention any other module, for every possible pairing, with no
exceptions:

```php title="tests/Architecture/ModuleIsolationTest.php"
$sanctionedBridges = [];

foreach ($modules as $self) {
    foreach ($modules as $other) {
        if ($self === $other) continue;

        arch("Modules\{$self} does not import Modules\{$other}")
            ->expect("Modules\{$self}")
            ->not->toUse("Modules\{$other}")
            ->ignoring($sanctionedBridges);
    }
}
```

That loop generates the hundreds of pairings nobody would ever write out by hand.
The empty array at the top is the entire policy. It is a named, empty list rather than
nothing at all, so that the day somebody needs an exception they have to type it into a
visible place where a reviewer will see it and ask why.

The interfaces come in two kinds and the names matter. A **Reader** asks another module a
question. A **Writer** asks another module to change its own data on your behalf. No
module ever writes another module's tables directly; it asks the owner, and the owner's
own validation and rules apply.

```php title="app/Domain/Shared/Contracts/ClientReader.php"
interface ClientReader
{
    public function findByPublicId(string $clientPublicId): ?ClientSnapshot;

    /** @return ClientSnapshot[] */
    public function listAccessibleForUserInOrg(
        string $userPublicId,
        ?string $organizationPublicId,
    ): array;
}
```

Look at the argument: a plain `string`, not a `Client` object. Identity crosses the
boundary as an opaque id and nothing else. This is not fussiness. A module physically
cannot accept another module's model if it is not allowed to write that model's name, and
once the id is just a string, nobody is tempted to try.

What comes back is a **snapshot**: a small, frozen object containing plain values only.
No database models, no date objects.

```php title="app/Domain/Shared/Snapshots/ClientSnapshot.php"
final readonly class ClientSnapshot
{
    public function __construct(
        public string $publicId,
        public string $name,
        public string $photoPreference = 'use_service_default',
    ) {}
}
```

Three fields, from a model that has thirty. A snapshot is not a copy of the table; it is
exactly what one other module needed, and no more. The pressure is always to add "just
one more field while I'm here", and if you give in every time, the snapshot slowly turns
back into the model and the boundary becomes decoration.

Keeping database models out is the important half. Hand another module a `Client` object
and you have not handed it a client, you have handed it the whole database: it can write
`$client->orders->first()->product` and walk from there to anywhere. The test still
passes. The boundary is fiction. A snapshot is data and cannot do that.

**One thing I am not happy about.** Laravel's relationships want a class name, and
writing that class name would fail the test above. So we write it as text instead:

```php title="modules/Invoicing/app/Models/Invoice.php"
// Importing this class properly would fail the architecture test.
return $this->belongsTo('Modules\\Clients\\Models\\Client', 'client_id');
```

The relationship still works at runtime, and the test no longer sees a forbidden
reference, because as far as the test is concerned it is just a string. But my editor can
no longer follow it, and neither can any tool that checks types. It is the least-bad
option I have found and I would happily be shown a better one.

## 7. Events: the part that does the real separating

Readers and writers still leave one module pointing at another, even if it is only
pointing at an interface. Something has to close that last gap, and that something is
events.

> **Diagram: Two ways for modules to talk. A reader points at the module it needs. An event points at nobody, which is why it is the one that truly separates.** Interactive version at https://laskov.dev/posts/module-boundaries/

```mermaid
flowchart TB
  C1["Consumer module"]
  K1["Shared kernel: ClientReader interface"]
  P1["Producer module: EloquentClientReader"]
  P2["Producer module"]
  K2["Shared kernel: TaskCompleted, a past-tense fact"]
  C2["Consumer module registers its own listener"]
  C3["Another consumer, added later"]
  C1 -->|"asks a question"| K1
  P1 -->|"implements"| K1
  P2 -->|"announces"| K2
  K2 -->|"dispatched, then forgotten"| C2
  K2 --> C3
%% note C1: The consumer has to know a ClientReader exists. The dependency is narrow, because it points at an interface in the kernel rather than at the producer, but it points.
%% note K1: Synchronous half of the seam. Scalars in, snapshot or scalar out. Naming is load-bearing: a Reader asks, a Writer asks the owner to change its own state.
%% note P1: The producing module claims the interface in its own service provider, overriding the default stub binding.
%% note P2: The producer announces a fact about its own domain and knows nothing about who cares. Delete every consumer and this module does not change.
%% note K2: Past tense, with an occurredAt. A fact that already happened, not a request to make something happen, which is what makes a listener safe to retry.
%% note C2: The listener lives in the consuming module and the consuming module registers it. There is no central EventServiceProvider collecting everyone's wiring.
%% note C3: Adding this costs the producer nothing. That is the property Uber lost to networked monoliths and had to rebuild as DOMA.
```

The difference is which way the dependency runs. When a module uses a reader, it has to
know that reader exists. When a module publishes an event, it announces something that
happened inside itself and has no idea who is listening. You can add five listeners or
delete all of them, and the module that published it never changes.

So the rule is simple: **if you want to know something, use a reader. If something has
happened, publish an event.** The way I check this in review is to ask whether the
publishing module would need editing if we deleted everyone who listens. If the answer
is yes, somebody wrote a function call and dressed it up as an event.

An event is a small frozen object carrying plain values, and nothing else:

```php title="app/Domain/Shared/Events/TaskCompleted.php"
#[TypeScript]
final readonly class TaskCompleted implements DomainEvent
{
    /** @param  string[]  $memberEmployeePublicIds */
    public function __construct(
        public string $taskPublicId,
        public string $teamPublicId,
        public array $memberEmployeePublicIds,
        public string $completedAt,
        public string $occurredAt,
    ) {}
}
```

Note the name is in the past tense, and it records when it happened. This is a statement
that something *did* happen, not an instruction to do something. That distinction is
what makes it safe to process an event twice, which matters, because sooner or later
something will retry. Handling "the task was completed" twice changes nothing the second
time. Handling "complete this task" twice might send two invoices.

The part that matters most is where the listening code lives. It lives with the module
that cares, and that module registers it itself:

```php title="modules/Teams/app/Providers/EventServiceProvider.php"
final class EventServiceProvider extends ServiceProvider
{
    protected $listen = [
        TaskCompleted::class   => [RecordTaskCompletionListener::class],
        UserDeactivated::class => [DeactivateEmployeesOnUserDeactivatedListener::class],
    ];

    protected static $shouldDiscoverEvents = false;
}
```

Open that one file and you can see everything the teams module cares about in the
outside world. Meanwhile the module that published `TaskCompleted` mentions none of it:
not the statistics that get updated, not the notification that goes out, not the other
modules that react. It genuinely does not know.

That is exactly what is missing when services have to be released together. In that
situation the publishing side does know who its listeners are, somewhere in its code, so
changing one drags the others along behind it. Putting a network in between neither
causes that nor cures it.

Two practical rules keep this working. First, every listener is written down in a file
like the one above rather than discovered automatically at runtime, because a list you
can read in a code review is worth more than magic. Second, any listener that does
something slow or external - sending an email, calling an API - goes onto a queue, so a
broken mail provider cannot fail the request that completed the task.

That second rule has a consequence you should choose on purpose rather than discover:
the moment work moves to a queue, some other part of the system is briefly out of date.
For a notification, fine. For a number somebody is about to make a decision on, not
fine. Decide it per event.

## 8. Stubs: how you prove any of this is true

Everything up to here is a claim I am making about our code. This is the part that makes
it checkable.

Every interface that crosses between modules has a second implementation that does
nothing at all. Readers return "nothing found". Writers accept the call and ignore it.
And that do-nothing version is what the application uses **by default**:

```php title="app/Providers/SharedContractsServiceProvider.php"
final class SharedContractsServiceProvider extends ServiceProvider
{
    /**
     * Named $stubBindings rather than the conventional $bindings because
     * Laravel auto-processes $bindings via bind() after register() runs,
     * which would defeat the bindIf() semantics we need here.
     */
    public array $stubBindings = [
        ClientReader::class => StubClientReader::class,
        TeamReader::class   => StubTeamReader::class,
        // …and the rest of them
    ];

    public function register(): void
    {
        foreach ($this->stubBindings as $abstract => $concrete) {
            $this->app->bindIf($abstract, $concrete);
        }
    }
}
```

The important word is `bindIf`: use this do-nothing version *only if* nothing else has
claimed the interface already. Each real module claims its own interfaces earlier during
startup, so in a normal run the real code wins every time and none of these defaults are
used.

> **Diagram: What happens at startup. Each module claims its own interfaces first; anything left unclaimed falls back to the do-nothing version.** Interactive version at https://laskov.dev/posts/module-boundaries/

```mermaid
flowchart TB
  BOOT["Application boots"]
  MOD["Module service providers register first"]
  ON["Module is ON: bind ClientReader to EloquentClientReader"]
  OFF["Module is OFF: nothing is bound"]
  SHARED["SharedContractsServiceProvider registers last: bindIf for every stub"]
  NOOP["Already bound, so bindIf is a no-op. The real implementation wins."]
  STUB["Not bound, so the stub binds. Reads return null, empty array or zero."]
  APP["The application boots either way. Consumers resolve their contracts."]
  BOOT --> MOD
  MOD --> ON
  MOD --> OFF
  ON --> SHARED
  OFF --> SHARED
  SHARED --> NOOP
  SHARED --> STUB
  NOOP --> APP
  STUB --> APP
%% note SHARED: bindIf, not bind. Named $stubBindings rather than $bindings, because Laravel auto-processes a property called $bindings with plain bind after register has finished, which would stamp the stubs back over every real implementation.
%% note STUB: This is what makes the boundary falsifiable. Switch a module off and screens that depended on it render empty instead of exploding.
%% note NOOP: The inversion is the whole trick. Defaults are null implementations, and a present module overrides its own.
%% note APP: A claim you can test by deletion. Genuinely optional is not an opinion about the design, it is a command you can run in CI.
```

That comment in the code is there because of an afternoon I would like back. Laravel
treats a property called `$bindings` specially and registers it *after* the code above
has run, which would have replaced every real implementation with the do-nothing one
across the entire application. Renaming the property fixed it. The boundary was nearly
undone by a helpful framework feature.

Here is what all that buys. Switch a module off, and the application still starts.
Everything that used it carries on, receiving "nothing found" instead of crashing, and
the screens that depended on it come up empty. So "these modules are independent" stops
being something I assert in an article and becomes something you can watch happen.

It is also an honest answer to "could you actually pull this out into a separate
service?" At level one the answer is a plan and some optimism. Here the answer is:
replace one line, so that the interface is served by something that makes an HTTP call
instead of a database query. Nothing else changes, because the interface already only
passes plain values back and forth - which is what all that strictness about plain values was for.

## 9. What this costs us

The bill, so that I am not only selling you the good parts.

**Four files to move one string.** Take the example from the summary: the invoicing
screen needs a client's VAT number. Add the field to the snapshot, make sure the
interface offers a way to get it, add it to the do-nothing version so the two still
match, and add the column to the real one. Most days that is a minor irritation. On the
day you are chasing a bug across five modules, it is four files at every step, and you
will resent each of them.

**The empty screen with no error.** This is the serious one, and it is created by the
design itself. If somebody adds a new interface and forgets the one line where the real
module claims it, nothing breaks. The do-nothing version answers instead, perfectly
politely, with nothing. No crash, no failing test, no entry in any log. A screen just
renders empty, as though there genuinely were no data. The same mechanism that lets a
module be missing also lets a module that is present look missing. If you take one
warning from this article, take that one, and write the boring test:

```php title="tests/Architecture/BindingsTest.php"
it('binds the real reader, not the stub', function () {
    expect(app(ClientReader::class))->not->toBeInstanceOf(StubClientReader::class);
});
```

**More queries than you would like.** If a screen needs data from three modules, that is
three separate questions to three separate owners. A single database join would have
answered all three at once, but no module is allowed to see another's tables, so it
cannot. This is where the boundary costs milliseconds.

**It is harder to find your way around.** At level one, a developer finds code by
following an import until they arrive. At level three they have to learn that the thing
they want is behind an interface, that the code implementing it lives in a module they
have not opened, and that the reason their change had no effect is a line of
configuration somewhere else entirely. That is a real cost, paid by every new person.

## 10. How many layers is this project worth?

More layers really is stronger, so the question is not which level is *correct*. It is
how much of this a given codebase earns.

Both extremes are real failures. Too few layers, and the boundaries quietly rot because
nothing is stopping anyone. Too many, and you have fitted a bank vault door to a garden
shed: every layer is another step between a developer and the change they came to make,
and it is paid for out of the same limited attention as everything else.

> **Diagram: Which level a project belongs on, and the one question that turns it into a question about separate services instead. Select a box for the reasoning.** Interactive version at https://laskov.dev/posts/module-boundaries/

```mermaid
flowchart TB
  START["Where do your folder names come from?"]
  LAYER["They name the framework: Controllers, Services, Models"]
  CAP["They name the business: Billing, Catalog, Licensing"]
  R0["Level 0. Not a boundary question yet. Rename by capability first."]
  Q1["Up to about eight capabilities, one team, no extraction in sight?"]
  R1["LEVEL 1. Folders, plus a few arch tests pinning the leaves and forbidding cycles."]
  Q2["Must a capability be shipped, sold or switched off on its own, in the same process?"]
  R2["LEVEL 2. Packages, plus an on/off registry."]
  Q3["Fifteen or more modules, several people in parallel, a real chance of extraction?"]
  R3["LEVEL 3. Shared kernel, readers, snapshots, events, stubs."]
  Q4["Different scaling profile, compliance boundary, or release cadence?"]
  NET["A services question, not a level question. Do level 3 first anyway."]
  START --> LAYER
  START --> CAP
  LAYER --> R0
  R0 --> CAP
  CAP --> Q1
  Q1 -->|"yes"| R1
  Q1 -->|"no"| Q2
  Q2 -->|"yes"| R2
  Q2 -->|"no"| Q3
  R2 --> Q3
  Q3 -->|"yes"| R3
  Q3 -->|"no"| R1
  R3 --> Q4
  Q4 -->|"yes"| NET
%% note R0: Nothing else matters until this is fixed. A folder named after a framework layer bounds nothing, so there is no claim for a test to enforce.
%% note R1: The smaller of my two examples is here. Eight business areas, one team. Costs a handful of assertions and catches the failure that actually hurts, which is the cycle.
%% note R2: The module stops loading when you flip the boolean. It does not stop being needed. This is a wiring achievement, not a coupling one.
%% note R3: Leha, the landscaping ERP, is here. A couple of dozen modules, every ordered pair asserted, zero exceptions. Budget four files to move one string across a boundary.
%% note NET: Extraction is a deployment decision. At rung 3 the seam is already scalar-in, scalar-out, so it costs one binding swap rather than a rewrite.
%% note Q1: Count business capabilities, not classes. The question behind it is how many forbidden pairs you would have to assert by hand.
%% note Q4: These are the only three reasons in the literature that survive scrutiny. Repository size is not one of them.
```

| | level 1 - folders | level 2 - packages | level 3 - shared interfaces |
| --- | --- | --- | --- |
| Business areas | up to about 8 | 8 to 20 | 15 and up |
| People working in parallel | one team | one or two | several |
| What is enforced | a few hand-written rules | the above, plus its own wiring | every possible pairing, no exceptions |
| Reading another area's data | import it directly | import it directly | ask through an interface |
| Removing an area | a refactor | flip one setting | flip one setting, app still starts |
| Adding one shared field | one file | one file | four files |
| Pulling something out into a service | hope | plausible | change one line |
| What it introduces | - | false confidence | the empty screen with no error |

The smaller application has eight business areas, one team, and nobody has ever
seriously suggested splitting it up. Level one, plus a few tests, is the right answer
there. Putting the full level-three machinery in front of eight areas would buy a slower
codebase and a bored team. This is not a compromise or something we have not got round
to yet. Moving it up would make it worse.

**Leha**, the landscaping ERP, has a couple of dozen modules and several people working
in different parts of it at the same time. There, the invoicing module being unable to
see the operations module is worth four files per field. The alternative is two dozen
modules free to import each other, which is not a modular application at all - it is the
same tangle as before, with better folder names.

We did not work this out from first principles, and the way we learned it was expensive.
**Talo**, an ERP for building managers, and **Leha** were both started on top of the same
shared base we already maintained. Two ERPs, same team, same framework: obviously they
should share a foundation, because a team should not solve the same problem twice.

It did not hold. They pulled apart early, and not because anyone changed their mind
about the architecture. Buildings and landscaping simply do not divide the same way -
different numbers of modules, different numbers of people in the code at once, and a
different answer to whether anything would ever need to run on its own.

That is the most expensive way I know to learn there is no one-size-fits-all, and I
would rather you had it from a paragraph.

And if you are on level one today, you are not stuck there. You add layers one area at a
time: move its interface to the shared place, add the do-nothing version behind it,
switch the callers over, delete the old direct imports, then turn on the test for that
pairing. Each one takes a day or two, and nothing is blocked while you are halfway
through.

## 11. When it really is time for separate services

You split something out when it needs to scale differently from everything else, when it
falls under rules the rest of the system does not, or when a separate team needs to
release on its own schedule. Not because the repository has got big.

The sharpest version of that test I have read comes from the Istio project, who merged
their own separate services back into one program after concluding that being able to
release and scale the pieces independently was worth less than what it cost to
coordinate them. Three things to weigh, and most teams never try to put a number on the
first two until they are already paying the third.

What level three changes is how much that decision costs you. Once modules only exchange
plain values, never share a database transaction, and never reach into each other's
data, moving one onto its own server stops being a rewrite and becomes a deployment
question. The interfaces do not change. The data being passed does not change. One line
changes, so that the answer arrives over the network instead of from the database next
door. You also inherit timeouts, retries, and a new thing that can page somebody at 3am.

That is the real reason to do this work inside a single application. Not because it
prepares you for separate services - plenty of teams never need them, and that is a fine
place to end up, not an unfinished one. It is that the discipline that would make the
split safe is the same discipline that makes the application understandable in the
meantime, and you get that second benefit every day whether or not you ever use the
first.

## 12. So, to come back to the beginning

There is no right architecture and no wrong one. There is a **poor choice** - a shape
whose costs you are not equipped to pay, in nights and attention and calendar time as
much as in money. And there is a **poor implementation** - a boundary that nothing can
catch you crossing.

The first is an economics problem. You can reason about it in advance, in a meeting,
with a whiteboard, and you will usually get it roughly right.

The second is an engineering problem, and it is the one that actually ruins codebases.
It is invisible until you try to test it, and by the time you try, it has been free for
three years.

So my answer to "monolith or microservices" is that it is the wrong question, usually
asked with great confidence. The question is: **what is holding the line, and can I
prove it?** Answer that, and where the code runs goes back to being what it always was -
a decision about scale, rules and release schedules, which you can make later and more
cheaply, with information you do not have yet.

A boundary you can delete a module behind is a boundary. Everything else is a directory
with good intentions.

## 13. What I read

Two are worth singling out.

Doğaç Eldenk and Hüseyin Alperen Çetin studied five years of production incidents at
one company while it split a large application into services. It is the only item here
that is evidence rather than experience. Their finding is that what helped most was not
the splitting at all: it was stopping database queries from reaching across business
areas, and making every read go through one owner first. That is the same conclusion as
levels one and three above, reached by people whose problem was measured in outages
rather than in taste, which is the closest thing to independent confirmation I have.

And Spring Modulith now ships boundary checking and reliable events between modules as
ordinary framework features. Roughly the second half of this article, done for you, if
you happen to work in Java.

| Article | Author | Year |
| --- | --- | --- |
| [Modular monoliths and real boundaries](https://wendelladriel.com/blog/modular-monoliths-creating-real-boundaries-before-reaching-for-microservices) | Wendell Adriel | 2026 |
| [Spring Modulith 2.0](https://spring.io/blog/2025/11/21/spring-modulith-2-0-ga-1-4-5-and-1-3-11-released/) | the Spring team | 2025 |
| [Incidents During Microservice Decomposition](https://arxiv.org/abs/2505.09813) | Doğaç Eldenk, Hüseyin Alperen Çetin | 2025 |
| [InfoQ Software Architecture and Design Trends](https://www.infoq.com/articles/architecture-trends-2025/) | InfoQ editorial team | 2025 |
| [Beyond buzzwords: a brief history of microservices patterns](https://developer.ibm.com/articles/cl-evolution-microservices-patterns/) | Kyle Brown | 2024 |
| [Towards Modern Development of Cloud Applications](https://dl.acm.org/doi/10.1145/3593856.3595909) | Google Research | 2023 |
| [Scaling up the Prime Video monitoring service](https://www.primevideotech.com/video-streaming/scaling-up-the-prime-video-audio-video-monitoring-service-and-reducing-costs-by-90) | Prime Video Tech | 2023 |
| [The Monolith Strikes Back](https://research.google/pubs/the-monolith-strikes-back-why-istio-migrated-from-microservices-to-a-monolithic-architecture/) | Google Research | 2021 |
| [Introducing Domain-Oriented Microservice Architecture](https://www.uber.com/us/en/blog/microservice-architecture/) | Adam Gluck | 2020 |
| [Introducing istiod](https://istio.io/latest/blog/2020/istiod/) | the Istio team | 2020 |
| [Modular Monolith: Architecture Enforcement](https://www.kamilgrzybek.com/blog/posts/modular-monolith-architecture-enforcement) | Kamil Grzybek | 2020 |
| [Modern Banking in 1500 Microservices](https://www.infoq.com/presentations/monzo-microservices/) | Matt Heath, Suhail Patel | 2020 |
| [Go + Services = One Goliath Project](https://blog.khanacademy.org/go-services-one-goliath-project/) | Khan Academy engineering | 2019 |
| [Deconstructing the Monolith](https://www.shopify.com/uk/enterprise/blog/monolithic-to-microservices) | Kirsten Westeinde | 2019 |
| [Goodbye Microservices](https://www.twilio.com/en-us/blog/developers/best-practices/goodbye-microservices) | Alexandra Noonan | 2018 |
| [How to break a Monolith into Microservices](https://martinfowler.com/articles/break-monolith-into-microservices.html) | Zhamak Dehghani | 2018 |
| [The Majestic Monolith](https://signalvnoise.com/svn3/the-majestic-monolith/) | David Heinemeier Hansson | 2016 |
| [MonolithFirst](https://martinfowler.com/bliki/MonolithFirst.html) | Martin Fowler | 2015 |
| [Don't start with a monolith](https://martinfowler.com/articles/dont-start-monolith.html) | Stefan Tilkov | 2015 |
| [Microservice Trade-Offs](https://martinfowler.com/articles/microservice-trade-offs.html) | Martin Fowler | 2015 |
| [Microservices](https://martinfowler.com/articles/microservices.html) | James Lewis, Martin Fowler | 2014 |
