Skip to main content

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

AspectDescription
Core IdeaSeparate concerns into horizontal layers
DependenciesEach layer depends only on the layer below
StrengthsSimplicity, separation of concerns, familiarity
WeaknessesCan lead to "lasagna code", doesn't scale well

The Classic Layers

Layer Responsibilities

LayerResponsibilityExamples
PresentationUser interface, API endpointsControllers, Views, DTOs
BusinessBusiness logic, rules, workflowsServices, Domain objects
PersistenceData access, storageRepositories, ORM mappings
DatabaseData storageSQL 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

StrengthDescription
SimplicityEasy to understand and explain
SeparationClear boundaries between concerns
TestabilityLayers can be tested independently
FamiliarityMost developers know this pattern
ToolingFrameworks support this structure

Weaknesses

WeaknessDescription
Lasagna CodeChanges require touching all layers
Anemic ModelsBusiness logic often ends up in services
CouplingLayers are tightly coupled vertically
PerformanceData passes through all layers
ScalabilityDifficult 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