All articles
12 September 2026
7 min read

The distributed monolith

By Tim Rutte, Cloud & Software ArchitectTopicBackend & Platforms

Five separate white boxes held together by one taut blue rope.

There is a state worse than the monolith you came from, and it gets reached regularly by people trying to leave it. Six services, six repositories, six deployment pipelines. And a release that only works if all six go out in the right order.

That is a distributed monolith: an architecture with the cost of distribution and the dependencies of a monolith. You pay for the network, for resilience, for operations and for traceability, and you get none of the freedoms you split for.

This article is about how to recognize the state, why it happens, and how to leave it without rebuilding everything again.

Five sentences, and you know

The diagnosis needs no tooling. If more than one of these is true, the answer is clear.

Two services have to be deployed together, or something breaks. Then they are not two services, they are one with a network connection in the middle.

Two services write to the same table. The schema is then a shared interface that nobody treats as one, and every column change is a coordinated project.

One call triggers a chain of four more, all synchronous. Response time is then the sum and availability is the product: five services at 99.9 percent each make 99.5.

A test environment needs every service in order to test a single one. That is the most expensive finding on the list, because it costs time daily.

A new field needs changes in three repositories. When a business change routinely touches three services, the boundary between them runs across the business, not along it.

Why the cuts sit where they sit

None of these states is the result of incompetence. They come from two decisions that looked sensible at the time they were made.

The cut followed the nouns. Customer service, product service, order service. That is the split the data model suggests, and it is the obvious one. Except a business process runs across it: placing an order touches customer, product, stock, price and payment. Cut along nouns and every process becomes a distributed call.

The cut followed the teams. Not wrong, but it describes who works, not what belongs together. Change the org chart and the boundaries stay put.

The cut that holds follows a third thing: the boundary of a transaction. What has to be consistent together in one step belongs in one service. What may happen afterwards may cross the boundary. That question is less comfortable than the one about nouns, because it demands domain knowledge, and it is the only one whose answer holds for years.

What a first cut looks like that does not produce this situation in the first place is covered in Which service to pull out of the monolith first.

The shared database

Among all the symptoms one is the most expensive, and it is the most often overlooked, because it does not hurt as long as nothing changes.

When two services share a table, every assumption one makes about the structure is a dependency on the other that is written down nowhere. No contract, no version, no test. Changing a column becomes a scheduling exercise, which is exactly what splitting was supposed to prevent.

The common repair attempt is to put an API in front of it and have the second service read through that. It moves the problem: an invisible coupling to the schema becomes a visible one to a call, and your response time now depends on the other service's availability.

What holds is the third route, and it is less comfortable: the service that needs the data gets its own copy of the subset it actually uses, filled from events emitted by the owning service. That is more work, it means living with slightly stale data, and it is the only thing that genuinely separates the two.

The rebuild of the table itself follows the familiar pattern: expand and contract, new structure alongside first, then move the readers, then remove the old. A cut in one step fails on precisely the service you forgot about.

What helps before anything gets rebuilt

The rebuild takes months. The worst property of a distributed monolith can be defused in a week, and that week is the best investment on the whole list.

That property is: one slow service takes everything down with it. Calls without a deadline pile up, connection pools fill, and a problem in a peripheral service becomes an outage of the home page in four minutes.

Three things help against that, and none of them requires an architectural decision:

// 1. A deadline on EVERY outgoing call. The default in many
//    clients is "forever", and that is exactly the bug.
client := &http.Client{Timeout: 800 * time.Millisecond}

// 2. A breaker that stops asking after enough failures.
//    A service that is down does not get faster from waiting.
var breaker = gobreaker.NewCircuitBreaker(gobreaker.Settings{
    Name: "stock",
    ReadyToTrip: func(c gobreaker.Counts) bool {
        return c.ConsecutiveFailures > 5
    },
    Timeout: 30 * time.Second,
})

func Stock(ctx context.Context, sku string) (int, error) {
    value, err := breaker.Execute(func() (any, error) {
        return fetchStock(ctx, client, sku)
    })
    if err != nil {
        // 3. A defined fallback instead of an error page.
        //    "Availability unknown" is an answer,
        //    a timeout after 30 seconds is not.
        return 0, ErrStockUnknown
    }
    return value.(int), nil
}

The third point is the one that gets done least often and pays the most. For almost every call there is a sensible answer without the other service: the last known value, a note instead of a number, moving on without that information. Define one for every call and you have turned a chain of dependencies into a series of degradations.

Untangling one connection at a time

The way out does not run through a plan for all six services, it runs through the one connection that hurts most. Usually it is the one furthest back in the chain that still waits synchronously for an answer.

The cut there is a question the business side can answer: does this have to happen now, or does it have to happen? The invoice does not have to exist before the order is confirmed. Neither does the notification. The stock check does.

For everything that merely has to happen, the call becomes an event. That removes the dependency from response time and from availability at the same time, and the receiving service may be gone for an hour without anybody noticing.

The condition for that sits in the small print and gets skipped happily: the receiver has to be able to process an event twice without it taking effect twice. Without idempotency you trade a synchronous dependency for duplicate invoices, and that is not progress.

How a synchronous call turns into a job is covered in From cron jobs to queues.

Merging back is also a solution

Finally, the option that rarely gets said out loud in these discussions although it is often the right one.

If two services are always deployed together, always changed together and need the same data, then they are one. Merging them back is not a step backwards, it is the correction of a cut that turned out to be wrong. One service fewer means one pipeline fewer, one network boundary fewer, one source of failure fewer.

So the useful target state for most grown systems is not "as many small services as possible" but a modular monolith with two or three real services alongside it: where a different language, a different load profile or a different release rhythm justifies one. Everything in between is the state this article is about.

How I cut services alongside an existing system is described on its own page.

This article belongs to a series about systems that already exist. The retrospective orders every article in it by situation.