The system had been split into four services in 2019. Three of them shared a database, deployed together because a schema change touched all three, and could not start if any of the others was down. The fourth was genuinely independent and nobody had noticed that it was the only one.
The symptom
$ git log --oneline --since='6 months' --format='%H'
-- services/orders | wc -l
84
# and how many of those deploys included another service:
# orders alone 6
# orders + catalogue 41
# orders + catalogue + fulfilment 37
$ grep -rn 'DB_DATABASE' services/*/.env
services/orders/.env:DB_DATABASE=shop
services/catalogue/.env:DB_DATABASE=shop
services/fulfilment/.env:DB_DATABASE=shop
services/pricing/.env:DB_DATABASE=pricingSix independent deploys out of eighty-four. The other seventy-eight were coordinated releases with a network boundary in the middle, which is a monolith with extra failure modes.
Why it happens
The boundaries were drawn on a whiteboard from the domain language, which is the right starting point, and the data was never separated because separating it is the hard half and there is always something more urgent.
Once three services share a schema, every schema change is a coordinated release and every service can see every table. The boundary exists in the code layout and nowhere else.
The fix
The three tests, applied honestly
orders catalogue fulfilment pricing
deploy independently? no no no YES
owns its data? no no no YES
survives the others
being down? no no no YES
three noes is not a service. it is a module with a
network call in the middle of it.
and pricing answers yes three times — which is why it
was the one that was genuinely working.Writing the table out is the whole diagnosis and it takes twenty minutes. The resistance to doing it is that the answer is embarrassing, and the answer being embarrassing is not an argument against knowing it.
Merging three back into one
app/
Modules/
Orders/
Domain/ entities, value objects, events
Application/ handlers, queries
Http/ controllers, resources
Infrastructure/ repositories, migrations
Catalogue/
Fulfilment/
Shared/
Events/ the contracts between modules
The module structure preserves the boundary the split was trying to express, and it does so in a place where it can actually be enforced. The three services became three directories, the HTTP calls between them became method calls, and forty milliseconds of network latency per request disappeared.
// enforced in CI, so the boundary is real
// deptrac.yaml
layers:
- name: Orders
collectors: [{ type: directory, value: app/Modules/Orders/.* }]
- name: Catalogue
collectors: [{ type: directory, value: app/Modules/Catalogue/.* }]
- name: Shared
collectors: [{ type: directory, value: app/Shared/.* }]
ruleset:
Orders: [Shared] # NOT Catalogue
Catalogue: [Shared]
Shared: ~
A dependency rule checked in the pipeline is what makes a module boundary stronger than the service boundary it replaced, because the service boundary was enforced by nothing — the three services reached into each other’s tables freely. A build failure on a cross-module import is a stricter guarantee than a network call.
Communication between modules goes through events in Shared, dispatched synchronously in process. That keeps the coupling explicit and directional, and it means a future split is a matter of changing the dispatcher rather than untangling the calls.
What was gained, measured
before after
---------------------------------------------------------
p95 order creation 412ms 188ms
(three HTTP hops removed)
deploys per week 11 6
(but each one is a release, not a coordinated one)
failure modes on the
order path 7 2
lines of code 41,204 33,880
(client libraries, retries, circuit breakers,
serialisation, three sets of configuration)Seven thousand lines removed is mostly the machinery of distribution — HTTP clients, retry policies, circuit breakers, DTO mapping in both directions, three sets of health checks and three deployment configurations. All of that was correct code solving problems the split had created.
The one that stayed separate, and why
pricing stayed a service because:
it owns its data and nothing else reads it
it is CPU-bound and scales on a different axis —
3 replicas at peak, 1 at night, while the rest of
the application scales on connections
it is written in another language, for a reason
it can be down for 30 seconds and the application
degrades to a cached price rather than failing
that last property is what a service boundary is FOR.The graceful degradation is the property worth optimising for, and it is the one that is usually claimed and rarely true — three of the four services could not tolerate each other being down at all, which means the network boundary was pure cost.
The independent scaling axis is the other legitimate reason and it is measurable: pricing runs three replicas at peak and one overnight, while the rest of the application scales on a different signal entirely.
Verifying it worked
$ vendor/bin/deptrac analyse
Violations: 0
$ php artisan test
Tests: 1,412 passed # was 3 suites of 480, 402, 530
$ curl -s -o /dev/null -w '%{time_total}n' -X POST /api/orders -d @order.json
0.191
# and the assertion that the remaining boundary is real:
$ docker compose stop pricing
$ curl -s -X POST /api/orders -d @order.json | jq -r '.price_source'
"cache"
$ echo $?
0Stopping the remaining service and asserting that order creation still works is the test that proves the boundary is doing something. It is also the test that would have failed for the other three, which is how the decision was made.
One test suite instead of three is a smaller improvement than it looks, because the tests were mostly unchanged. The meaningful difference is that an integration test now covers the interaction between modules without needing three processes running.
What this costs
An argument, and admitting that a decision made two years ago by people still on the team was wrong. That is the actual difficulty and no amount of measurement removes it — the numbers make the case and somebody still has to say it out loud. Framing it as “the split was right and the data separation never happened” is both true and easier to hear than “the split was a mistake”.
The modular monolith also gives up something real: a runaway module can exhaust memory for everything, a slow query in the catalogue affects order creation, and there is no independent resource limit. Those were the problems the split was supposed to solve and it never solved them either, because the three shared a database and therefore shared a connection pool. Acknowledging that they remain unsolved, rather than claiming the merge fixed them, is what keeps the next architecture conversation honest.