Few architecture debates generate as much confident bad advice as monolith versus microservices. Ask a room of developers and you will hear two certainties that contradict each other: that a monolith is technical debt waiting to happen, and that microservices are needless complexity. Both claims are wrong in the same way. They treat the question as a matter of taste, when it is actually a matter of fit. The right architecture depends on the team, the domain, and the stage of the product. The same split that saves one organization will sink another.
Start with what each shape actually is, because the words get used loosely. A monolith is a single deployable unit that contains the whole application: the web layer, the business logic, and usually the data access, all in one codebase and one process. A microservices architecture splits the application into many small services, each independently deployable, each owning its own data, communicating over the network. The difference that matters most is not size. It is the boundary between the pieces. In a monolith, a call between two modules is a function call: fast, type checked, and easy to trace. In microservices, that same call crosses a network, which means latency, serialization, partial failure, and versioning. Everything else follows from that one fact.
A monolith's strengths are real and often underrated. Development is simple. A new developer clones one repository, runs one command, and has the whole system on their machine. Refactoring is cheap, because the compiler or the test suite sees the whole codebase at once. Renaming a field touches every caller in one commit. Transactions are straightforward, because one database can guarantee that two related writes either both happen or neither does. Debugging is easier too: a stack trace shows the full path through the code, and there is no network hop to suspect. Deployment is one artifact, one version, one rollback. For a small team building a product whose shape is still changing, that simplicity is worth a great deal.
The monolith's weaknesses are also real, and they appear on a schedule. As the codebase grows, the build gets slower, the test suite gets slower, and the deploy becomes scarier, because every change ships with every other change. Teams start stepping on each other: two features in the same release, one of them buggy, block the other from going out. Scaling gets blunt. If one endpoint needs ten times the capacity and the rest need none, you scale the whole application anyway, because it is one unit. And the technology choice is one choice for everything. The language and framework picked on day one are the language and framework for every feature that follows.
Microservices fix those specific problems, and it is worth naming them precisely rather than praising the pattern in general. Independent deployment is the big one. A team can ship its service without coordinating a release train with five other teams. Independent scaling follows: the hot service gets more instances while the quiet ones stay small. Fault isolation is real too. If the recommendation service crashes, the checkout service keeps working, provided the two are genuinely independent and checkout does not block on recommendations. And technology can vary by service, so the team that needs a fast data pipeline can use one stack while the team that needs a simple CRUD API uses another.
But every one of those benefits has a price, and the price is paid in distributed systems problems. The network is not reliable, so every call between services needs a timeout, a retry policy, and a plan for what happens when the other service is down. Data consistency gets harder in a specific way. There is no single database transaction across two services, so a business operation that used to be one atomic write becomes a saga: a sequence of steps with compensating actions when a later step fails. That is not a small change. It is a different way of thinking about correctness, and it leaks into every feature that touches more than one service.
Observability gets harder too. A request that used to appear in one log file now crosses five services, each with its own logs, and reconstructing what happened requires a correlation id passed through every hop and a tracing system that collects it. Testing gets harder in the same direction. A unit test is still easy, but an integration test now needs several services running, with realistic data, in the right versions. Teams that split too early discover they have traded one hard problem, a big codebase, for several harder problems, distributed transactions, network failure modes, and cross service debugging, before they had any of the benefits that justify the trade.
So when does splitting actually help? There are a few honest signals. The first is team size and independence. When several teams work on the same codebase and their releases keep colliding, splitting along team boundaries can help, because each team gets its own deploy pipeline and its own release cadence. The second is genuinely different scaling needs. A video processing pipeline and a user profile API have nothing in common in their load profile, and scaling them together wastes money. The third is fault isolation that the business actually needs. If one feature going down must not take the rest of the system with it, and the product genuinely depends on that guarantee, the isolation is worth the cost. The fourth is regulatory or security boundaries, such as payment data that must live in a separate system with separate access controls.
And when does splitting hurt? Almost always when the product is young. A startup with three developers and an unproven product does not have a scaling problem. It has a learning problem, and microservices slow down learning, because every change to a boundary becomes a negotiation between services. Splitting hurts when the domain boundaries are not yet clear. If you cannot draw the lines between your services with confidence, you will draw them wrong, and a wrong service boundary is far more expensive than a wrong module boundary, because moving code between services means changing APIs, data ownership, and deployment pipelines. It hurts when the team lacks operational maturity. If you do not have good logging, tracing, alerting, and deployment automation, microservices will multiply the operational surface faster than the team can manage it.
The practical middle path, and the one most teams should take, is the modular monolith. Keep one deployable application, but organize it into strict modules with clear boundaries, each module owning its own data access and exposing a narrow interface to the others. No module reaches into another module's tables. No module imports another module's internals. The boundaries are enforced by code review and by structure, not by the network. Then, if one module later needs to become a service, the extraction is a mechanical job: the interface already exists, the data ownership is already clear, and the hard design work is already done. If it never needs to become a service, you have lost nothing.
The Go example below shows the shape. Two modules, orders and inventory, live in the same binary. They communicate through interfaces defined in a shared package, and neither one touches the other's storage. The day inventory needs to become a service, the implementation of the Inventory interface is replaced with an HTTP client, and the orders module does not change.
package domain
type Order struct {
ID string
ItemID string
Qty int
}
type Inventory interface {
Reserve(itemID string, qty int) error
Release(itemID string, qty int) error
}
type Orders struct {
inv Inventory
}
func NewOrders(inv Inventory) *Orders {
return &Orders{inv: inv}
}
func (o *Orders) Place(itemID string, qty int) (*Order, error) {
if err := o.inv.Reserve(itemID, qty); err != nil {
return nil, err
}
return &Order{ID: "ord_1", ItemID: itemID, Qty: qty}, nil
}
package inventory
import "errors"
var errOutOfStock = errors.New("out of stock")
type Store struct {
stock map[string]int
}
func NewStore() *Store {
return &Store{stock: map[string]int{"widget": 100}}
}
func (s *Store) Reserve(itemID string, qty int) error {
if s.stock[itemID] < qty {
return errOutOfStock
}
s.stock[itemID] -= qty
return nil
}
Notice what this buys you. The orders module never sees the stock map. The only contract between the modules is the interface. Tests for orders use a fake inventory, and tests for inventory use no orders code at all. That discipline is most of what microservices promise, without any of the network.
There is also a decision framework worth applying before any split. Ask four questions, and answer them with evidence, not enthusiasm. First, which teams are actually blocked by shared releases, and how often? If the answer is rarely, there is no deployment problem to solve. Second, which parts of the system have genuinely different scaling or availability needs? If the answer is none yet, there is no scaling problem to solve. Third, can you draw the service boundaries from the domain, with each service owning data that no other service needs to write? If the answer is no, the boundaries will be wrong. Fourth, does the team have the operational tooling to run many services: tracing, centralized logging, automated deploys, and alerts that a tired person can act on at 3 in the morning? If the answer is no, fix that first, because splitting without it is how you build a system nobody can debug.
A final warning about the most common failure mode. Teams split a monolith because it feels slow and messy, but the mess was never about the deployment shape. It was about unclear module boundaries, shared mutable state, and code that any part of the system could touch. Splitting that codebase into services does not fix the mess. It distributes it across a network, where it becomes harder to see and harder to fix. The order of work matters: clean up the boundaries inside the monolith first. If the monolith is still slow after that, the split will be easy, because the boundaries are already drawn. If it is fast enough after that, you have saved yourself a distributed system you did not need.
The question was never monolith or microservices. It was: what problem are you solving, and is a network boundary the cheapest way to solve it? Answer that honestly, and the architecture mostly picks itself.
Top comments (0)