Skip to content
Low Level Design Mastery Logo
LowLevelDesign Mastery

Monolithic Architecture

Sometimes the simplest approach is the best approach

Imagine building with LEGO blocks. A monolithic architecture is like building your entire castle in one big piece - all the towers, walls, and gates are connected together. Everything is in one place, and you can see and touch any part of it easily.

In software terms, a monolithic architecture means your entire application - all its features, components, and modules - are built, deployed, and run as a single unit.

Most successful startups and even many large companies run on monolithic architectures. Netflix started as a monolith. Shopify is still largely monolithic. Stack Overflow famously runs on a monolith serving billions of requests.

Why? Because monoliths, when done right, offer incredible simplicity, performance, and developer productivity.


A typical monolithic application has all these components in one codebase:

Diagram

This is what gives monoliths a bad name. Code is tangled, modules depend on everything, and changes in one place break things everywhere.

Characteristics:

  • No clear boundaries
  • God classes with 1000+ lines
  • Direct database access from UI code
  • Copy-paste programming
  • “Just make it work” philosophy

A well-structured monolith with clear module boundaries, clean interfaces, and strong separation of concerns.

Characteristics:

  • Clear module boundaries (e.g., Orders, Payments, Users)
  • Modules communicate through well-defined interfaces
  • Each module could theoretically become a microservice
  • Strong encapsulation within modules
Diagram

Organized into horizontal layers with clear responsibilities.

Diagram

👨‍💻 For Beginners

You have one codebase, one IDE project, one repository. You can see the entire application flow from HTTP request to database query in one place.

🚀 For Senior Engineers

Atomic changes across multiple modules. Refactoring is straightforward with IDE support for finding all usages and automated refactoring tools.

You can:

  • Set breakpoints anywhere in the flow
  • Run the entire application locally
  • Write integration tests that exercise the full stack
  • Use profilers to find performance bottlenecks end-to-end
Terminal window
# Build once
mvn clean package # Java
python setup.py build # Python
# Deploy once
docker build -t myapp .
docker run myapp
# That's it! Your entire application is running.

In-Process Communication:

  • Function calls instead of network calls (microseconds vs milliseconds)
  • No serialization/deserialization overhead
  • Shared memory between components
  • No network latency

Example Performance Comparison:

OperationMonolithMicroservices
Function call0.001 ms-
In-process message0.01 ms-
HTTP REST call-10-50 ms
Message queue-5-20 ms
# In a monolith, this is one database transaction
def transfer_money(from_account, to_account, amount):
with database.transaction(): # ACID guarantees!
from_account.deduct(amount)
to_account.add(amount)
# Either both succeed or both fail

In microservices, this same operation requires distributed transactions or eventual consistency patterns.


You must scale the entire application even if only one component needs more resources.

Diagram

Once you choose a technology stack, you’re committed:

  • Started with Python? The entire app must be Python
  • Chose PostgreSQL? Everything uses PostgreSQL
  • Want to try Rust for CPU-intensive tasks? Too bad

As the codebase grows:

  • Build time: 2 minutes → 10 minutes → 30 minutes
  • Test suite: 5 minutes → 30 minutes → 2 hours
  • Startup time: 5 seconds → 30 seconds → 2 minutes

This slows down development velocity.

With 50 engineers working on one codebase:

  • Merge conflicts become frequent
  • Code reviews take longer
  • Release coordination is complex
  • “Who owns this code?” becomes unclear

LLD Connection: Designing a Modular Monolith

Section titled “LLD Connection: Designing a Modular Monolith”

The same principles that make classes clean also make monoliths maintainable.

Single Responsibility Principle (Module Level)

Section titled “Single Responsibility Principle (Module Level)”

Each module should have one reason to change.

modules/orders/order_service.py
# Orders module - responsible ONLY for order management
class OrderService:
def __init__(self, order_repo, payment_client, user_client):
self._order_repo = order_repo
self._payment_client = payment_client # Interface!
self._user_client = user_client # Interface!
def create_order(self, user_id: str, items: List[Item]) -> Order:
# Validate user through interface
user = self._user_client.get_user(user_id)
# Create order (our responsibility)
order = Order(user_id=user_id, items=items)
self._order_repo.save(order)
# Initiate payment through interface
self._payment_client.process_payment(order.id, order.total)
return order
modules/payments/payment_client.py
# Payments module - responsible ONLY for payment processing
from abc import ABC, abstractmethod
class PaymentClient(ABC):
"""Interface that other modules use"""
@abstractmethod
def process_payment(self, order_id: str, amount: Decimal) -> PaymentResult:
pass
class PaymentService(PaymentClient):
def process_payment(self, order_id: str, amount: Decimal) -> PaymentResult:
# Payment-specific logic here
# Other modules don't need to know implementation details
pass

Dependency Inversion Principle (Module Level)

Section titled “Dependency Inversion Principle (Module Level)”

Modules should depend on abstractions, not concrete implementations.

Diagram
ecommerce-monolith/
├── src/
│ ├── modules/
│ │ ├── orders/
│ │ │ ├── __init__.py
│ │ │ ├── domain/
│ │ │ │ ├── order.py # Domain entities
│ │ │ │ └── order_item.py
│ │ │ ├── service/
│ │ │ │ └── order_service.py # Business logic
│ │ │ ├── repository/
│ │ │ │ └── order_repository.py
│ │ │ └── api/
│ │ │ └── order_controller.py
│ │ │
│ │ ├── payments/
│ │ │ ├── __init__.py
│ │ │ ├── domain/
│ │ │ │ └── payment.py
│ │ │ ├── service/
│ │ │ │ └── payment_service.py
│ │ │ └── api/
│ │ │ └── payment_controller.py
│ │ │
│ │ ├── users/
│ │ │ └── ...
│ │ │
│ │ └── shared/ # Shared kernel
│ │ ├── events/
│ │ ├── exceptions/
│ │ └── utils/
│ │
│ └── main.py # Application entry point

  1. Starting a New Project

    • You don’t know what will succeed yet
    • Premature microservices = premature optimization
    • “Start with a monolith, extract microservices later” - Martin Fowler
  2. Small to Medium-Sized Teams

    • Team size: 1-20 developers
    • Everyone can understand the entire codebase
    • Less operational complexity to manage
  3. Predictable Load Patterns

    • All features have similar traffic patterns
    • No need for independent scaling
  4. Strong Consistency Requirements

    • Financial transactions
    • Inventory management
    • Booking systems
  5. Rapid Development Phase

    • MVP development
    • Startup mode
    • Experimenting with features
  1. Large Teams (50+ engineers)

    • Coordination overhead too high
    • Multiple teams stepping on each other’s toes
  2. Different Scaling Needs

    • Search service needs 100 instances
    • Admin panel needs 2 instances
  3. Technology Diversity Required

    • ML models in Python
    • Real-time processing in Go
    • Legacy integration in Java

Stack Overflow: The Monolith that Serves Billions

Section titled “Stack Overflow: The Monolith that Serves Billions”

Stats:

  • 1.3 billion page views/month
  • 9 web servers
  • 4 SQL servers
  • One monolithic .NET application

Why it works:

  • Highly optimized code
  • Efficient caching strategy
  • Excellent database design
  • Team knows the codebase intimately

Stats:

  • Powers millions of stores
  • Processes billions in sales
  • Still largely monolithic (with some extracted services)

Their Approach:

  • Strong module boundaries enforced by tooling
  • “Componentization” within the monolith
  • Extract to services only when absolutely necessary

David Heinemeier Hansson (creator of Ruby on Rails) advocates for the “Majestic Monolith”:

  • Easier to understand
  • Easier to develop
  • Easier to deploy
  • Better performance

Their philosophy:

“The Majestic Monolith can become The Citadel. A monolith that deploys one thousand knights of logic in a single unit.”


Use tooling to prevent unwanted dependencies:

architecture_test.py
import pytest
from modulefinder import ModuleFinder
def test_orders_module_does_not_depend_on_payments_implementation():
"""Ensure Orders module only depends on Payment interfaces"""
finder = ModuleFinder()
finder.run_script('src/modules/orders/service/order_service.py')
# Orders should NOT import from payments.service
forbidden_imports = [
'modules.payments.service',
'modules.payments.repository'
]
for module in finder.modules.keys():
for forbidden in forbidden_imports:
assert not module.startswith(forbidden), \
f"Orders module should not depend on {forbidden}"

Instead of direct method calls between modules:

event_based_communication.py
from dataclasses import dataclass
from typing import Protocol
# Event definition (in shared kernel)
@dataclass
class OrderCreatedEvent:
order_id: str
user_id: str
total: Decimal
timestamp: datetime
# Event bus (in shared kernel)
class EventBus:
def __init__(self):
self._handlers = {}
def subscribe(self, event_type, handler):
if event_type not in self._handlers:
self._handlers[event_type] = []
self._handlers[event_type].append(handler)
def publish(self, event):
event_type = type(event)
for handler in self._handlers.get(event_type, []):
handler(event)
# Orders module publishes events
class OrderService:
def __init__(self, event_bus: EventBus):
self._event_bus = event_bus
def create_order(self, user_id, items):
order = Order(user_id=user_id, items=items)
self._repository.save(order)
# Publish event instead of calling other modules directly
self._event_bus.publish(
OrderCreatedEvent(
order_id=order.id,
user_id=user_id,
total=order.total,
timestamp=datetime.now()
)
)
# Payments module subscribes to events
class PaymentService:
def __init__(self, event_bus: EventBus):
event_bus.subscribe(OrderCreatedEvent, self.handle_order_created)
def handle_order_created(self, event: OrderCreatedEvent):
# Process payment when order is created
self.process_payment(event.order_id, event.total)

The “Shared Kernel” should only contain:

  • Common value objects (Money, Address, Email)
  • Base exceptions
  • Utility functions
  • Event definitions

Not:

  • Business logic
  • Domain entities specific to one module

Option A: Shared Database with Module-Specific Schemas

-- Each module has its own schema
CREATE SCHEMA orders;
CREATE SCHEMA payments;
CREATE SCHEMA users;
-- Modules access only their schema
CREATE TABLE orders.orders (...);
CREATE TABLE payments.transactions (...);

Option B: Shared Database with Access Rules

# Only OrderRepository can access orders table
class OrderRepository:
def save(self, order):
# Direct access OK
db.execute("INSERT INTO orders ...")
# PaymentService CANNOT access orders table directly
class PaymentService:
def __init__(self, order_client: OrderClient):
self._order_client = order_client # Use interface
def process_payment(self, order_id):
# Must go through OrderClient interface
order = self._order_client.get_order(order_id)

AspectMonolithMicroservices
ComplexityLowHigh
Development SpeedFast (initially)Slow (initially)
DeploymentSimple, riskyComplex, safer
ScalingVertical, entire appHorizontal, per service
ConsistencyStrong (ACID)Eventual
PerformanceExcellent (in-process)Good (network overhead)
Team Size1-20 optimal20+ optimal
TechnologyOne stackPolyglot
DebuggingEasyDifficult
TestingStraightforwardComplex
Operational CostLowHigh

Migration Path: When to Break Up the Monolith

Section titled “Migration Path: When to Break Up the Monolith”
  1. Deployment becomes risky

    • Fearful of releasing
    • Frequent rollbacks
    • Long testing cycles
  2. Scaling inefficiency

    • 90% of resources used by 10% of features
    • Can’t scale just what you need
  3. Team bottlenecks

    • Teams waiting on each other
    • Merge conflicts daily
    • Unclear ownership
  4. Technology constraints

    • Need different languages for different problems
    • Performance issues in specific areas

The Gradual Approach (Strangler Fig Pattern)

Section titled “The Gradual Approach (Strangler Fig Pattern)”

Don’t rewrite! Extract services gradually:

  1. Identify boundaries - Which module is most independent?
  2. Add interfaces - Define clean API contracts
  3. Duplicate functionality - New service implements same interface
  4. Route gradually - Route 5% → 20% → 100% to new service
  5. Remove old code - Once migration is complete

(This will be covered in detail in the Strangler Fig Pattern lesson!)


Start Simple

Begin with a monolith. You can always extract services later when you have real data about bottlenecks.

Modular is Key

A well-designed modular monolith with clear boundaries is better than poorly designed microservices.

Know Your Constraints

Choose architecture based on your team size, scale requirements, and organizational structure.

Performance Matters

In-process communication is 100-1000x faster than network calls. Don’t sacrifice performance without reason.



  • “Monolith to Microservices” by Sam Newman
  • “The Majestic Monolith” by David Heinemeier Hansson
  • “Modular Monoliths” by Simon Brown
  • Stack Overflow Architecture - Blog posts by Nick Craver