laskov.dev

Notes on life, code, security and stuff worth paying attention to

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.

In this article
  1. 0. TL;DR
  2. 1. The two ways to be wrong
  3. 2. What a poor implementation actually is
  4. 3. Boundaries come in layers, not steps
  5. 4. Level one: name the folders after the business
  6. 5. Level two: each part becomes a package
  7. 6. Level three: move the contract out of the module
  8. 7. Events: the part that does the real separating
  9. 8. Stubs: how you prove any of this is true
  10. 9. What this costs us
  11. 10. How many layers is this project worth?
  12. 11. When it really is time for separate services
  13. 12. So, to come back to the beginning
  14. 13. What I read

Recently Freek Van der Herten shared a piece by Wendell Adriel - modular monoliths and 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

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.

fit good to poor

good fit · poor implementation

Right shape, no real boundaries

Splitting up was the correct call, and then nothing enforced the new lines. Ten services that look independent on the diagram, where changing one still means releasing three others the same afternoon. You are paying for the network and not collecting the independence it was supposed to buy.

good fit · good implementation

It works

The shape suits the constraints and the boundaries are real. People change one part without arranging a meeting about it, and a component can be replaced without the rest of the system noticing. Monolith or services: which one it is turns out not to be the interesting question.

poor fit · poor implementation

Wrong shape, and no boundaries either

One codebase where every part reaches into every other, or a split that added a network without assigning ownership of anything. Both fail identically: nothing stops a change here from breaking something over there. Distribution neither causes that nor cures it. It only makes the debugging more expensive.

poor fit · good implementation

Good boundaries, wrong problem

Careful, properly enforced boundaries protecting a system that was never going to need them. Nothing is broken. You are just paying every day, in indirection and ops and people's attention, for guarantees nobody is using.

implementation poor to good

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".

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.

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.

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:

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:

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.

Where the interface lives decides whether you can delete the module behind it.

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:

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.

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.

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:

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.

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.

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:

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:

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:

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.

What happens at startup. Each module claims its own interfaces first; anything left unclaimed falls back to the do-nothing version.

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:

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.

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.
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 Wendell Adriel 2026
Spring Modulith 2.0 the Spring team 2025
Incidents During Microservice Decomposition Doğaç Eldenk, Hüseyin Alperen Çetin 2025
InfoQ Software Architecture and Design Trends InfoQ editorial team 2025
Beyond buzzwords: a brief history of microservices patterns Kyle Brown 2024
Towards Modern Development of Cloud Applications Google Research 2023
Scaling up the Prime Video monitoring service Prime Video Tech 2023
The Monolith Strikes Back Google Research 2021
Introducing Domain-Oriented Microservice Architecture Adam Gluck 2020
Introducing istiod the Istio team 2020
Modular Monolith: Architecture Enforcement Kamil Grzybek 2020
Modern Banking in 1500 Microservices Matt Heath, Suhail Patel 2020
Go + Services = One Goliath Project Khan Academy engineering 2019
Deconstructing the Monolith Kirsten Westeinde 2019
Goodbye Microservices Alexandra Noonan 2018
How to break a Monolith into Microservices Zhamak Dehghani 2018
The Majestic Monolith David Heinemeier Hansson 2016
MonolithFirst Martin Fowler 2015
Don't start with a monolith Stefan Tilkov 2015
Microservice Trade-Offs Martin Fowler 2015
Microservices James Lewis, Martin Fowler 2014