Monolithic Architecture: When & How to Build It Right

Monolithic architecture is one of the most misunderstood concepts in software development. Developers often treat "monolith" as a dirty word — synonymous with legacy code, tech debt, and scalability nightmares. But that's wrong.
A well-structured monolith is a perfectly valid architectural choice — often the best choice — especially in the early stages of a product. Companies like Stack Overflow, Shopify, Basecamp, and GitHub ran on monoliths for years and scaled to millions of users.
In this post, we'll cover:
✅ What a monolith actually is (and what it isn't)
✅ The different types of monolithic architectures
✅ When monoliths are the right choice
✅ How to structure a well-organized modular monolith
✅ Scaling strategies for monoliths
✅ Common anti-patterns to avoid
✅ The monolith-first strategy
✅ When to consider breaking it apart
What Is a Monolith?
A monolith is an application where all functionality is packaged and deployed as a single unit. The entire codebase — the user interface, business logic, and data access layer — lives together and is deployed together.
When a user makes a request, the monolith handles everything: authentication, business logic, data access, and the response — all within the same process.
What a Monolith Is NOT
A monolith is not necessarily:
- ❌ Unstructured — A monolith can be beautifully organized with clear modules and boundaries
- ❌ Unscalable — Monoliths can scale horizontally behind a load balancer
- ❌ Legacy — New systems start as monoliths every day
- ❌ Bad — The right tool for many situations
The problem isn't monoliths — it's badly structured monoliths.
Types of Monolithic Architectures
Not all monoliths are created equal. There's a spectrum from well-structured to chaotic:
1. The Modular Monolith
The gold standard of monolithic architecture. Code is organized into well-defined modules with clear boundaries. Modules communicate through explicit interfaces, not by reaching into each other's internals.
src/
├── modules/
│ ├── users/
│ │ ├── user.entity.ts
│ │ ├── user.service.ts
│ │ ├── user.controller.ts
│ │ └── user.repository.ts
│ ├── orders/
│ │ ├── order.entity.ts
│ │ ├── order.service.ts
│ │ ├── order.controller.ts
│ │ └── order.repository.ts
│ └── products/
│ ├── product.entity.ts
│ ├── product.service.ts
│ ├── product.controller.ts
│ └── product.repository.ts
└── shared/
├── database/
├── middleware/
└── config/Modules communicate through service interfaces, not direct database access across boundaries:
// ✅ Good: Orders module calls Users module through its service interface
class OrderService {
constructor(private readonly userService: UserService) {}
async createOrder(userId: string, items: OrderItem[]) {
const user = await this.userService.findById(userId); // Through interface
// ... create order logic
}
}
// ❌ Bad: Orders module directly queries Users table
class OrderService {
async createOrder(userId: string, items: OrderItem[]) {
const user = await this.db.query('SELECT * FROM users WHERE id = ?', [userId]); // Bypasses boundary
// ...
}
}2. The Layered Monolith
Code is organized by technical layer rather than by feature. This is the most common structure in frameworks like Spring MVC or ASP.NET:
src/
├── controllers/ # HTTP layer
├── services/ # Business logic layer
├── repositories/ # Data access layer
└── models/ # Data modelsThis works fine for smaller applications but becomes problematic at scale — unrelated features get tangled together within each layer.
3. The Big Ball of Mud
The anti-pattern: no structure, no boundaries, everything depends on everything. A common evolution from a once-small codebase that grew without architectural discipline.
This is what gives monoliths a bad name. And it's avoidable.
When Is a Monolith the Right Choice?
The answer depends on your context — team size, domain complexity, and scale requirements.
✅ Choose a Monolith When:
1. You're starting a new project
You don't know your domain well yet. Microservices require well-defined boundaries, and premature service splitting often leads to the wrong cuts. Start simple, learn your domain, then evolve.
"Don't start with microservices. Start with a monolith and break it apart when you have a clear reason to." — Martin Fowler
2. Your team is small (< 10 developers)
Microservices introduce enormous operational overhead: service discovery, distributed tracing, multiple deployments, inter-service communication failures, network latency. A small team will spend more time on infrastructure than features.
3. Your domain is not well understood
Services need stable, well-defined interfaces. If you're still figuring out what your product does, a monolith lets you refactor freely without coordinating cross-service changes.
4. You need fast iteration
A monolith is a single deployment. One git push updates everything. No need to version APIs, coordinate releases, or manage service compatibility.
5. Your scale requirements are modest
A well-optimized monolith on a single server can handle thousands of concurrent requests. Stack Overflow serves millions of requests per day with a small server fleet running a monolith.
📊 The Decision Matrix
| Factor | Lean Monolith | Lean Microservices |
|---|---|---|
| Team Size | < 10 developers | > 20 developers |
| Domain Clarity | Evolving / unclear | Well-defined boundaries |
| Deployment Frequency | Multiple times/day | Per-service teams |
| Scale Requirements | < 100k daily users | Millions of users (per feature) |
| Ops Maturity | Basic DevOps | Kubernetes, service mesh |
| Time-to-Market | Fast is critical | Acceptable complexity |
Structuring a Well-Organized Monolith
The key to a sustainable monolith is enforcing boundaries even without physical service separation.
Feature-Based Module Organization
Organize by business capability, not by technical layer:
src/
├── modules/
│ ├── catalog/ # Product catalog bounded context
│ │ ├── application/ # Use cases / services
│ │ ├── domain/ # Entities, value objects, domain logic
│ │ ├── infrastructure/ # Repository implementations, external APIs
│ │ └── api/ # Controllers, DTOs
│ ├── orders/ # Order management bounded context
│ │ ├── application/
│ │ ├── domain/
│ │ ├── infrastructure/
│ │ └── api/
│ └── payments/ # Payment processing bounded context
│ ├── application/
│ ├── domain/
│ ├── infrastructure/
│ └── api/
└── shared/
├── database.ts
├── logger.ts
└── config.tsDefine Clear Module Interfaces
Each module should expose a public API — a set of services that other modules can use:
// modules/users/index.ts — Public interface for the Users module
export { UserService } from './application/user.service';
export type { User, CreateUserDto } from './domain/user.entity';
// Everything else is internal — not exported// modules/orders/application/order.service.ts
import { UserService } from '@/modules/users'; // Only imports from public interface
class OrderService {
constructor(private readonly userService: UserService) {}
// ...
}Use Dependency Injection for Loose Coupling
In Spring Boot:
// Clean separation: OrderService depends on UserService interface
@Service
public class OrderService {
private final UserService userService;
private final OrderRepository orderRepository;
public OrderService(UserService userService, OrderRepository orderRepository) {
this.userService = userService;
this.orderRepository = orderRepository;
}
public Order createOrder(String userId, List<OrderItem> items) {
User user = userService.findById(userId)
.orElseThrow(() -> new UserNotFoundException(userId));
Order order = Order.create(user, items);
return orderRepository.save(order);
}
}Enforce Boundaries with Architecture Tests
Use tools like ArchUnit (Java) or eslint-plugin-import (TypeScript) to fail the build if modules violate boundaries:
// ArchUnit test: Orders module must not import from Users' internal packages
@Test
void ordersShouldNotDependOnUsersInternals() {
JavaClasses importedClasses = new ClassFileImporter()
.importPackages("com.example");
ArchRule rule = noClasses()
.that().resideInAPackage("..orders..")
.should().dependOnClassesThat()
.resideInAPackage("..users.infrastructure..");
rule.check(importedClasses);
}Scaling Strategies for Monoliths
A common misconception: monoliths can't scale. They absolutely can.
Horizontal Scaling (Scale Out)
Deploy multiple instances behind a load balancer. Each instance is stateless (session state in Redis, database, or JWT):
This works effectively for most applications and is much simpler to operate than microservices.
Vertical Scaling (Scale Up)
Add more CPU and RAM to the server. Modern cloud providers make this trivially easy. For many applications, this is the cheapest and simplest scaling strategy.
Read Replicas
Offload read-heavy operations to database read replicas:
class ProductRepository {
constructor(
private readonly writeDb: DatabaseClient,
private readonly readDb: DatabaseClient, // Read replica
) {}
async save(product: Product) {
return this.writeDb.query('INSERT INTO products...', [...]);
}
async findAll() {
return this.readDb.query('SELECT * FROM products'); // Read from replica
}
}Caching
Add Redis caching for expensive operations:
class ProductService {
async findPopularProducts(): Promise<Product[]> {
const cacheKey = 'products:popular';
// Check cache first
const cached = await this.redis.get(cacheKey);
if (cached) return JSON.parse(cached);
// Cache miss — query database
const products = await this.productRepository.findPopular();
// Store in cache for 10 minutes
await this.redis.setex(cacheKey, 600, JSON.stringify(products));
return products;
}
}Background Jobs
Offload slow operations to background workers:
// Instead of sending email synchronously during HTTP request
class OrderService {
async createOrder(dto: CreateOrderDto) {
const order = await this.orderRepository.save(dto);
// Queue the email instead of sending inline
await this.jobQueue.add('send-order-confirmation', {
orderId: order.id,
userEmail: dto.email,
});
return order; // Return immediately, email sends async
}
}Common Anti-Patterns to Avoid
1. The God Class / God Service
A single class that does everything. Signs: thousands of lines, dozens of methods, imported everywhere.
// ❌ Anti-pattern: UserService does way too much
@Service
public class UserService {
public User createUser(...) { ... }
public void sendWelcomeEmail(...) { ... }
public void processPayment(...) { ... }
public void generateReport(...) { ... }
public void handleReturns(...) { ... }
public void updateInventory(...) { ... }
// ... 50 more methods
}Fix: Split by bounded context. Each service has a single, clear responsibility.
2. Tight Coupling Through Shared Database Tables
Multiple modules writing directly to the same tables, bypassing service boundaries:
// ❌ Anti-pattern: OrderRepository queries Users table directly
@Repository
public class OrderRepository {
public List<Order> findOrdersWithUserEmail() {
return entityManager.createQuery(
"SELECT o FROM Order o JOIN User u ON o.userId = u.id" // Cross-boundary JOIN
).getResultList();
}
}Fix: Call UserService.findById() instead. Each module owns its tables.
3. Shared Mutable State (Global Singletons)
Shared state that multiple modules read and write without coordination leads to race conditions and unpredictable behavior.
// ❌ Anti-pattern: Global mutable state
let currentDiscount = 0; // Race condition if multiple requests modify this
export function applyDiscount(amount: number) {
currentDiscount = amount; // Shared across all requests
}Fix: Use database or Redis for shared state with proper locking/transactions.
4. Circular Dependencies
Module A depends on Module B which depends on Module A. Signals poor boundary design.
Fix: Introduce an interface or extract a shared concept into a third module.
5. Skipping Tests Because "It's Just a Monolith"
Monoliths need tests just as much as microservices — arguably more, because a bug in a shared utility can break everything at once.
The Monolith-First Strategy
Martin Fowler popularized the monolith-first strategy: build your system as a modular monolith first, then extract services only when you have clear reasons to.
The reasoning:
- You don't know your domain boundaries yet — Service boundaries require deep domain knowledge. Get it by building the monolith.
- Microservices are expensive — Distributed systems have distributed problems (network failures, latency, partial failures). Don't pay that cost before you need to.
- You can extract later — A well-structured modular monolith is designed for extraction. Clean module boundaries become service boundaries.
When to Extract a Service
Consider extracting a module into a separate service when you have a specific, concrete reason:
- Independent scaling — One feature requires 10x more compute than others
- Technology mismatch — One feature needs a GPU, a different runtime, or a different language
- Team autonomy — A team needs to deploy independently without coordinating with others
- Fault isolation — One feature failing shouldn't take down the whole system
- Different SLA — One feature needs 99.99% uptime while others can tolerate downtime
Not because:
- "Microservices are modern"
- "Netflix uses microservices"
- "Monoliths don't scale" (they do)
Real-World Monolith Success Stories
Stack Overflow
Stack Overflow serves millions of page views per day from a small number of servers running a .NET monolith. Their architecture is deliberately simple:
- 9 web servers
- 4 SQL servers
- Redis for caching
- One deployable monolith
They've chosen simplicity over distributed complexity — and it works.
Shopify
Shopify ran as a Rails monolith for years while scaling to billions in transactions. They only recently began modularizing — and even then, into a modular monolith with enforced boundaries, not microservices.
"We have a modular monolith — a single deployable unit with strict module boundaries." — Shopify Engineering
Basecamp / HEY
DHH and the Basecamp team are vocal advocates for the monolith. Their apps (Basecamp, HEY) run as Rails monoliths on modest hardware and serve hundreds of thousands of paying customers.
Monolith vs Microservices: Honest Trade-offs
| Aspect | Monolith | Microservices |
|---|---|---|
| Deployment | One artifact | Many services to coordinate |
| Local Development | One process to run | Docker Compose with 10+ services |
| Debugging | Stack traces are complete | Distributed tracing required |
| Testing | Integration tests are easy | Contract testing complexity |
| Refactoring | Easy (same codebase) | Requires API versioning |
| Scaling | Scale whole app | Scale individual services |
| Team Autonomy | Coordinate releases | Independent deployments |
| Failure Isolation | One bug can break everything | Services fail independently |
| Latency | In-process calls (nanoseconds) | Network calls (milliseconds) |
| Ops Overhead | Low | High (k8s, service mesh, tracing) |
| Domain Boundaries | Optional (but recommended) | Required (or chaos ensues) |
When to Break Apart the Monolith
The monolith served you well. But eventually, specific pain points may justify extracting a service. Here are the signals:
Signal 1: One Feature Is a Bottleneck
Your video transcoding is CPU-intensive and slowing down API responses. Extract it to a worker service that can scale independently.
Signal 2: Different Technology Requirements
Your ML inference service needs Python and GPUs. Your main app is Java. Extract the ML service.
Signal 3: Team Friction
Multiple teams deploying to the same codebase leads to merge conflicts, deployment coordination, and bottlenecks. Service boundaries enable team autonomy.
Signal 4: Clear Domain Boundary
After running the monolith for a year, you understand exactly where "payments" ends and "orders" begins. The boundary is stable. Now you can extract.
How to Extract: The Strangler Fig Pattern
Don't rewrite. Strangle the monolith incrementally:
- Build the new service alongside the monolith
- Route traffic to the new service (via API gateway or feature flag)
- Verify it works
- Remove the code from the monolith
This approach is low-risk and reversible.
Practical Example: E-Commerce Monolith in Spring Boot
Here's how a well-structured Spring Boot monolith looks for an e-commerce application:
src/main/java/com/example/store/
├── modules/
│ ├── catalog/
│ │ ├── CatalogModule.java # Module boundary marker
│ │ ├── application/
│ │ │ └── ProductService.java
│ │ ├── domain/
│ │ │ ├── Product.java
│ │ │ └── ProductRepository.java # Interface
│ │ └── infrastructure/
│ │ └── JpaProductRepository.java # Implementation
│ ├── orders/
│ │ ├── OrdersModule.java
│ │ ├── application/
│ │ │ └── OrderService.java
│ │ ├── domain/
│ │ │ ├── Order.java
│ │ │ └── OrderRepository.java
│ │ └── infrastructure/
│ │ └── JpaOrderRepository.java
│ └── payments/
│ ├── PaymentsModule.java
│ ├── application/
│ │ └── PaymentService.java
│ ├── domain/
│ │ └── Payment.java
│ └── infrastructure/
│ └── StripePaymentGateway.java # External adapter
├── shared/
│ ├── DatabaseConfig.java
│ ├── SecurityConfig.java
│ └── EventBus.java # In-process event bus
└── StoreApplication.javaCross-module communication via an in-process event bus (not HTTP):
// When an order is created, publish an event
@Service
public class OrderService {
private final EventBus eventBus;
public Order createOrder(CreateOrderCommand command) {
Order order = Order.create(command);
orderRepository.save(order);
// Publish event — Payments module listens without tight coupling
eventBus.publish(new OrderCreatedEvent(order.getId(), order.getTotalAmount()));
return order;
}
}
// Payments module handles the event independently
@Component
public class OrderCreatedEventHandler {
@EventListener
public void handle(OrderCreatedEvent event) {
paymentService.initiatePayment(event.getOrderId(), event.getAmount());
}
}This approach gives you loose coupling within the monolith — the same loose coupling you'd get with microservices, but without network overhead.
Summary
Monolithic architecture is not a problem to be solved — it's a tool that fits many situations perfectly:
When monolith wins:
- Small teams building new products
- Unclear or evolving domain boundaries
- Need for fast iteration and simple deployment
- Scale requirements that don't justify distributed complexity
How to do it right:
- Organize by business capability (modular monolith)
- Enforce module boundaries through interfaces and architecture tests
- Use dependency injection for loose coupling
- Add caching, read replicas, and background jobs as you scale
Common mistakes to avoid:
- God classes and services
- Cross-module database coupling
- Shared mutable state
- No tests
When to evolve:
- Specific scaling bottleneck for one feature
- Clear, stable domain boundaries you want to deploy independently
- Team size that creates deployment coordination friction
The goal is never to "have microservices" — the goal is to deliver software effectively. Often, a well-structured monolith does that better than a prematurely split distributed system.
What's Next in the Software Architecture Series
This is post 2 of 12 in the Software Architecture Patterns series:
- ✅ ARCH-1: Software Architecture Patterns Roadmap
- ✅ ARCH-2: Monolithic Architecture (this post)
- ✅ ARCH-3: Layered (N-Tier) Architecture
- ✅ ARCH-4: MVC, MVP & MVVM Patterns Compared
- 🔜 ARCH-5: Microservices Architecture
- 🔜 ARCH-6: Event-Driven Architecture
- 🔜 ARCH-7: CQRS & Event Sourcing
- 🔜 ARCH-8: Hexagonal Architecture (Ports & Adapters)
- 🔜 ARCH-9: Clean Architecture
- 🔜 ARCH-10: Domain-Driven Design (DDD)
- 🔜 ARCH-11: Serverless & Function-as-a-Service
- 🔜 ARCH-12: Choosing the Right Architecture
Related posts:
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.