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.
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.
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.
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.
fitgoodtopoor
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.
implementationpoortogood
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.
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.
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.
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.
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.
Where you cannot remove the name, make the violation fail the build.
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.
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.
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.
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:
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.
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.
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:
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.
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.
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.
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.
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.
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.
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.
This site runs Google Analytics. It tells me how people find the writing and roughly where they are reading from, so I know who I am writing for. Nothing is sold, shared, or used to target ads.