Modular monolith with internal modules beside a hybrid architecture with two extracted Go services

Modular Monolith vs Microservices: A 2026 Decision Guide

Hamza Anis
Hamza AnisAuthor
·11 min read

For most teams, the right answer to "modular monolith vs microservices" is neither extreme. Run a modular monolith, meaning one deployable with hard, enforced boundaries between modules. Then extract the few services that have a concrete reason to be separate: a hot path that needs to scale on its own, a component that must fail or deploy independently, or a boundary a separate team owns. Splitting everything into microservices by default buys you network calls, distributed data, and an ops bill before it buys you anything else.

We run a microservices migration practice, so it may sound odd that we open with "don't split everything." It's the most useful thing we tell clients, and it's what the rest of this guide is about: what each option really costs, the signals that justify extracting a service, and how to extract one safely when you have them.

What each option actually is

A monolith is one deployable unit. A big ball of mud monolith has no internal boundaries: any code can call any other code and read any table. That's the thing people are right to escape.

A modular monolith is still one deployable, but it is split into modules (Orders, Billing, Catalog, …) that talk only through explicit interfaces and own their own tables. The boundaries are enforced by the compiler and by CI, not by good intentions.

Microservices turn those modules into separately deployed processes that communicate over the network (HTTP/gRPC or events) and each own their data store.

The important insight: most of the design benefit people want from microservices (clear ownership, small blast radius for changes, the ability to reason about one part in isolation) comes from boundaries, not from processes. A modular monolith gives you the boundaries. Microservices give you boundaries plus independent deployment and scaling, at a price.

What microservices really cost

The price is real, and it is mostly paid by the teams who never needed the benefits:

  • Every function call can now fail.

    In-process calls become network calls with latency, timeouts, retries, and partial failure. You need timeouts, circuit breakers, and idempotency everywhere.

  • Transactions disappear.

    A change that used to be one database transaction across two tables now spans two services. You get sagas, outboxes, and eventual consistency, and the bugs that come with them.

  • Operational surface multiplies.

    Each service needs CI, deploys, dashboards, alerts, on-call ownership, secrets, and version compatibility with its neighbours.

  • Debugging needs distributed tracing.

    A single user request now crosses five processes. Without tracing you're guessing.

  • Infra cost can go up, not down.

    Many small services, each with headroom, sidecars, and its own database, often cost more than one well-sized application.

This is why the industry conversation shifted. The widely cited example is Amazon Prime Video's video-quality monitoring team, which moved a distributed, serverless pipeline into a single process and reported cutting that service's infrastructure cost by over 90%. The original 2023 post is no longer online; Adrian Cockcroft's analysis is a good summary of what it did and didn't prove. That doesn't mean microservices are wrong. It means splitting has to pay for itself.

The decision checklist

Answer these per component, not for the whole system:

Question

If yes

If no

Does this component need to scale very differently from the rest (10×+ traffic, different hardware)?

Candidate for extraction

Keep in the monolith

Does it need to deploy on a different cadence, owned by a different team?

Candidate

Keep

Does it need independent failure isolation (e.g. must stay up when the rest degrades)?

Candidate

Keep

Does it have different compliance or security requirements (PCI, PII isolation)?

Candidate

Keep

Is it a hot path where a faster runtime would materially cut cost or latency?

Candidate (often a Go service)

Keep

Would extracting it require distributed transactions with its neighbours?

Fix the boundary first

Easier to extract

If a component doesn't clear at least one of the first five rows with a specific, measurable reason, it stays in the monolith. "Microservices are best practice" isn't a reason.

The hybrid most teams should run

In practice the winning shape for a scaling startup or mid-market product is:

  • A

    modular monolith core

    that holds most of the business logic, with enforced boundaries and one primary database (with schemas or table ownership per module).

  • Two to five extracted services

    for the hot or special paths: typically ingestion, real-time processing, search, pricing, or notifications.

  • Events

    between them for anything that doesn't need a synchronous answer (see our

    Kafka vs NATS guide

    for choosing the backbone).

This is also where Go earns its place. When the extracted service exists because of throughput or cost, writing it in Go usually means fewer instances and predictable latency under load. We compare runtimes in detail in Go vs Rust vs Node.js for high-throughput APIs.

Not sure which parts of your system actually need to be separate? That's exactly what our 1–2 week discovery answers, with numbers from your system instead of opinions. Book a technical discovery call.

Move the data before the code

The hardest part of any extraction isn't the service. It's the data. A component that shares tables with the rest of the monolith can't be extracted cleanly, however well its code is organised. So the first milestone is always data ownership:

  1. Find every read and write to the component's tables

    , including reports, cron jobs, and admin scripts. Query logs help here;

    pg_stat_statements

    lists every statement Postgres has seen.

  2. Route other modules through the owning module's API

    instead of querying its tables directly, even while everything is still in one process.

  3. Replace cross-module joins

    with API calls or read models built from events. If a join is truly unavoidable, the boundary is probably in the wrong place.

  4. Only then move the tables

    to the new service's own schema or database, with change-data-capture or dual writes during the switch.

Teams that skip this end up with a "distributed monolith": separate deployables that still share a database and still have to deploy together, which gives you the costs of both architectures and the benefits of neither.

How to extract a service without a big-bang rewrite

When a component clears the checklist, extract it incrementally with the strangler fig pattern (Martin Fowler's description): the new service grows around the old code path until the old path can be deleted.

  1. Draw the boundary inside the monolith first.

    Move the component behind an interface in its own module, with its own tables. If you can't do this in-process, you can't do it over the network either.

  2. Build the new service behind the same interface.

    Same contract, new implementation, its own data store fed by change-data-capture or dual writes during transition.

  3. Shadow traffic.

    Send a copy of real requests to the new service, compare responses with the monolith's, and log mismatches. Users still get the monolith's answer.

  4. Shift traffic gradually behind a feature flag

    : 1%, 10%, 50%, 100%, watching error rates and p99 latency at each step. Keep the flag as your rollback.

  5. Delete the old path

    once the new service has carried full traffic for long enough to trust it. Migrations that skip this step end up maintaining both forever.

This is how we run zero-downtime migrations: hot paths first, so the biggest wins land early, and no big-bang cutover.

Enforcing module boundaries in Go

A modular monolith only works if the boundaries hold. In Go you get two tools for free.

First, internal/ directories: code under modules/billing/internal/ can only be imported by code rooted at modules/billing/. Put everything except the module's public API there and the compiler enforces it.

Second, an architecture test in CI that fails when one module imports another module's non-API package:

Go
package architecture_test

import (
	"os/exec"
	"strings"
	"testing"
)

const root = "example.com/app/modules/"

// Modules may import each other only through their public "api" package.
func TestModuleBoundaries(t *testing.T) {
	out, err := exec.Command("go", "list", "-f",
		`{{.ImportPath}}{{range .Imports}} {{.}}{{end}}`, "./...").Output()
	if err != nil {
		t.Fatal(err)
	}
	for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
		fields := strings.Fields(line)
		pkg, imports := fields[0], fields[1:]
		from := moduleOf(pkg)
		if from == "" {
			continue
		}
		for _, imp := range imports {
			to := moduleOf(imp)
			if to == "" || to == from {
				continue
			}
			if imp != root+to+"/api" {
				t.Errorf("%s imports %s: cross-module imports must go through %s/api", pkg, imp, to)
			}
		}
	}
}

// moduleOf returns "billing" for "example.com/app/modules/billing/...".
func moduleOf(pkg string) string {
	if !strings.HasPrefix(pkg, root) {
		return ""
	}
	name, _, _ := strings.Cut(strings.TrimPrefix(pkg, root), "/")
	return name
}

Add a matching rule for the database: each module owns its tables, and no module queries another module's tables directly. When a boundary holds for a few months in-process, extracting that module later becomes a mostly mechanical job.

FAQ

Is a modular monolith better than microservices?

For most teams under roughly a hundred engineers, yes: one deployable with enforced module boundaries gives you most of the design benefits of microservices without the network, data-consistency, and operations cost. Extract services only where a specific scaling, isolation, or team-ownership need justifies it.

When should you migrate from a monolith to microservices?

When one part of the system needs to scale, deploy, or fail independently of the rest, and you can name it. Common signals: a hot path that forces you to scale the whole app, deploys blocked by unrelated teams, or a component with different reliability or compliance needs.

Are companies moving back from microservices to monoliths?

Some are consolidating services they split too early. The best-known example is Amazon Prime Video's monitoring team, which moved a distributed pipeline into a single process and reported a large infrastructure cost reduction. The lesson is to split for a reason, not by default.

How long does a monolith to microservices migration take?

Extracting the few services that matter, one at a time with the strangler pattern, typically takes 3 to 6 months depending on scope. A full rewrite into dozens of services takes far longer and carries far more risk, which is why we don't recommend it.

Can a modular monolith be written in Go?

Yes. Go's package system and internal/ directories make module boundaries enforceable at compile time, and a small architecture test in CI can block imports that cross module boundaries.

Decide with data, not dogma

If your monolith is buckling, the fix is rarely "rewrite it as fifty services." It's usually a firmer set of boundaries plus a handful of well-chosen Go services on the paths that hurt. That's the kind of monolith to microservices migration we run: incremental, measured, and reversible at every step. Book a technical discovery call and we'll help you figure out which parts of your system actually need to move.

Hamza Anis

Written by Hamza Anis

CEO & Senior Backend Engineer at CapregSoft. Building high-performance backends and AI automation systems for funded startups and mid-market teams.

Free resource

The Backend Scaling Checklist

The 12 checks we run before a system hits 10k req/sec — what to fix before it breaks in production. Free, no fluff.

Currently booking·2 discovery slots open this month
Book a call

Is your stack the bottleneck? Let's find out.

30-min call. We'll tell you straight if we can help.

No obligation. If we're not the right fit, we'll tell you straight — and point you somewhere better.