The service that should not have been a service

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=pricing

Six 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 $?
0

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