Skip to content
← All insights
Software architecture19 min read

Scaling out from a monolith: when to split and how to do it safely

How to distinguish capacity problems from architectural ones, recognise evidence for extracting a service and move a bounded capability without losing data ownership, reliability or delivery speed.

A monolith is a deployment boundary, not a diagnosis

A monolith packages several capabilities into one deployable application. It may be internally modular and easy to operate, or tightly coupled and difficult to change. The deployment shape alone does not tell you which.

Splitting an application introduces network calls, independent deployments, more configuration and more places for partial failure. Those costs can be worthwhile, but only when they address a constraint the existing shape cannot handle economically.

Begin by naming the problem. Slow requests, a long build, conflicting releases, unclear ownership and one overloaded database are different problems. Turning all of them into “we need microservices” prevents a useful diagnosis.

Scaling the monolith is different from splitting it

A monolith can often scale horizontally by running multiple stateless instances behind a load balancer. That increases capacity without changing application boundaries. Session state, scheduled work, local files and database connections need deliberate handling, but the programming model remains largely intact.

Architectural decomposition is different. A capability becomes independently deployed, usually with its own runtime, release path and operational ownership. Calls that were local become remote, and assumptions previously protected by one process or transaction must be made explicit.

Try the smaller intervention first when it addresses the evidence. Profile expensive paths, improve queries and indexes, remove avoidable serial work, isolate background work where appropriate and test another application instance under representative load. A service extraction should not substitute for performance investigation.

  • Capacity: can more application instances handle the workload?
  • Contention: does one workload consume resources needed by unrelated work?
  • Delivery: does one deployment unit create material release coordination?
  • Reliability: does one capability need a different failure or recovery boundary?
  • Ownership: can a team own the extracted capability through production?

Large code is not enough reason to distribute it

Repository size, age and untidy packages can make a monolith uncomfortable without making a network boundary useful. If responsibilities are unclear inside one process, extracting them often preserves the confusion and adds distributed failure modes.

A slow build may need build analysis, module boundaries or better test selection. Frequent merge conflicts may reflect ownership and change design. A fragile release may need automation, observability and smaller changes. Addressing these directly is usually cheaper than operating another service.

A modular monolith is often the best intermediate architecture. Give capabilities explicit interfaces, prevent arbitrary imports, separate application decisions from transport and persistence, and make ownership visible. Those changes improve the current system and produce evidence about whether a later extraction has a credible seam.

Look for a constraint with an independent shape

The strongest extraction candidates have a reason to change, scale or fail independently. One capability may receive most of the traffic, require a different release cadence or perform work whose resource profile harms interactive requests. Another may have a stable business boundary and a team ready to own it.

Use measurements over architectural preference. Break down latency, throughput, error rate, resource use and deployment lead time by capability. Review incidents and release delays. If the proposed boundary cannot be connected to an observed constraint, the extraction is still a hypothesis.

The cost needs to recur enough to repay the permanent operational overhead. A single difficult release does not necessarily justify a new service. Repeated contention, delayed changes or unacceptable blast radius provides a stronger case.

  • Demand is concentrated in one identifiable capability
  • The capability needs genuinely independent scaling or release timing
  • Failures in that area regularly affect unrelated behaviour
  • The boundary has a clear business responsibility and owner
  • The data it owns can be separated without constant cross-service transactions
  • The expected benefit can be measured after extraction

Create the seam before creating the service

First express the capability behind a narrow application interface while it still runs in the monolith. Callers should depend on meaningful inputs and results rather than its tables, framework classes or internal data structures.

This step reveals coupling while changes are still local and easy to test. If every caller needs a different subset of internal data, or every operation participates in a wider transaction, the proposed boundary is not ready.

The interface can initially have an in-process implementation. A later remote adapter can preserve the caller-facing contract while transport concerns remain at the edge. Do not pretend local and remote calls have identical behaviour: timeouts, unavailability and version compatibility must enter the model when the boundary moves.

Define a capability before choosing its transportscala
final case class CustomerId(value: String)
final case class SuspendCustomer(customerId: CustomerId, reason: String)

sealed trait SuspensionError
object SuspensionError {
  case object CustomerNotFound extends SuspensionError
  case object AlreadySuspended extends SuspensionError
}

trait CustomerSuspension {
  def suspend(command: SuspendCustomer):
    Future[Either[SuspensionError, Unit]]
}

// Start with an in-process implementation.
// A remote adapter can be introduced after the boundary is proven.

Data ownership determines whether the split is real

Code is relatively easy to move; shared mutable data is not. An extracted service that still lets both applications write the same tables has independent deployment in name only. A schema change or transaction can still couple both releases.

Name one owner for each piece of data and route writes through that owner. Other capabilities can request behaviour through the contract or maintain a deliberately derived view when stale data is acceptable. The consistency requirement must come from product behaviour rather than a blanket preference for immediate or eventual consistency.

Avoid introducing dual writes as an informal migration shortcut. If two writes must succeed together across a network boundary, partial failure needs an explicit recovery design. Idempotent operations, recorded hand-off state and reconciliation are often more important than the transport chosen.

  • Which capability is authoritative for this fact?
  • Who may create, update and delete it?
  • Which consumers need a copy, and how stale may it be?
  • What happens when the second operation fails?
  • How is a repeated request recognised safely?
  • How will migrated and old records be reconciled?

Extract through a reversible sequence

A big-bang move combines boundary design, data migration, new infrastructure and behaviour change. Prefer a sequence where each stage can be observed and reversed.

Protect the current behaviour, introduce the in-process boundary, then place a transport adapter behind a controlled routing decision. Move a narrow read or low-risk operation first where that provides useful evidence. Migrate ownership of writes only when failure and reconciliation behaviour are understood.

Keep the old path available until the new path has handled representative production behaviour. Compare outcomes where safe, watch latency and failure rates, and remove the old implementation only after the new owner is established. Temporary migration code should have an explicit removal condition.

An extraction plan with observable checkpointstext
1. Characterise current behaviour and production signals
2. Introduce an in-process capability interface
3. Move callers behind that interface
4. Deploy the new service with no production traffic
5. Route a controlled operation or traffic slice
6. Verify behaviour, latency and failure handling
7. Transfer authoritative writes and reconcile data
8. Remove the old path after an agreed observation period

A network boundary adds failure states

A local call either returns, throws or fails with the process. A remote call can time out after the server completed the operation, fail before reaching it, return an incompatible response or succeed while the caller loses the reply. Retries without idempotency can duplicate work.

Set timeouts from the behaviour the caller can tolerate, not from a generic platform default. Bound retries and use them only where repetition is safe. Decide whether the caller should fail, return a reduced response or continue work asynchronously when the dependency is unavailable.

Add correlation to logs and metrics across the boundary. Monitor requests, failures, latency and saturation for the new service as well as the effect on its callers. Independent deployment is valuable only when the team can diagnose and recover the service independently.

Testing must move with the boundary

Keep focused tests for the capability rules, then add integration evidence around serialisation, persistence and the deployed transport. Contract tests can protect the request and response assumptions shared by the caller and provider without turning every check into an end-to-end environment.

Retain a small number of complete journeys for behaviour that crosses the new boundary. They prove wiring and deployment, while narrower tests should carry most diagnostic detail. Test timeout, unavailable dependency, duplicate request and incompatible-data paths rather than only the successful response.

Performance testing should compare the original baseline with the new architecture under representative demand. Extraction can increase capacity for one workload while making ordinary calls slower through network and serialisation overhead. Measure both.

Scale out one constraint, then stop and measure

After the extraction, compare the result with the original reason for doing it. Did deployment lead time fall? Can the high-demand capability scale without scaling everything else? Did incidents become better isolated? Include operating cost, developer effort and new failure rates in that assessment.

Do not treat the first successful service as permission to split everything. The value came from resolving a particular constraint, not from increasing the service count. Other parts of the monolith may remain cheaper and safer together.

A sound scaling decision leaves the organisation with a boundary it can own, deploy, observe and change. If those capabilities are missing, improving the monolith and its delivery path is often the more scalable engineering choice.

  • Recheck the original capacity, delivery or reliability measure
  • Compare end-to-end latency and failure rates
  • Measure operational and on-call overhead
  • Confirm data ownership is no longer shared informally
  • Ask whether the owning team can release and recover independently
  • Record which assumptions were proved or disproved