Skip to main content

From Monolith to Macroservices: The Architecture Journey Nobody Tells You About

· 9 min read
Revant Nandgaonkar
Maintainer of Framework M

A question I keep running into when talking to developers:

"Can we deploy modules separately and manage them independently—like microservices?"

My answer is always: "Yes—if designed that way."

But that answer deserves a much longer conversation. So here it is.

The Up-Front Tax of Microservices

Microservices solve real problems: team independence, isolated deployments, per-service scaling. At the right scale, they are the right answer.

But they come with an enormous up-front cost:

  • Service discovery, API gateways, distributed tracing
  • Eventual consistency instead of ACID transactions
  • Network latency on every cross-service call
  • Separate CI/CD pipelines, separate observability stacks
  • Distributed debugging at 2 AM when the on-call alert fires

This is the tax you pay before you have a single paying customer. Teams that commit to microservices early often find themselves solving infrastructure problems instead of domain problems.

The label "microservice" also gets stretched. A service with a full framework, an ORM, connection pooling, and its own database—that's not micro in footprint, even if it's small in scope. Truly minimal services—a compiled binary, a Wasm function, a purpose-built engine like TigerBeetle—carry only what the problem requires. That's the spirit worth preserving, whatever stack you choose.

The Monolith Is Not the Enemy

When you are building a business product, you have exactly one scarce resource in the early days: iteration speed.

A monolith gives you:

  • One codebase, one deploy, one database transaction boundary
  • Refactoring across domain boundaries with your IDE, not via Slack
  • No network failures between modules—just function calls
  • Full ACID guarantees without distributed saga choreography

The conventional wisdom says: "Start with a monolith, then break it apart when you need to." But that's where the advice usually stops. Nobody explains how to break it apart without rewriting everything.

That gap is exactly what Framework M is designed to eliminate—because the decomposition seams are already baked in. Every module depends on Protocols, not concrete infrastructure. When you hit a real bottleneck, you don't invent the boundaries under fire. They're already there.

What "Macroservices" Actually Mean

Let's name the middle ground properly. Between "a single process with everything" and "400 independently deployed functions," there is a productive sweet spot.

Macroservices are SOA without the baggage it carried.

Service-Oriented Architecture had the right decomposition idea: domain-scoped services, clear ownership, independent deployment. What killed SOA in practice was the implementation stack—verbose XML-based protocols, machine-generated contract files nobody could read, and a centralized "Enterprise Service Bus" that routed every message between every service through one team-owned bottleneck. The hub that was supposed to decouple everything became the tightest coupling of all.

Macroservices keep the concept, swap the stack. Lightweight events over a distributed message bus instead of XML over a central hub. Domain-scoped, independently deployable—one service per bounded context, extracted when independence is genuinely earned.

In Framework M's world, a module is already a bounded context. It has its own DocTypes, its own controller hooks, its own tests. When the time comes to extract it:

  1. The code doesn't change. The module already depends on protocols.
  2. The adapter wiring changes. Instead of in-process function calls, the adapter publishes to NATS and the module subscribes.
  3. The DI container changes. You bind a remote client instead of a local repository.

This is decomposition by paradigm shift, not copy-paste surgery.

The Step Before Extraction

Most teams will never need to split into separate services. There's a step in the progression that Framework M provides which most frameworks don't: module-level independence inside a shared runtime.

Here's what that means in practice.

The container image is one monolith—one Python virtualenv, one process. But every app and module inside it is a fully independent Python package. The frontend shell is served by the main app; each module's UI is bundled inside its own Python wheel as static assets. There is no separate frontend build step, no shared node_modules, no JS bundler in your deployment pipeline.

To upgrade a module, you only need:

FROM your-base-python-image
RUN uv pip install --upgrade your-module

No JS builds. No CSS compilation. No asset pipeline. Just Python.

If CI grabs the previous image, installs the updated wheel, and redeploys, the turnaround is fast enough to feel like an independent microservice deployment—without the operational overhead of actually running one. Combined with Framework M's Zero-Downtime Migration support, module upgrades can be rolled out seamlessly, pod by pod, while the rest of the fleet keeps serving traffic.

The result: module teams work independently, ship independently, and their changes land in production independently—all while sharing the same runtime and getting full ACID transaction guarantees across module boundaries when they need them.

Extraction into a true macroservice is a choice you make when the problem demands it. Not a forced architectural decision on day one.

Why You Extract: More Than You Think

There is no fixed list of reasons to pull something out of the monolith. The signal is always "the monolith is the wrong fit for this problem." Here are the categories that come up most often—but the real answer is: whatever the problem demands.

Throughput Specialization

Some problems genuinely need hardware-level performance. A double-entry ledger at high transaction volume is one of them.

book-keeper uses TigerBeetle as its engine—a purpose-built financial accounting database written in Zig that can process millions of transfers per second on commodity hardware. No Python ORM can match that. No PostgreSQL trigger can match that.

Framework M doesn't try to replicate that. Instead, book-keeper is a specialized, headless service with a clean port: LedgerProtocol. Your module calls ledger.transfer(debit, credit, amount). Whether that resolves to a local in-memory fake (tests), a PostgreSQL ledger (small scale), or TigerBeetle (high-throughput production)—is an adapter concern, not a business logic concern.

stock-keeper follows the same pattern for high-velocity inventory movements. Warehouse operations with sub-millisecond stock reservation requirements don't belong in a general-purpose ORM row-lock.

These are not microservices extracted because someone read a blog post. They are specialized engines chosen because the domain problem demands it.

Team and Deployment Independence

Different teams, different release cadences, different uptime requirements. A Framework M module can become a standalone service by packaging it as its own pyproject.toml artifact and communicating via the NATS event bus that was already in the infrastructure.

The event bus speaks CloudEvents. It has Go clients, Rust clients, JavaScript clients. The moment you need a high-throughput worker written in Go, it subscribes to the same NATS subject your Python service already publishes to.

Gateways, Proxies, and Relays

Not everything is about the core domain. Sometimes you need a thin service that sits at a network boundary: exposing an internal API over a VPN, bridging two protocols, rate-limiting an outbound integration, or acting as a relay for a partner system. These services don't carry business logic—they carry connectivity logic. The monolith shouldn't own that concern.

Translators, Parsers, and Projectors

When you integrate with external systems—EDI feeds, legacy XML APIs, binary protocols, flat-file imports—you often need a dedicated parsing and projection layer. Baking a heavy XML-to-domain transformer into the monolith couples your release cycle to an external format you don't control. A small, independently deployable translator service absorbs that volatility.

Real-Time and Streaming

WebRTC signalling, live collaboration, event streaming to frontends, IoT telemetry ingestion—these have fundamentally different runtime characteristics than request-response APIs. Long-lived connections, fan-out, backpressure, and high message rates don't fit naturally inside a standard HTTP service. A dedicated real-time service, communicating with the monolith over the NATS event bus, is the right seam.

Anything Else

The pattern is always the same: if the monolith is the wrong runtime environment for a concern, define a Protocol at the boundary, implement an adapter, and let the right tool handle it. Framework M doesn't dictate what services you build around it—it just makes sure the boundaries are clean.

The Decision Framework: When to Extract

Not every module deserves its own service. Here is the mental model:

SignalWhat to do
Bottleneck is CPU/throughputExtract with a specialized engine (TigerBeetle, Redis, Rust binary)
Bottleneck is team velocityExtract as a macroservice with NATS boundary
Connectivity or protocol concernBuild a thin gateway/relay, not a domain service
Volatile external formatIsolate it in a translator/projector service
Long-lived connections or streamingDedicated real-time service, NATS as the backbone
Bottleneck is a specific read pathAdd a CQRS read model, not a new service
You just read a microservices articleStay in the monolith

The monolith should absorb everything it can. When it genuinely can't—and you will know when that moment arrives—Framework M's seams turn extraction into a configuration change, not a rewrite.

What Framework M Won't Do

This is important to be explicit about.

Framework M will not become a service mesh orchestrator. The modular monolith is the product. The specialized engines around it (book-keeper, stock-keeper) are companions, not fragments.

We are not building a general framework for arbitrary microservice composition. If you need that, Kubernetes, Istio, and Dapr exist and are well-supported.

What we are building is the best possible foundation for a business product that starts as a monolith, serves production traffic well, and decomposes gracefully—without architectural debt accumulating along the way.

The Bottom Line

Monoliths, macroservices, microservices, compiled binaries—every pattern solves a real problem. None of them is universally right or wrong. The question is always: what does this problem actually need?

Framework M's answer is to start where you can move fast—a modular monolith—and make sure the boundaries are clean enough that you can reach for any other tool when the problem genuinely calls for it.

  • Throughput problem? Plug in a specialized engine at the port.
  • Team or deployment independence? Extract a macroservice via the NATS boundary.
  • Need a gateway, a translator, a real-time layer? Define the Protocol, build the adapter, use the right runtime.

The business logic you wrote doesn't change. The adapters change. That is the Zero-Cliff promise: you pick the right tool for each problem without rewriting what already works.


Have thoughts on this? Join the conversation on the Framework M community forum.