← Back to Studio Notes

Microservices vs Monolith for Early-Stage SaaS Products

Discover why early-stage SaaS startups should avoid premature microservices and build a clean modular monolith in Django for speed, simplicity, and scale.

KEHEM / Studio Notes
  KEHEM

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 itemModular monolithMicroservices (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:

  1. Single Database with Logical Boundaries: All apps share a PostgreSQL database, but billing tables are only modified by billing services.
  2. Explicit Service APIs: Apps communicate with each other through well-defined service functions or domain events rather than deep cross-app database joins.
  3. 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:

  1. Pick a module with a clean boundary and low transactional coupling — notifications, PDF generation, search indexing, video processing. Not billing.
  2. 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.
  3. Give it its own data. Move the tables it owns to a separate schema, and then a separate database, before separating the process.
  4. 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.
  5. 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

SignalStill fine as a monolithConsider extracting
Team sizeUnder ~30 engineers50+ across autonomous squads
Deploy frequencyDaily or moreMerge conflicts are the bottleneck
One module's loadSimilar to the rest10× the compute of everything else
Failure isolationAcceptableOne module must not take the app down
Component typeCRUD and reportingVideo, ML inference, heavy search
ComplianceStandardIsolated PCI/HIPAA enclave required
Operational capacityNo dedicated infra personDedicated 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.

Talk to KEHEMExplore Services