🏢 Analogy
A modular monolith is an open-plan office with clearly marked departments — everyone’s in one building (cheap to walk over and talk, one set of doors to lock), but each team has its own area and you don’t rummage through another team’s desks. Microservices are separate buildings across town: real autonomy (each can renovate or grow independently) but now every conversation is a phone call that can drop, and shipping a parcel between them is a logistics problem. You move to separate buildings when a team genuinely needs to — not because separate buildings are fashionable.
The real trade-off
The choice is not “monolith = bad, microservices = good.” It’s a trade of simplicity for autonomy:
graph LR subgraph Mono["Modular monolith (one deploy)"] A["order module"] -->|in-process call| B["billing module"] end subgraph Micro["Microservices (many deploys)"] C["order service"] -->|network call| D["billing service"] end Mono -->|"split only under real force"| Micro
| Modular monolith | Microservices | |
|---|---|---|
| Calls between modules | in-process (fast, reliable) | network (latency, partial failure) |
| Transactions | easy (one DB, ACID) | hard (sagas, eventual consistency) |
| Deploy | one unit | independent per service |
| Scaling | whole app | per service |
| Ops complexity | low | high (discovery, tracing, mesh) |
| Team autonomy | shared codebase | independent ownership |
| Moving a boundary | a refactor | a migration |
A modular monolith keeps the operational simplicity of one deployable while enforcing clean internal boundaries: modules talk through explicit interfaces, never by reaching into each other’s internals (Go’s internal/ and package encapsulation enforce this). It’s the right default for most systems.
When to actually split
Split a module into a service when there’s a real force, not fashion:
- Independent scaling — one part has wildly different load (e.g. image processing).
- Independent deploy cadence / team autonomy — a separate team must ship on its own schedule (Conway’s Law: boundaries mirror org structure).
- Fault isolation — that subsystem’s failures must not take down the rest.
- Different runtime/tech — it genuinely needs another language or specialized infra.
Raw line count and “microservices are modern” are not reasons.
See it: modules talk through an interface (split-ready)
If modules already communicate through a small interface — not by importing each other’s internals — extracting one into a service later is a swap of the implementation, not a rewrite. Here order depends on a Billing port; today it’s an in-process module, tomorrow an HTTP client. The order code doesn’t change:
package main
import "fmt"
// order module depends on this PORT, not on billing's internals.
type Billing interface {
Charge(orderID string, cents int64) (string, error)
}
// --- today: in-process module (a method call) ---
type localBilling struct{}
func (localBilling) Charge(orderID string, cents int64) (string, error) {
return "txn_local_" + orderID, nil
}
// --- tomorrow: a remote service, SAME interface ---
type remoteBilling struct{ baseURL string }
func (r remoteBilling) Charge(orderID string, cents int64) (string, error) {
// would POST to r.baseURL/charge over the network; faked deterministically
return "txn_remote_" + orderID, nil
}
type OrderService struct{ billing Billing }
func (s OrderService) Place(orderID string, cents int64) error {
txn, err := s.billing.Charge(orderID, cents)
if err != nil {
return err
}
fmt.Printf("order %s placed, charged via %s\n", orderID, txn)
return nil
}
func main() {
// Modular monolith: wire the in-process module.
mono := OrderService{billing: localBilling{}}
_ = mono.Place("A1", 1999)
// Extracted to a service later: ONLY this wiring line changes.
micro := OrderService{billing: remoteBilling{baseURL: "http://billing"}}
_ = micro.Place("A2", 1999)
}
The lesson: clean module seams make extraction cheap. The boundary you’d cut for a service is the same interface you’d define in a monolith — so design the boundary first, deploy it separately only when justified.
🐹 Start as a modular monolith — Go makes it natural
Go is unusually good at the modular-monolith sweet spot: internal/ and package encapsulation give you compiler-enforced module walls inside one binary, fast in-process calls, and a single deploy — without a service mesh, distributed tracing, or sagas to operate. Build feature packages (one per bounded context) that communicate through interfaces, give each its own data ownership, and you have a system that’s simple to run today and shaped to extract tomorrow. When a real force appears, lift that package out behind the same interface as an HTTP/gRPC client. Don’t pay the distributed-systems tax before you have to.
⚠️ Beware the distributed monolith
The worst outcome is splitting into services that are still tightly coupled — a shared database, synchronous call chains where one request fans out to five services, or releases that must go out in lock-step. You’ve paid microservices’ full cost (network latency, partial failure, ops overhead) and bought none of the autonomy. It usually comes from splitting too early or along the wrong boundaries, before the domain is understood. If you must evolve a monolith, use the strangler-fig pattern: extract one capability at a time behind a router, growing the new system around the old until the old can be retired — never a big-bang rewrite.
See also
- Project layout — module boundaries with
internal/and packages. - Microservices basics (cloud) — what a service looks like once extracted.
- Resilience patterns (cloud) — the failure handling distribution forces on you.
- Domain-Driven Design — bounded contexts as the split lines.
That’s the Architecture track — head back to the index or explore the Cloud-Native track.
Related topics
The architecture lens on Go repo structure — organizing by feature/domain (not technical layer) so packages map to bounded contexts, and where the domain↔infrastructure seam goes on disk.
arch-principlesDomain-Driven DesignModeling software around the business — the ubiquitous language, bounded contexts, entities vs value objects, and when DDD's complexity pays off (and when it doesn't).
arch-principlesHexagonal & Clean ArchitecturePorts & adapters and the dependency rule — keeping the domain free of I/O so the database, HTTP, and queues are swappable details. Demonstrated with Go interfaces.
Check your understanding
Score: 0 / 51. What is a 'modular monolith'?
A modular monolith keeps the operational simplicity of one deployable (one process, one build, in-process calls, easy transactions) while enforcing clean module boundaries inside — modules communicate through explicit interfaces, not by reaching into each other's internals. It's often the best default: you get most of the design benefits of microservices (clear boundaries, separation of concerns) without the distributed-systems tax. And it keeps a future split tractable.
2. Which is a genuine reason to split a module into a separate service?
Splitting buys *independent* deployment, scaling, failure isolation, and tech choice — at the cost of network calls, distributed transactions, eventual consistency, and operational overhead. So the justification should be a real force: this part must scale separately, a separate team must ship on its own cadence, it needs a different runtime, or its failures must be isolated. 'It's modern' and raw line count are not reasons. Conway's Law matters: service boundaries tend to mirror team boundaries.
3. What is the 'distributed monolith' anti-pattern?
A distributed monolith is the worst of both worlds: you've paid the network/ops tax of splitting, but the services are still coupled — a shared database, synchronous call chains, or lock-step releases mean you can't deploy or scale them independently. You get latency, partial failure, and deployment complexity without the autonomy that justifies them. It usually comes from splitting along the wrong boundaries (or too early), before the domain boundaries are understood.
4. What is the 'strangler fig' pattern for evolving architecture?
Named after a vine that grows around a tree until it replaces it, the strangler-fig pattern (Martin Fowler) extracts pieces of a monolith one at a time: a facade/router sends some requests to the new service and the rest to the old monolith, and you migrate functionality incrementally until the monolith can be retired. It avoids the catastrophic risk of a big-bang rewrite and lets you learn the right boundaries as you go.
5. Why is a well-bounded modular monolith a good STARTING point even if you expect to need services later?
Early on you don't yet know the correct boundaries — splitting prematurely freezes guesses into expensive-to-change network APIs. In a monolith, moving a boundary is a refactor, not a cross-service migration. If you keep modules cleanly separated (communicate via interfaces, no reaching into another module's internals, ideally separate schemas), each module is already shaped like a service and can be lifted out when a real force demands it. 'Modular monolith first, extract when justified' is the low-regret path.
Comments
Sign in with GitHub to join the discussion.