👨💻 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.
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:
This is what gives monoliths a bad name. Code is tangled, modules depend on everything, and changes in one place break things everywhere.
Characteristics:
A well-structured monolith with clear module boundaries, clean interfaces, and strong separation of concerns.
Characteristics:
Organized into horizontal layers with clear responsibilities.
👨💻 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:
# Build oncemvn clean package # Javapython setup.py build # Python
# Deploy oncedocker build -t myapp .docker run myapp
# That's it! Your entire application is running.In-Process Communication:
Example Performance Comparison:
| Operation | Monolith | Microservices |
|---|---|---|
| Function call | 0.001 ms | - |
| In-process message | 0.01 ms | - |
| HTTP REST call | - | 10-50 ms |
| Message queue | - | 5-20 ms |
# In a monolith, this is one database transactiondef 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 failIn 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.
Once you choose a technology stack, you’re committed:
As the codebase grows:
This slows down development velocity.
With 50 engineers working on one codebase:
The same principles that make classes clean also make monoliths maintainable.
Each module should have one reason to change.
# Orders module - responsible ONLY for order managementclass 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# Payments module - responsible ONLY for payment processingfrom 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// Orders module - responsible ONLY for order managementpublic class OrderService { private final OrderRepository orderRepo; private final PaymentClient paymentClient; // Interface! private final UserClient userClient; // Interface!
public OrderService(OrderRepository orderRepo, PaymentClient paymentClient, UserClient userClient) { this.orderRepo = orderRepo; this.paymentClient = paymentClient; this.userClient = userClient; }
public Order createOrder(String userId, List<Item> items) { // Validate user through interface User user = userClient.getUser(userId);
// Create order (our responsibility) Order order = new Order(userId, items); orderRepo.save(order);
// Initiate payment through interface paymentClient.processPayment(order.getId(), order.getTotal());
return order; }}// Payments module - responsible ONLY for payment processingpublic interface PaymentClient { PaymentResult processPayment(String orderId, BigDecimal amount);}
public class PaymentService implements PaymentClient { @Override public PaymentResult processPayment(String orderId, BigDecimal amount) { // Payment-specific logic here // Other modules don't need to know implementation details return new PaymentResult(); }}Modules should depend on abstractions, not concrete implementations.
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 pointStarting a New Project
Small to Medium-Sized Teams
Predictable Load Patterns
Strong Consistency Requirements
Rapid Development Phase
Large Teams (50+ engineers)
Different Scaling Needs
Technology Diversity Required
Stats:
Why it works:
Stats:
Their Approach:
David Heinemeier Hansson (creator of Ruby on Rails) advocates for the “Majestic Monolith”:
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:
import pytestfrom 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}"import com.tngtech.archunit.core.domain.JavaClasses;import com.tngtech.archunit.core.importer.ClassFileImporter;import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.library.Architectures.layeredArchitecture;
public class ArchitectureTest {
@Test public void modules_should_not_depend_on_each_others_implementation() { JavaClasses classes = new ClassFileImporter() .importPackages("com.ecommerce");
ArchRule rule = layeredArchitecture() .layer("Orders").definedBy("..orders..") .layer("Payments").definedBy("..payments..") .layer("Users").definedBy("..users..")
// Modules can only depend on other modules' API packages .whereLayer("Orders").mayNotAccessAnyLayer() .ignoreDependency("..orders.api..", "..payments.api..") .ignoreDependency("..orders.api..", "..users.api..");
rule.check(classes); }}Instead of direct method calls between modules:
from dataclasses import dataclassfrom typing import Protocol
# Event definition (in shared kernel)@dataclassclass 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 eventsclass 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 eventsclass 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)// Event definition (in shared kernel)public record OrderCreatedEvent( String orderId, String userId, BigDecimal total, Instant timestamp) {}
// Event bus (in shared kernel)public class EventBus { private final Map<Class<?>, List<Consumer<?>>> handlers = new ConcurrentHashMap<>();
public <T> void subscribe(Class<T> eventType, Consumer<T> handler) { handlers.computeIfAbsent(eventType, k -> new ArrayList<>()).add(handler); }
public <T> void publish(T event) { List<Consumer<?>> eventHandlers = handlers.get(event.getClass()); if (eventHandlers != null) { eventHandlers.forEach(handler -> ((Consumer<T>) handler).accept(event)); } }}
// Orders module publishes eventspublic class OrderService { private final EventBus eventBus;
public void createOrder(String userId, List<Item> items) { Order order = new Order(userId, items); repository.save(order);
// Publish event instead of calling other modules directly eventBus.publish(new OrderCreatedEvent( order.getId(), userId, order.getTotal(), Instant.now() )); }}
// Payments module subscribes to eventspublic class PaymentService { public PaymentService(EventBus eventBus) { eventBus.subscribe(OrderCreatedEvent.class, this::handleOrderCreated); }
private void handleOrderCreated(OrderCreatedEvent event) { // Process payment when order is created processPayment(event.orderId(), event.total()); }}The “Shared Kernel” should only contain:
Not:
Option A: Shared Database with Module-Specific Schemas
-- Each module has its own schemaCREATE SCHEMA orders;CREATE SCHEMA payments;CREATE SCHEMA users;
-- Modules access only their schemaCREATE TABLE orders.orders (...);CREATE TABLE payments.transactions (...);Option B: Shared Database with Access Rules
# Only OrderRepository can access orders tableclass OrderRepository: def save(self, order): # Direct access OK db.execute("INSERT INTO orders ...")
# PaymentService CANNOT access orders table directlyclass 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)| Aspect | Monolith | Microservices |
|---|---|---|
| Complexity | Low | High |
| Development Speed | Fast (initially) | Slow (initially) |
| Deployment | Simple, risky | Complex, safer |
| Scaling | Vertical, entire app | Horizontal, per service |
| Consistency | Strong (ACID) | Eventual |
| Performance | Excellent (in-process) | Good (network overhead) |
| Team Size | 1-20 optimal | 20+ optimal |
| Technology | One stack | Polyglot |
| Debugging | Easy | Difficult |
| Testing | Straightforward | Complex |
| Operational Cost | Low | High |
Deployment becomes risky
Scaling inefficiency
Team bottlenecks
Technology constraints
Don’t rewrite! Extract services gradually:
(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.