Back to engineering notes
Architecture12 min read·

The $50,000 Microservices Mistake: When to Monolith Your SaaS Back First

Eleven services and five engineers is not an architecture, it is a tax. Here is what each surplus service actually costs, how to tell a premature split from a real one, and how to consolidate without a rewrite.

MicroservicesMonolithArchitectureEngineering velocitySaaS
Eleven services maintained by five engineers, with the monthly infrastructure and engineering overhead each one carries and the delivery slowdown that follows

Nobody regrets the first service. They regret the eleventh, which exists because the diagram had a box for it.

The call is rarely about architecture. It is about speed: the team used to ship weekly, now a small change takes nine days, and nobody can explain where the time goes. Everyone is busy. Nothing is obviously broken.

Then you look at the repositories. Eleven services, five engineers, all of them deployed together because they share a database and none of them can be released alone. That is not microservices — it is a monolith that has been distributed across a network, keeping every cost of both models and the benefits of neither.

This is not an argument against microservices. It is an argument against paying for them before you can spend the thing they buy. Below: what a service actually costs, how to tell a real split from a premature one, and how to consolidate without another rewrite.

01

The bill nobody itemises

Ask what a service costs and you get an infrastructure number, which is the small part. The real cost is what every service adds to every subsequent change — and that part never appears on an invoice, so it never gets debated.

Count it honestly for one service. A minimal production footprint with its share of a load balancer and log ingestion is around ninety-five dollars a month. A pipeline and a staging instance add forty. Then engineering time: patching, dependency updates, its share of on-call, and — the large one — the extra debugging that happens when a problem crosses a network boundary instead of a function call.

Call it six engineering hours a month per surplus service, conservatively. At a blended sixty dollars an hour, that is about five hundred dollars a month per service, all in. Eight services more than the shape of the product requires is roughly four thousand dollars a month — call it fifty thousand a year, before anyone has built anything a customer asked for.

The infrastructure cost of a microservice is the part you can see. The tax on every future change is the part that decides whether you ship this quarter.
What one surplus service costs per month
LineCostNotes
Runtime, load balancer share, logs~$95The only part anyone budgets for.
Pipeline and a staging instance~$40Multiplied by however many environments you keep.
Patching, dependency updates, on-call share~2 hoursEvery service needs its runtime and libraries kept current.
Cross-service debugging and coordination~4 hoursThe big one. A bug that spans a network boundary costs several times one that does not.
Total~$500Eight surplus services ≈ $4,000/month ≈ $48,000/year.
02

The velocity tax, which is the real damage

Money is the easier half to recover. The expensive half is what happens to a feature that spans four services: four repositories, four pull requests, four reviews, four deploys in a specific order, and a contract change that has to be backwards compatible in both directions because you cannot deploy them atomically.

That work is real engineering, it is invisible to everyone outside the team, and it produces no customer value. It is also the reason estimates stop being meaningful — the coding took an afternoon and the coordination took a week.

Watch what happens to the four measures below. When a team tells me they have slowed down and cannot say why, these are where it shows, and they recover quickly after consolidation because the work that disappears was never the work that mattered.

Before and after consolidating eleven services into three
MeasureBeforeAfter
Median lead time, small feature9 days2 days
Deploys needed for a typical change3.41
Change failure rate31%9%
Local environment setup40 minutes, often broken4 minutes
Time to first commit, new hire2 weeks3 days
03

How to tell a premature split from a real one

There is a specific and very common failure mode: services that are separately deployed but not separately deployable. Independent boxes on the diagram, one lockstep release in practice. The test is simple — can you deploy any one service, alone, on a Friday, without coordinating with anyone? If not, you have the costs of distribution without its benefits.

The clearest tell is a shared database. When several services read and write the same tables, there are no boundaries; there is one system with several front doors and no way to change a column safely. Everything else follows from that: schema changes require a coordinated release, nobody can say which service owns a field, and a bug investigation starts with a tracing tool for what is conceptually a single CRUD flow.

None of this means the team made bad decisions. It usually means the split happened at the wrong time — before the domain boundaries were known — so the seams were drawn where the whiteboard suggested rather than where the system actually separates.

  • Multiple services writing the same tables: The defining symptom of a distributed monolith. You have network calls between things that still share a transaction boundary in practice.
  • Releases that must be ordered: If service B must deploy before service A, they are one unit with extra steps and a new class of failure between them.
  • One team owns everything: Service boundaries exist to let teams move independently. With one team, they only add coordination between that team and itself.
  • You need distributed tracing to debug a form submission: Tracing is a fine tool for a genuinely distributed system. Needing it to follow a basic CRUD flow is a sign the flow should not be distributed.
  • The same auth, logging and config code in eleven places: Either duplicated and drifting, or in a shared library that must be upgraded everywhere at once — which is coupling with more steps.
04

What you were promised, and what arrived

Microservices deliver four things, and each requires a precondition that most early-stage teams do not have yet. Reading the promises against your own situation is usually enough to settle the argument without any ideology.

This is worth doing explicitly, because the benefits are real — at the right size. Nothing here says the architecture is wrong. It says the preconditions are load-bearing, and adopting the pattern without them buys the costs on their own.

The four promises and what each one needs to be true
PromiseRequiresAt 5 engineers
Independent deploymentNo shared database, backwards-compatible contracts, per-service pipelinesUsually false — one database, lockstep releases.
Independent scalingComponents with genuinely different load profilesSometimes true, and it is the best single reason to split one service out.
Team autonomyMultiple teams that would otherwise block each otherFalse by definition with one team.
Fault isolationGraceful degradation when a dependency is downUsually false — one service down takes the flow down anyway, now with retries and timeouts in between.
05

The modular monolith: keep the boundary, drop the network

The alternative is not a big ball of mud. It is one deployable unit with hard internal boundaries — modules with explicit public interfaces, private internals, and no reaching into each other's data.

Concretely: one repository, one deployment, one database, and a schema per module with a rule that no module queries another's tables. Cross-module access goes through a published interface, exactly as it would across a network, but as a function call that is typed, atomic, debuggable in one stack trace, and free.

The important part is enforcing it. Boundaries that rely on discipline erode in a quarter. Enforce them mechanically — architecture tests that fail the build on a forbidden import, module-level ownership in code review, a linter rule on cross-schema SQL. Do that, and when a module genuinely needs to become a service later, the extraction is mechanical: the interface already exists and the data is already separated.

Boundaries the build enforces, not the wiki
typescript
src/
  modules/
    billing/
      index.ts        // the ONLY file other modules may import
      internal/       // repositories, entities, jobs — private
      schema.sql      // owns tables in the "billing" schema
    invoicing/
      index.ts
      internal/
    identity/
      index.ts
      internal/

// The rule, enforced in CI rather than in a code review comment:
//   import { chargeCustomer } from '@/modules/billing'            ok
//   import { StripeClient } from '@/modules/billing/internal/...'  fail
//   SELECT * FROM billing.invoices        -- from invoicing module  fail

// dependency-cruiser, eslint boundaries, ArchUnit, Deptrac — pick one:
{
  "forbidden": [{
    "name": "no-module-internals",
    "from": { "path": "^src/modules/([^/]+)/" },
    "to":   { "path": "^src/modules/(?!$1)([^/]+)/internal/" },
    "severity": "error"
  }]
}

// When a module must become a service later, the extraction is mechanical:
// the public interface already exists, and nothing reads its tables.
06

Consolidating without another rewrite

Merging services back has the same rule as any migration: incremental, reversible, and boring. Do not schedule a consolidation project. Merge one pair at a time, in the order that returns the most relief.

Start with the chattiest pair — the two services that call each other most, especially any pair that cannot be deployed independently anyway. Move one's code into the other as a module, keeping its public interface as a module interface so callers change one import rather than their logic. Replace the HTTP call with a function call, keep the tests, delete the pipeline and the staging instance.

Do the data last if they already share a database — there is nothing to move. If each has its own, keep both schemas inside the consolidated service and merge them only when there is a reason beyond tidiness. Then measure lead time again before deciding whether to continue; two or three merges usually recover most of the velocity, and the remaining services may be fine exactly as they are.

  • Merge the chattiest pair first: Highest coupling, lowest independence, biggest immediate relief. The network call between them was never buying anything.
  • Keep the interface, remove the transport: The module keeps its published surface; callers switch from an HTTP client to an import. The boundary survives, the latency and failure modes do not.
  • Delete the pipeline, the alerting and the staging instance: Unglamorous, and where the running cost actually disappears. A service you merged but whose infrastructure you left behind saves nothing.
  • Re-measure after each merge: Lead time, deploys per change, change failure rate. Stop when the numbers stop improving — the goal is velocity, not a particular service count.
07

When splitting is genuinely the right call

There are good reasons, and they share a property: each one is a constraint you can point at, not a preference about structure.

A genuinely different scaling profile is the strongest. If image processing needs GPUs while the API needs memory, or one component handles a hundred times the traffic of everything else, separating it lets you buy the right hardware for each — and that is a cost and performance argument, not an architectural one.

Team size is the second, and the threshold is higher than people expect. Boundaries between services exist to remove coordination between teams; with fewer than about fifteen to twenty engineers there is not enough coordination to remove. The others are specific: a compliance or data-residency requirement that forces isolation, a component that genuinely needs a different runtime, and a blast radius you must contain — something whose failure or resource consumption must not be able to take the main application down.

If the honest answer to all five is no, you are buying distribution costs to solve a code organisation problem. Modules solve that, and they are free.
The decision test
QuestionIf yesIf no
Does this component have a different scaling or hardware profile?Strong reason to splitKeep it in the module
Will a separate team own it, with its own roadmap?Strong reason to splitKeep it — you are adding coordination, not removing it
Does a compliance or residency rule require isolation?Split, and document whyKeep it
Can it be deployed alone, on a Friday, without coordination?It is genuinely a serviceIt is a module wearing a service costume
Would its failure otherwise take the whole product down?Split for blast radiusKeep it

Frequently asked questions

How many services should an early-stage SaaS have?

Usually one, plus something for genuinely different workloads — a worker for background jobs, and occasionally one component with a distinct scaling profile. Below roughly fifteen engineers there is rarely enough coordination between teams for service boundaries to remove, so each extra service adds overhead without buying the autonomy it is supposed to provide.

What is a distributed monolith and how do I know I have one?

Services that are separately deployed but not separately deployable. The test: can you release any one of them, alone, without coordinating? If several services write the same database tables, or releases must happen in a particular order, you have the costs of distribution and none of the independence. A shared database across services is the clearest single indicator.

Isn't a monolith bad for scaling?

Rarely at this stage, and not in the way people mean. A single well-structured deployable scales horizontally behind a load balancer perfectly well into substantial traffic. What a monolith cannot do is scale one component independently of the rest — so split that component out when its profile genuinely differs, and leave the rest alone.

How do I keep a monolith from becoming a mess?

Enforce module boundaries mechanically. One repository, modules with a single public entry point and private internals, a database schema per module, and a rule that no module queries another's tables. Then make the build fail on a forbidden import — architecture tests, dependency-cruiser, ArchUnit, Deptrac. Discipline alone erodes within a quarter; a failing build does not.

Is consolidating services back a rewrite?

It should not be. Move one service's code into another as a module, keep its public interface so callers change an import rather than their logic, replace the HTTP call with a function call, and keep the existing tests. Merge one pair at a time, starting with the chattiest, and re-measure lead time after each. Most teams recover most of their velocity in two or three merges.

When is the right time to split a module into a service?

When one of five things is true: it has a genuinely different scaling or hardware profile, a separate team will own it, a compliance rule requires isolation, it needs a different runtime, or its failure must not be able to take the main application down. If a well-enforced module boundary already exists, the extraction at that point is mechanical rather than a project.