Back to Blog
EngineeringJuly 21, 2026·14 min

Stop Starting with Microservices

Microservices solve organisational scaling problems—not technical ones. If your team is under 20 engineers, you are paying a distributed-systems tax for benefits you cannot use. Build a modular monolith instead.

architecturemicroservicesmodular-monolithdomain-driven-designbackendgotypescriptsoftware-engineering

# Stop Starting with Microservices

In 2023, I joined a 9-person startup as a technical advisor. They had 4 microservices, a Kubernetes cluster, a Kafka instance, a service mesh, and approximately 700 daily active users. Their lead time from commit to production was 4 days. Their error budget was burned by the 12th of every month. Two senior engineers spent roughly 60% of their time not building product features but debugging distributed-systems problems: retry storms, split-brain configuration, and a gRPC version mismatch that caused silent data corruption for three weeks.

Their microservices were solving a problem they did not have.

This is not an isolated story. I have consulted for 14 companies over the past 5 years, and I have seen the same pattern at 11 of them: teams adopting microservices because it is what "real" engineering organisations do, then discovering—months and hundreds of thousands of euros later—that they traded a manageable monolithic codebase for a distributed tangle of network failures, data inconsistency, and deployment orchestration without gaining any of the benefits.

What follows is not an argument that microservices are bad. It is an argument that starting with microservices is a mistake for most teams. The correct path is a modular monolith—and here is how to build one.


The Problem Microservices Actually Solve

Let us be precise about what microservices are for. The original justification, from the people who coined the term, was not technical. It was organisational.

James Lewis and Martin Fowler wrote in 2014 that microservices are about enabling independent deployability so that multiple teams can work on different parts of a system without coordinating releases. The scaling problem they solve is team scaling, not traffic scaling. When you have 50 engineers across 6 teams, and each team needs to ship code without waiting for the others, microservices make sense.

The corollary: if you have one team of 8 engineers, microservices solve nothing. You can already ship without coordinating with yourself. You do not need independent deployability because you are the only team deploying.

Yet the industry spent a decade treating microservices as a technical architecture pattern rather than an organisational one. Teams with 3 developers split their TODO app into 5 services because conference talks told them to. The result was not agility. It was complexity without scale.

Team SizeMicroservices Benefit?What You Actually Need
1–8 engineersNo. You have one deployment cadence.Modular monolith
8–20 engineersMaybe. If you have clear team boundaries, start splitting at the seams.Modular monolith with 1–2 extracted services
20–50 engineersYes. Multiple teams need independent deployability.Microservices at domain boundaries
50+ engineersYes. Organisational scaling demands it.Microservices, likely with a platform team

The decision should be driven by team topology, not technology fashion.


What a Modular Monolith Actually Looks Like

A modular monolith is not a legacy ball of mud with a new name stapled on. It is a single deployable application whose internal structure enforces the same domain boundaries that microservices enforce at the network level.

The key difference: boundaries are enforced by the compiler, not by network latency.

Here is a concrete example in Go. A modular monolith for an e-commerce system might look like this:

cmd/
  server/
    main.go           // Composes modules, starts HTTP server
internal/
  orders/
    domain/
      order.go        // Order aggregate, pure domain logic
    app/
      service.go      // Use cases: PlaceOrder, CancelOrder
    ports/
      http_handler.go // HTTP adapter
      postgres.go     // PostgreSQL adapter
  payments/
    domain/
      payment.go
    app/
      service.go      // Use cases: AuthorizePayment, Refund
    ports/
      http_handler.go
      stripe.go       // Stripe adapter
  shipping/
    domain/
      shipment.go
    app/
      service.go
    ports/
      http_handler.go
  shared/
    events.go         // Domain event types (NOT shared utilities)
    money.go          // Value object used across modules
pkg/
  observability/      // Cross-cutting: logging, tracing, metrics

Each module—orders, payments, shipping—is a self-contained unit with its own domain logic, application services, and port adapters. The only things that cross module boundaries are domain events and well-defined interfaces.

The critical rule: no module may import another module's internal packages. The compiler enforces this. If orders/app/service.go tries to import payments/internal, your build breaks immediately. This is not a code review guideline. It is a mechanical guarantee.

// internal/orders/app/service.go — CORRECT
package app

import (
    "context"
    "example/internal/orders/domain"  // ✅ Own domain
    "example/internal/shared"          // ✅ Shared value objects
)

type Service struct {
    repo      domain.Repository
    publisher shared.EventPublisher // Interface, not concrete dependency
}

func (s *Service) PlaceOrder(ctx context.Context, cmd PlaceOrder) (*domain.Order, error) {
    order, err := domain.NewOrder(cmd.CustomerID, cmd.Items)
    if err != nil {
        return nil, err
    }

    if err := s.repo.Save(ctx, order); err != nil {
        return nil, err
    }

    // Publish domain event — payments module subscribes to this
    s.publisher.Publish(ctx, shared.OrderPlaced{
        OrderID:    order.ID,
        CustomerID: order.CustomerID,
        Total:      order.Total,
    })

    return order, nil
}
// internal/payments/app/service.go — also CORRECT
package app

import (
    "context"
    "example/internal/shared" // ✅ Only imports shared events
    // NO: "example/internal/orders/..." — compiler blocks this
)

type Service struct {
    gateway   domain.PaymentGateway
    events    shared.EventSubscriber
}

func (s *Service) SubscribeToOrderEvents(ctx context.Context) {
    s.events.Subscribe("OrderPlaced", func(ctx context.Context, evt shared.OrderPlaced) {
        s.AuthorizePayment(ctx, evt.OrderID, evt.Total)
    })
}

This is the entire trick. Module boundaries enforced by the build system. No network. No serialisation. No retry logic. No distributed tracing to figure out which service dropped your order.

The same pattern works in TypeScript with Nx or Turborepo workspace boundaries, in Java with Maven/Gradle modules and ArchUnit tests, and in Rust with crate privacy. The tooling matters less than the discipline.


How Modules Communicate: Events, Not RPC

In a microservices architecture, services communicate over the network—HTTP, gRPC, or a message broker. Each call has latency, failure modes, and serialisation overhead. In a modular monolith, modules communicate in-process, but they should still communicate through the same abstractions: domain events.

This is not academic purity. It is a design choice that keeps your modules decoupled and ensures that when you do eventually split a module into its own service, the transition is a deployment change—not a rewrite.

The pattern:

// shared/events.go — Domain events are plain structs, no framework dependency
package shared

import "time"

type OrderPlaced struct {
    OrderID    string    `json:"order_id"`
    CustomerID string    `json:"customer_id"`
    Total      Money     `json:"total"`
    Items      []Item    `json:"items"`
    OccurredAt time.Time `json:"occurred_at"`
}

// EventPublisher is the only interface that touches the messaging infrastructure.
// In the monolith, this is in-memory. In a split service, it backs onto Kafka/NATS.
type EventPublisher interface {
    Publish(ctx context.Context, event interface{}) error
}

type EventSubscriber interface {
    Subscribe(eventType string, handler interface{})
}

In the monolith, EventPublisher is an in-memory channel:

type InMemoryEventBus struct {
    mu       sync.RWMutex
    handlers map[string][]reflect.Value
}

func (b *InMemoryEventBus) Publish(ctx context.Context, event interface{}) error {
    eventType := reflect.TypeOf(event).Name()
    b.mu.RLock()
    handlers := b.handlers[eventType]
    b.mu.RUnlock()

    for _, h := range handlers {
        go h.Call([]reflect.Value{reflect.ValueOf(ctx), reflect.ValueOf(event)})
    }
    return nil
}

When you split the payments module into its own service, you swap the InMemoryEventBus for a Kafka-backed publisher—and the module code does not change. This is the hexagonal architecture pattern applied at the module level. The domain logic does not know whether the event handler is in-process or two network hops away.


The Real Costs of Microservices (That Nobody Budgets For)

Advocates talk about scalability and team autonomy. They skip the line items that show up on your infrastructure bill and your on-call rotation. Here is what microservices actually cost:

1. The Network Is Not a Function Call

Every inter-service call adds 0.5–5ms of latency (if everything is healthy). A user request that touches 4 services has a minimum latency of 2–20ms just from network overhead—before any business logic runs. Compare that to an in-process function call: 50–200 nanoseconds. That is 4 to 5 orders of magnitude difference.

This matters because latency compounds. If your checkout endpoint calls inventory-service (checks stock), then pricing-service (calculates totals), then payment-service (authorises), then notification-service (sends email), and each call adds 3ms—you are at 12ms minimum latency. A modular monolith does the same work in under 1ms.

2. You Now Have (N × M) Failure Modes

A monolith has one failure mode: the process crashes. A system with N microservices, where each can call M others, has N × M partial-failure states. The payment service is up but the inventory service is slow. The notification service is down but everything else works. Your checkout endpoint now needs to handle all of these gracefully—retries, circuit breakers, fallbacks, compensating transactions.

Every partial-failure handler is code you wrote instead of a product feature.

3. Distributed Transactions Are Not a Solved Problem

In a monolith, when PlaceOrder reserves inventory, charges the customer, and schedules shipping, these three operations run in a single database transaction. If the charge fails, the inventory reservation rolls back. ACID guarantees this.

In a microservices system, these three operations span three databases on three different machines. You need a saga—a choreography of compensating transactions. If step 2 fails, you must undo step 1. But what if the undo also fails? Now you need a dead-letter queue, an operations dashboard, and a human to manually reconcile.

None of this is impossible. It is all solvable. But every solution adds code, infrastructure, and operational complexity to solve a problem that a database transaction solves for free.

4. Developer Experience Takes a Real Hit

Setting up a development environment for a microservices system is a genuine productivity tax. Docker Compose with 8 services. Port conflicts. "It works on my machine" becomes "it works on nobody's machine." Telepresence, DevSpaces, and Tilt help, but they are bandages on a self-inflicted wound.

A modular monolith starts with go run ./cmd/server. One command. One process. A debugger that works first time. New engineers are productive on day one, not day ten.

5. Your Deploy Pipeline Gets Complicated

Deploying a monolith: build artifact, run tests, push, done. Deploying 8 microservices: you need orchestration. Which services changed? What order do they deploy? Does service A v2.3.1 work with service B v3.0.0? What if the database migration in service C hasn't finished yet? Now you need a release pipeline with dependency tracking, canary deployments per service, and rollback strategies that account for cross-service compatibility.


When You Should Actually Split

The decision to extract a microservice should be triggered by a specific, measurable problem—not a blog post and not a conference talk. Here are the triggers that actually matter:

Trigger 1: Independent Scaling

Your orders module handles 1,000 requests per second. Your reports module handles 2 requests per second but each one crunches 50MB of data and takes 30 seconds. Running them in the same process means 30-second report queries tie up workers that could be handling order requests.

This is a genuine reason to split. Extract the reports module into its own service with its own resource pool and its own autoscaling rules.

Trigger 2: Independent Deployment Cadence

Team Alpha ships to production 4 times a day. Team Beta ships once every 2 weeks and needs a manual QA sign-off before every deploy. If they share a monolith, Alpha's velocity is capped by Beta's process. Splitting at the team boundary lets each team deploy on its own schedule.

Trigger 3: Technology Heterogeneity (That Actually Matters)

Your core services are Go. But you need a real-time collaboration feature, and Elixir/Phoenix is genuinely the best tool for WebSocket-heavy, stateful connections. Or you need a machine-learning inference service, and Python's ecosystem is non-negotiable. These are legitimate reasons to introduce a polyglot architecture—as long as the benefit of the specialised technology outweighs the operational cost of maintaining a second stack.

Trigger 4: Organisational Scaling

You hired. You went from 8 engineers to 35. You now have 5 teams. They need independent deployability. This is the original use case for microservices, and it is the only one that is always valid.

What is not a valid trigger:
  • "It is the industry standard." The industry standard for many things is consulting McKinsey. Do not outsource your architecture decisions to memes.
  • "We might need to scale later." Premature scaling is the root of much suffering. Solve the problems you have, not the ones you imagine.
  • "The conference talk said monoliths are legacy." The conference talk was sponsored by a service mesh vendor.


From Modular Monolith to Microservices: The Migration Path

Build the modular monolith first. When one of the triggers above fires, extract a single module. Here is the migration path that has worked for me:

Phase 1: Extract the module's database. The extracted service gets its own schema or database instance. The monolith and the new service share nothing at the persistence layer. This is the hardest step because it breaks ACID transactions across the boundary. You must implement sagas or event-driven eventual consistency for operations that span modules. Phase 2: Swap the event bus. The in-memory EventPublisher is replaced with a message broker (Kafka, NATS, RabbitMQ) for the events that the extracted module publishes and subscribes to. The event types—OrderPlaced, PaymentAuthorized—do not change. Only the transport changes. Phase 3: Deploy and route. Deploy the new service alongside the monolith. Use a feature flag or traffic-splitting to gradually route requests to the new service. Monitor error rates, latency, and business metrics. If something breaks, flip the flag back.
# Feature flag for gradual extraction
feature_flags:
  - name: payment-service-extracted
    rollout: 5%        # Start with 5% of traffic
    metrics:
      - error_rate < 0.1%
      - p99_latency < 200ms
    auto_rollback: true
Phase 4: Remove the old module. Once the new service is stable at 100% traffic for a full business cycle (at least one month, including month-end and any seasonal peaks), delete the payments module from the monolith. Do not leave it "just in case." Dead code in a monolith accumulates like unused gym equipment in a basement. It gets in the way and nobody wants to be the one to get rid of it.

The key insight: you can do this one module at a time, over months or years, with zero downtime. The modular monolith gives you the option to split when you need to. Premature microservices take that option away by forcing you to deal with distributed-systems complexity from the start.


Real Numbers: What Changed When We Consolidated

I want to ground this with numbers from an actual migration. At a Swedish fintech client—a team of 11 engineers—we consolidated 6 microservices into a modular monolith. Here is what changed:

MetricBefore (6 Microservices)After (Modular Monolith)Change
P99 latency (checkout)847ms38ms-95%
Deployment time32 min average4 min average-87%
Production incidents/month142-86%
On-call alerts (after-hours)23/month3/month-87%
Time to working dev environment2.5 days (new hire)22 minutes-99%
Infrastructure cost€8,400/month€1,900/month-77%
Feature throughput4.2 features/sprint8.7 features/sprint+107%

The team doubled their feature throughput while cutting costs by 77%. The reason is not that microservices are inherently slow and monoliths are inherently fast. It is that the team was spending their cognitive budget on distributed-systems problems instead of product problems. Consolidation gave them their attention back.

One caveat: this team was 11 engineers with one codebase and one deployment pipeline. If they had been 50 engineers across 5 teams, the calculus would be different. That is the entire point. Architecture is a function of team structure, not of technology ideals.

Common Objections

"But we will have to rewrite everything when we scale!"

No, you will not. A well-structured modular monolith is a microservices system that happens to deploy as one artifact. The domain boundaries exist. The interfaces exist. The event contracts exist. Extracting a module is weeks of infrastructure work, not months of rewriting business logic.

The rewrite argument assumes your monolith is a ball of mud. If it is a ball of mud, microservices would not have helped either—you would just have a distributed ball of mud, which is worse.

"Monoliths don't scale!"

They scale further than you think. Shopify runs one of the largest Rails monoliths on the planet, processing millions of requests per minute. Stack Overflow ran on a few monolithic servers for over a decade. A single well-tuned Go or Rust monolith can handle thousands of requests per second on modest hardware.

Horizontal scaling of a monolith works fine: run N instances behind a load balancer, statelessly. The database is usually the bottleneck, not the application—and microservices do not magically fix database bottlenecks. They often make them worse by adding N connection pools instead of one.

"But what about independent technology choices?"

If your 8-person team genuinely needs 3 different programming languages in production, you have a different problem than architecture. Polyglot persistence and polyglot programming impose a real tax on hiring, onboarding, and operational tooling. Most teams overestimate the value of "the right language for the job" and underestimate the cost of maintaining expertise across multiple stacks.

The pragmatic position: pick one primary language. Use a second only when there is a clear, measurable advantage that justifies the operational cost—and extract only that module into its own service.


The Checklist: Modular Monolith Design Rules

Here is the ruleset I use when designing a modular monolith. These are not aspirational guidelines. They are enforceable by your build tooling and your CI pipeline.

  1. Module boundaries are package boundaries. Each domain module is a top-level package with its own domain, app, and ports sub-packages. No module may import another module's internal packages.

  1. Communication is via domain events. Modules communicate through an in-process event bus. No module calls another module's application service directly. No shared mutable state across modules.

  1. Each module owns its data. The orders module owns the orders table. The payments module owns the payments table. No cross-module JOINs. If the payments module needs order data, it consumes OrderPlaced events and stores a local projection.

  1. Shared kernel is minimal and immutable. The shared package contains only value objects (Money, Address, CustomerID) and domain event type definitions. No business logic. No database access. If you are tempted to put a utility function in shared, put it in pkg/ instead and maintain it as a proper library with its own tests and versioning.

  1. Database per module, not per microservice. In the monolith, all modules share a single PostgreSQL database, but each module has its own schema or table prefix. This enforces data ownership boundaries without the operational overhead of 8 database instances.

  1. CI enforces the rules. A linter or ArchUnit-style test runs on every PR and verifies that no illegal cross-module imports exist. If someone adds import ".../orders/internal" in the payments module, the build fails. No exceptions. No "just this once."

// CI check: ensure no cross-module imports
// Runs as part of `go test ./...`
func TestModuleBoundaries(t *testing.T) {
    modules := []string{"orders", "payments", "shipping", "inventory"}

    for _, mod := range modules {
        imports, err := parseImports(fmt.Sprintf("internal/%s", mod))
        require.NoError(t, err)

        for _, imp := range imports {
            for _, other := range modules {
                if other == mod {
                    continue
                }
                if strings.Contains(imp, fmt.Sprintf("internal/%s/", other)) {
                    t.Errorf("%s imports %s: cross-module dependency not allowed", mod, imp)
                }
            }
        }
    }
}

What to Do Next

If you are planning a new system, start with a modular monolith. It is not a temporary step on the way to microservices. It is a legitimate, permanent architectural choice that serves most teams for the entire lifetime of their product.

If you already have microservices and are feeling the pain, audit your system:

  1. Identify services that share a deployment cadence. If services A, B, and C always deploy together, they are one logical service. Consolidate them.

  1. Count cross-service calls per user request. If a typical user request touches more than 3 services, your latency is paying a network tax. Measure the overhead and put a euro value on it.

  1. Ask each team what percentage of their time goes to product features versus infrastructure. If infrastructure is above 20%, your architecture is costing more than it is worth.

  1. Check your team size. If you have fewer than 20 engineers and more than 3 microservices, you are almost certainly over-distributed. Consider consolidation.

The industry is slowly waking up from the microservices hangover. Conferences that spent 2015–2022 pushing "microservices for everything" are now running talks titled "When to Use a Monolith." Amazon Prime Video famously moved a monitoring service from microservices to a monolith and cut costs by 90%. The pendulum is swinging back—not to the ball of mud, but to the modular monolith.

Build the thing that solves your actual problems, not the thing that looks impressive in an architecture diagram. A clean modular monolith with clear domain boundaries, an event-driven internal architecture, and a 4-minute deploy pipeline is a better system than a microservices tangle that costs €8,000 a month and wakes you up at 3 AM.

Start there. Split when you must, not when a blog post tells you to.

— Including this one.

Got a project that needs illuminating?

We bring clarity to complex software challenges. Let's talk.

Get In Touch