In modern software engineering, few architectural debates generate as much controversy as Microservices vs. Monolithic Architecture.
In recent years, many venture-backed startups fell into the trap of adopting Netflix- or Uber-scale microservices on Day 1—splitting a 2-person project into 12 Kubernetes clusters, distributed databases, gRPC messaging, and service meshes.
The result? Massive cloud hosting bills, nightmarish debugging, broken transactional consistency, and sluggish feature releases.
For 95% of early-stage and mid-market SaaS companies in North America, Europe, and Australia, the Modular Monolith is the superior architectural choice.
This is not a religious position. We have built and rescued both. The pattern we see repeatedly is that the architecture was chosen to look sophisticated rather than to ship software, and the cost of that decision shows up eighteen months later as a delivery slowdown that nobody can quite explain.
The Hidden Costs of Premature Microservices
┌─────────────────────────────────────────────────────────────┐
│ MICROSERVICES OVERHEAD: │
│ • Distributed Transactions (No simple DB transactions) │
│ • Network Latency & Cascading Service Failures │
│ • Complex CI/CD Pipelines & Multiple Deployment Repos │
│ • High DevOps & Observability Tooling Costs (Datadog/AWS) │
└─────────────────────────────────────────────────────────────┘
When you are searching for product-market fit, your domain models change weekly.
Refactoring a domain model inside a monolithic codebase takes 20 minutes (change a Django model and run makemigrations).
Refactoring that same domain model across 4 microservices requires coordinating 4 separate database migrations, API versioning schemas, and multi-repo pull requests.
The costs that hurt most are the ones founders do not budget for:
Transactional consistency disappears. In a monolith, "create an order, reserve the stock, charge the card" is one database transaction. It either all happens or none of it does. Split across three services and you need sagas, compensating transactions, or an events table — and you now own the failure modes of a distributed system. Every "order charged but no stock reserved" bug in the first year of a microservices launch traces back to this.
Incident diagnosis gets slower, not faster. A timeout in a monolith shows one stack trace. A timeout in a service mesh shows a request that crossed four services and died somewhere in the middle, and answering "where" requires distributed tracing you may not have wired up yet.
DevOps staffing becomes a requirement, not a preference. A monolith on a single container can be deployed by any developer on the team. A Kubernetes estate with a service mesh, ingress, secrets management and per-service CI needs at least one person whose job is infrastructure.
What This Actually Costs Per Month
Indicative infrastructure and tooling budgets at the 5–20 engineer stage:
| Line item | Modular monolith | Microservices (8–12 services) |
|---|---|---|
| Compute | $50–$400/mo | $600–$2,500/mo |
| Managed Kubernetes control plane | — | ~$73/mo plus nodes |
| Observability (APM, logs, traces) | $0–$150/mo | $400–$1,200/mo |
| CI/CD minutes & runners | $20–$80/mo | $150–$500/mo |
| Secrets/queue/service mesh tooling | — | $100–$400/mo |
| Typical monthly total | $70–$630 | $1,300–$4,700 |
Over two years that difference is roughly $30,000–$100,000 — capital that, for an early-stage product, is roughly one or two engineers' worth of runway spent on infrastructure for a product that has not yet found its customer.
What Is a Modular Monolith?
A Modular Monolith is a single deployable application where internal boundaries are strictly partitioned into self-contained, decoupled domain modules.
In Django, this is naturally accomplished through the Apps pattern:
my_saas_project/
├── apps/
│ ├── authentication/ # Auth, SSO, User sessions
│ ├── billing/ # Stripe webhooks, subscriptions
│ ├── projects/ # Core business logic & workflows
│ ├── notifications/ # Email, SMS, Webhooks
│ └── analytics/ # Reporting & Metrics
├── core/ # Shared base models & utilities
└── manage.py
Key Rules of Modular Monoliths:
- Single Database with Logical Boundaries: All apps share a PostgreSQL database, but
billingtables are only modified bybillingservices. - Explicit Service APIs: Apps communicate with each other through well-defined service functions or domain events rather than deep cross-app database joins.
- Single Deployment Pipeline: One Docker container, one deployment script, zero distributed tracing complexity.
What the Rules Look Like in Real Code
The third rule is the one teams break first. A billing module that reads projects.Project.owner.email directly in a query has just created an implicit dependency that will be painful to untangle later. The fix is boring and effective:
# apps/billing/services.py
from apps.accounts.services import get_billing_contact
def create_invoice_for_project(project_id):
contact = get_billing_contact(project_id) # another module's public function
return Invoice.objects.create(
project_id=project_id,
recipient_email=contact["email"],
amount=contact["contract_value"],
)
The rule is not "never share data" — it is "share it through a module's public interface, not its tables". Move a genuine cross-module read into a one-line service function and the boundary survives future refactors.
You can enforce it mechanically rather than by convention. import-linter (or a simple custom test that walks the import graph) will fail the build when analytics imports billing.models directly. Boundary rules that live only in a wiki page decay within a quarter; boundary rules that live in CI do not.
One more practical addition: keep a core app for shared base models (timestamps, tenant ID, soft-delete) so that every module inherits from the same foundations instead of re-implementing them.
The Distributed Monolith: The Worst of Both Worlds
This is the failure mode that costs teams the most money, and it deserves its own section because it is where most "we did microservices" projects actually land.
A distributed monolith is a system split into services that cannot be deployed independently. Service A and Service B share the same database, or A synchronously calls B on every request, or a change to A's response shape always requires a matching deploy of B. You have paid the full operational cost of microservices and kept the coupling of a monolith.
Symptoms to watch for:
- Deploy coordination: releases require a specific order across services, or a "freeze" while several are updated together.
- Shared database tables: two services writing to the same table is the clearest sign the split was drawn in the wrong place.
- Synchronous chains: a single user action fans out into five sequential HTTP calls, so latency is additive and any one failure breaks the whole flow.
- Shared libraries with business logic: every service depends on a shared "domain" package, so a change to one affects all.
If you recognise three of those, the answer is usually to merge services back — which is far less embarrassing than it sounds, and considerably cheaper than living with it. We have merged four services back into a single Django app for one client and cut their monthly infrastructure bill by two thirds while making releases faster.
When Should You Actually Split into Microservices?
Microservices are an organizational scaling tool, not a performance hack. You should only extract a service when:
- Independent Scaling Demands: A specific sub-service has radically different resource requirements (e.g., a video transcoding queue or GPU-intensive ML inference worker).
- Team Organization: You have 50+ engineers across multiple autonomous squads who are constantly blocking each other on git merges.
- Distinct Security Perimeters: A PCI-compliant payment enclave that requires strict network isolation.
A useful test: can the service be deleted or rewritten without coordinating with another team? If yes, the boundary is real. If every change requires a conversation, you have drawn a line through the middle of a domain rather than between two of them.
Two related notes worth keeping in mind:
- Language choices are not a reason to split. "The ML model is in Python and the app is in Node" is real, but a single queue consumer in the other language gets you 95% of the benefit for 5% of the complexity.
- Scale numbers usually are not either. A single well-indexed PostgreSQL database on modest hardware handles far more traffic than most B2B products will ever see. If your product serves 20,000 businesses, you almost certainly do not need a service mesh to do it.
How to Extract a Service Without a Rewrite
When a genuine reason to split appears, do it incrementally rather than re-platforming. The pattern that works:
- Pick a module with a clean boundary and low transactional coupling — notifications, PDF generation, search indexing, video processing. Not billing.
- Extract it inside the codebase first. Move the logic into its own Django app with a narrow public interface and no direct table access from other modules. Run it that way for a month.
- Give it its own data. Move the tables it owns to a separate schema, and then a separate database, before separating the process.
- Route traffic through the interface. If all calls go through a service function today, converting that function into an HTTP or queue call later is a small diff. If calls are scattered across the codebase, they are not.
- Only then deploy it separately — and keep the monolith as the fallback path until the new service has proven itself in production for a full release cycle.
That sequence means the majority of the work is a refactor you can pause at any point, not a migration you must complete before the product can ship again.
Decision Checklist
| Signal | Still fine as a monolith | Consider extracting |
|---|---|---|
| Team size | Under ~30 engineers | 50+ across autonomous squads |
| Deploy frequency | Daily or more | Merge conflicts are the bottleneck |
| One module's load | Similar to the rest | 10× the compute of everything else |
| Failure isolation | Acceptable | One module must not take the app down |
| Component type | CRUD and reporting | Video, ML inference, heavy search |
| Compliance | Standard | Isolated PCI/HIPAA enclave required |
| Operational capacity | No dedicated infra person | Dedicated platform/SRE team exists |
Add one honest input to that table: does the team want to run a distributed system? It is a legitimate answer and it should be deliberate, not accidental.
Conclusion
Basecamp, Shopify, GitHub, and Stack Overflow all scaled to hundreds of millions in revenue on monolithic foundations.
Focus your engineering capital where it creates customer value: shipping fast, beautiful, reliable features.
Choose a modular monolith because it buys speed now and keeps the option open later. Splitting a well-modularised monolith into services is a mechanical exercise. Un-tangling a distributed monolith assembled by accident is a rewrite.
Related reading: Multi-tenant architecture is the decision that pairs with this one; the tech stack comparison covers the framework half; and technical debt explains where architecture choices show up eighteen months later. At KEHEM IT, we architect clean, maintainable modular software systems designed for longevity and rapid market validation.
Thinking about microservices before product-market fit? Talk to us first — it is usually cheaper to say no.
Have a project in mind?
KEHEM designs and builds thoughtful websites, SaaS products, and business systems.