Layered Architecture: The Classic Pattern
Layered Architecture (also called N-Tier) is the most common architectural pattern. Understanding it deeply - including when to deviate - is essential for Staff+ engineers.
TL;DR
| Aspect | Description |
|---|---|
| Core Idea | Separate concerns into horizontal layers |
| Dependencies | Each layer depends only on the layer below |
| Strengths | Simplicity, separation of concerns, familiarity |
| Weaknesses | Can lead to "lasagna code", doesn't scale well |
The Classic Layers
Layer Responsibilities
| Layer | Responsibility | Examples |
|---|---|---|
| Presentation | User interface, API endpoints | Controllers, Views, DTOs |
| Business | Business logic, rules, workflows | Services, Domain objects |
| Persistence | Data access, storage | Repositories, ORM mappings |
| Database | Data storage | SQL Server, PostgreSQL |
Implementation Example
Folder Structure
src/
├── Presentation/
│ ├── Controllers/
│ │ └── OrdersController.cs
│ ├── ViewModels/
│ │ └── OrderViewModel.cs
│ └── Mappers/
│ └── OrderMapper.cs
├── Business/
│ ├── Services/
│ │ └── OrderService.cs
│ ├── Models/
│ │ └── Order.cs
│ └── Interfaces/
│ └── IOrderService.cs
├── Persistence/
│ ├── Repositories/
│ │ └── OrderRepository.cs
│ ├── Entities/
│ │ └── OrderEntity.cs
│ └── DbContext.cs
└── Program.cs
Code Example
// Presentation Layer
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
private readonly IOrderService _orderService;
public OrdersController(IOrderService orderService)
{
_orderService = orderService;
}
[HttpPost]
public async Task<ActionResult<OrderViewModel>> CreateOrder(CreateOrderRequest request)
{
var order = await _orderService.CreateOrder(
request.CustomerId,
request.Items);
return Ok(OrderMapper.ToViewModel(order));
}
}
// Business Layer
public class OrderService : IOrderService
{
private readonly IOrderRepository _orderRepository;
private readonly ICustomerRepository _customerRepository;
public async Task<Order> CreateOrder(Guid customerId, List<OrderItem> items)
{
var customer = await _customerRepository.GetById(customerId);
if (customer == null)
throw new CustomerNotFoundException(customerId);
var order = new Order
{
Id = Guid.NewGuid(),
CustomerId = customerId,
Items = items,
Total = items.Sum(i => i.Price * i.Quantity),
Status = OrderStatus.Pending,
CreatedAt = DateTime.UtcNow
};
// Business rules
if (order.Total > customer.CreditLimit)
throw new CreditLimitExceededException();
await _orderRepository.Add(order);
return order;
}
}
// Persistence Layer
public class OrderRepository : IOrderRepository
{
private readonly AppDbContext _context;
public async Task Add(Order order)
{
var entity = new OrderEntity
{
Id = order.Id,
CustomerId = order.CustomerId,
Total = order.Total,
Status = order.Status.ToString(),
CreatedAt = order.CreatedAt
};
_context.Orders.Add(entity);
await _context.SaveChangesAsync();
}
}
Variants
Strict vs Relaxed Layering
Strict Layering: Each layer can only call the layer directly below.
Presentation → Business → Persistence → Database
✗ Presentation → Persistence (not allowed)
Relaxed Layering: Layers can skip intermediate layers.
Presentation → Business → Persistence → Database
✓ Presentation → Persistence (allowed for queries)
Open vs Closed Layers
┌─────────────────────────────────────────────────────────┐
│ CLOSED LAYER │
│ Requests must pass through this layer │
│ Example: Business layer - all requests go through │
│ │
│ OPEN LAYER │
│ Requests can bypass this layer │
│ Example: Caching layer - optional pass-through │
└─────────────────────────────────────────────────────────┘
Trade-offs
Strengths
| Strength | Description |
|---|---|
| Simplicity | Easy to understand and explain |
| Separation | Clear boundaries between concerns |
| Testability | Layers can be tested independently |
| Familiarity | Most developers know this pattern |
| Tooling | Frameworks support this structure |
Weaknesses
| Weakness | Description |
|---|---|
| Lasagna Code | Changes require touching all layers |
| Anemic Models | Business logic often ends up in services |
| Coupling | Layers are tightly coupled vertically |
| Performance | Data passes through all layers |
| Scalability | Difficult to scale layers independently |
When Layered Architecture Breaks Down
Problem 1: Feature Changes Touch All Layers
Adding "discount" field to orders:
1. Add to OrderEntity (Persistence)
2. Add to Order model (Business)
3. Add to OrderViewModel (Presentation)
4. Add to CreateOrderRequest (Presentation)
5. Update OrderMapper (Presentation)
6. Update OrderService (Business)
7. Update OrderRepository (Persistence)
7 files changed for one field!
Problem 2: Anemic Domain Model
// Business layer becomes transaction scripts
public class OrderService
{
public void ApplyDiscount(Guid orderId, decimal discount)
{
var order = _repository.GetById(orderId);
// All logic in service, not in domain
if (order.Status != "Pending")
throw new InvalidOperationException();
if (discount > order.Total * 0.5m)
throw new InvalidOperationException();
order.Discount = discount;
order.Total = order.Total - discount;
_repository.Update(order);
}
}
Problem 3: Database-Driven Design
Database schema drives everything:
- Entities mirror tables
- Business models mirror entities
- ViewModels mirror business models
Result: The database is the architecture
When to Use Layered Architecture
Good Fit
- Simple CRUD applications - Not much business logic
- Small teams - Easy to understand and maintain
- Rapid prototyping - Quick to set up
- Well-understood domains - No complex business rules
- Traditional web apps - Request/response without complex flows
Poor Fit
- Complex business logic - Consider DDD instead
- High scalability needs - Consider microservices
- Multiple UIs - Consider hexagonal architecture
- Event-driven systems - Consider event-driven architecture
- Large teams - Consider modular monolith
Evolving Beyond Layers
Path 1: Add Domain Layer (DDD)
Path 2: Vertical Slices
Path 3: Modular Monolith
Best Practices
1. Keep Layers Thin
// ❌ Fat controller
[HttpPost]
public async Task<ActionResult> CreateOrder(CreateOrderRequest request)
{
// 100 lines of validation
// 50 lines of business logic
// 30 lines of mapping
// 20 lines of error handling
}
// ✅ Thin controller
[HttpPost]
public async Task<ActionResult> CreateOrder(CreateOrderRequest request)
{
var command = _mapper.Map<CreateOrderCommand>(request);
var result = await _orderService.CreateOrder(command);
return Ok(_mapper.Map<OrderResponse>(result));
}
2. Depend on Abstractions
// Business layer defines interface
public interface IOrderRepository
{
Task<Order> GetById(Guid id);
Task Add(Order order);
}
// Persistence layer implements
public class OrderRepository : IOrderRepository
{
// Implementation
}
3. Don't Leak Layer Concerns
// ❌ Database concerns in business layer
public class OrderService
{
public async Task<Order> GetOrder(Guid id)
{
return await _context.Orders
.Include(o => o.Items) // EF Core in business layer!
.FirstOrDefaultAsync(o => o.Id == id);
}
}
// ✅ Repository abstracts data access
public class OrderService
{
public async Task<Order> GetOrder(Guid id)
{
return await _orderRepository.GetById(id);
}
}
Quick Reference Card
┌─────────────────────────────────────────────────────────┐
│ LAYERED ARCHITECTURE QUICK REFERENCE │
├─────────────────────────────────────────────────────────┤
│ │
│ LAYERS │
│ Presentation → Business → Persistence → Database │
│ │
│ STRENGTHS │
│ • Simple, familiar │
│ • Clear separation │
│ • Easy testing │
│ │
│ WEAKNESSES │
│ • Changes touch all layers │
│ • Encourages anemic models │
│ • Database-driven design │
│ │
│ GOOD FOR │
│ • Simple CRUD apps │
│ • Small teams │
│ • Rapid prototyping │
│ │
│ EVOLVE TO │
│ • DDD (complex logic) │
│ • Vertical slices (feature focus) │
│ • Modular monolith (scaling teams) │
│ │
└─────────────────────────────────────────────────────────┘
Next Steps
- Hexagonal Architecture - Ports and Adapters
- Vertical Slice - Alternative organization
- Modular Monolith - Scaling layered architecture