Back to Blog
April 22, 20241,000 views

Monolithic Architecture in System Design: Tradeoffs, Patterns & When to Use It

A monolith packages every feature of an application into a single deployable unit. Before rushing to microservices, every engineer should understand what a monolith is, when it wins, and how to structure one so it can evolve β€” or be safely broken apart later.

A monolith is a software architecture in which all functionality β€” user interface, business logic, data access, background jobs, and every domain (users, orders, billing, notifications) β€” lives in a single codebase and is built and deployed as one unit. When that process runs, the entire application runs; when it is deployed, every feature is updated at once.

Monoliths are not legacy accidents. They are the natural starting point for most products because they are the simplest possible deployment topology: one process, one database, one artifact to build and ship. Every successful large system β€” from Amazon to Shopify to Stack Overflow β€” started as a monolith. The question is not whether to start with one, but whether you are building it in a way that lets you evolve it later.

What a Monolith Actually Looks Like

Picture a web application with three domains: Users, Orders, and Billing. In a monolith all three live in the same repository, import each other's code freely, share the same database connection pool, and deploy together as one Docker image or process. A request for POST /checkout might call the Orders module which calls the Billing module directly as a function β€” no HTTP round trip, no serialization, no network timeout to worry about.

One Process Handling All Modules
A single request flows through the UI layer, then the Orders module, then Billing β€” all inside one process. Watch how each module lights up in sequence with no network hops.

Advantages of a Monolith

Simplicity of development: every developer can run the entire system locally with a single docker compose up. There is no service mesh, no distributed tracing setup, no contract testing between services. New engineers can trace a request end-to-end through one codebase by following function calls, not HTTP logs across five repositories.

Atomic deployments: when you ship a new feature that touches the Orders and Billing domains simultaneously, both changes land at exactly the same moment. In a microservices world that same change requires coordinating two independent deploys and a backward-compatible API version β€” a significant operational burden.

Zero network overhead between modules: cross-domain calls are plain function calls or method invocations. They are orders of magnitude faster than an HTTP request, always consistent (no partial failures from network partitions between services), and need no serialization overhead. For read-heavy workloads with complex joins, a monolith can outperform a service-based architecture significantly.

Easier cross-cutting concerns: authentication, logging, database transactions, and error handling can be applied uniformly with middleware or decorators in a single place. Wrapping a multi-domain operation in a database transaction is trivial when every module shares the same connection pool.

Drawbacks of a Monolith

You must scale the whole application. If only your image-processing pipeline is CPU-bound, you still have to spin up an entire copy of the application β€” including your API layer, billing logic, and everything else β€” just to add capacity to that one hotspot. Memory, CPU, and startup time all scale with the full application size.

Tight coupling compounds over time. Without deliberate enforcement of boundaries, modules start calling each other freely. What begins as a clean separation of concerns drifts into a big ball of mud where a bug in the notification module can break checkout. Database tables become shared implicitly, making it hard to reason about what owns what.

Large blast radius on failure. A memory leak in one domain can exhaust the process heap and bring down all domains simultaneously. An unhandled exception that crashes the process is an outage for every feature, not just the one that was buggy.

Technology lock-in: every module must use the same language, runtime, and major library versions. You cannot rewrite your image service in Go while keeping the rest in Python without first splitting it out.

Scaling a Monolith vs Scaling Microservices

The most frequently cited scaling limitation is that a monolith forces you to replicate the entire application to handle a hotspot in just one domain. The animation below shows the concrete difference: if your Orders service is under load, microservices let you add one pod; a monolith requires you to scale out the whole thing β€” wasting memory and startup time on code that does not need more capacity.

Scaling a Monolith vs Microservices
Watch: when Orders traffic spikes, microservices scale just that service (right). The monolith must replicate the entire app (left), even modules that are idle.

The Modular Monolith: The Best of Both Worlds

The antidote to the big ball of mud is the modular monolith: you keep a single deployable unit but enforce strict internal module boundaries enforced by code, not just convention. Each module owns its own database tables (or schema), exposes only a public API surface (interfaces, not concrete classes), and other modules are not allowed to directly access its internals or tables.

The result is a codebase where the domains are as logically isolated as they would be in microservices β€” you could extract any module into a separate service later without a rewrite β€” while retaining all the operational simplicity of a single process: one deploy, no distributed transactions, no service discovery, no network reliability budget for cross-domain calls.

Modular Monolith: Clear Boundaries Inside One Deployable
Each module exposes only a public interface. Cross-module calls go through that interface only β€” never directly to another module's internals or database tables.

Concretely, a modular monolith might use Python packages with __all__ exports, Java packages with package-private visibility, or explicit dependency injection containers that prevent modules from importing each other's internals. The database discipline is stricter: each module gets its own schema or table prefix, and no module may join across schema boundaries β€” it must call the other module's API instead.

Monolith vs Microservices: When Does Each Win?

Microservices are not an upgrade from monoliths β€” they are a different tradeoff optimized for organizations that have outgrown a single deployment unit. The question is not which is better but which fits your current team size, traffic profile, and domain clarity.

DimensionMonolithMicroservices
Deploy complexitySingle artifact, one deploy commandMany services, orchestration required (K8s, ECS)
Local developmentOne process, simple setupMultiple services, complex local orchestration
Cross-domain callsDirect function call (ns latency)Network HTTP/gRPC (ms latency, can fail)
Independent scalingMust scale entire appScale individual services independently
Technology diversityLocked to one stackEach service can use best-fit language/framework
Fault isolationOne bug can crash all domainsFailures contained to one service
Team autonomyShared codebase, merge conflictsSeparate repos, independent release cadence
Atomic deploysTrivial β€” always atomicRequires coordination across service deploys
Distributed tracingNot neededEssential; adds significant infra cost
Best stageEarly-stage, small team (<~20 devs)Large org, many teams, proven domain boundaries

The dominant real-world recommendation today is: start with a well-structured modular monolith. Get your domain boundaries right while development is cheap. When a specific module demonstrably needs independent scaling, independent deployment, or a different technology β€” extract it then. Do not pay the operational tax of microservices before you have proven you need it.

# Example: modular monolith directory layout
# Each module is a self-contained package
app/
  users/
    api.py          # public interface β€” the only import other modules use
    models.py       # private
    repository.py   # private; touches only users_* tables
    service.py      # private
  orders/
    api.py          # public interface
    models.py
    repository.py   # touches only orders_* tables
    service.py      # calls users.api and billing.api β€” never their internals
  billing/
    api.py
    models.py
    repository.py   # touches only billing_* tables
    service.py
  main.py           # single entry point

Frequently Asked Questions

Is a monolith always a bad choice for high-scale systems?

No. Stack Overflow serves millions of developers per month from a small fleet of monolithic .NET servers. Shopify ran a single Rails monolith for years at enormous scale by investing in horizontal scaling (adding more instances behind a load balancer) and database read replicas rather than decomposing services. Scale is first a hardware and database problem; microservices become relevant when a single team can no longer ship independently because the deployment unit is too large or a domain genuinely needs a different scaling profile.

What is the difference between a monolith and a modular monolith?

Both are single deployable units, but a plain monolith has no enforced internal boundaries β€” modules can call each other's internals freely, leading to a tightly coupled codebase that is hard to change. A modular monolith adds explicit API boundaries between modules, private ownership of database tables, and enforced dependency rules (often via linter or build system), so each domain is as logically isolated as a microservice but without the distributed system complexity.

When should I break a monolith into microservices?

Extract a service when at least one of these is demonstrably true: (1) a specific domain has a scaling profile so different from the rest that replicating the whole app is genuinely wasteful; (2) multiple teams are blocked on each other in the same deploy pipeline and independent release cadence has clear business value; (3) a domain needs a technology unavailable in the host runtime (e.g., a GPU-based ML inference engine). Otherwise, invest in module boundaries inside the monolith rather than paying distributed system costs prematurely.

Start with a well-structured modular monolith. Get your domain boundaries right when change is cheap. The time to extract a service is when you have a proven reason β€” not a hypothetical one.

β€” alokknight Engineering