As code grows, separation of concerns becomes necessary. When HTTP request handling, business logic, and database access mix in a single class, the blast radius of changes becomes unpredictable. Layered architecture solves this by separating code into horizontal layers based on technical responsibility.
The core is not layer separation itself but controlling the direction of dependencies.
Traditional Three-Layer Structure
The most basic form consists of Presentation, Business Logic, and Data Access layers. It mirrors the flow of receiving a web request, executing business logic, and persisting to a database.
Dependencies flow downward. Presentation calls Business Logic; Business Logic calls Data Access.
The problem: business logic depends directly on data access technology. Switching from MySQL to MongoDB or changing an external API integration requires modifying business logic. Core rules remain unchanged, yet technical choices force changes to the heart of the application.
Four-Layer Structure
To address this, layers are further refined and dependency direction is redesigned.
Presentation: Handles HTTP request/response processing. Routing, request parsing, and response serialization belong here. Controllers, interceptors, and exception filters live in this layer.
Application: Orchestrates business logic. Calls multiple services, transforms data, and defines transaction boundaries. This layer serves as the entry point for use cases. Interfaces for infrastructure dependencies are also defined here.
Domain: Contains business entities and rules. Pure models with no technical dependencies. Changes in other layers do not affect this layer.
Infra: Handles technical implementations. Database access, external API calls, messaging, and caching all belong here. Implements interfaces defined in the Application layer.
Dependency Direction and DIP
In the three-layer structure, dependencies flow Presentation → Business → Data, always downward. The four-layer structure inverts this direction.
The core principle is the Dependency Inversion Principle, DIP. Upper layers do not depend on lower layers directly. Instead, upper layers define interfaces, and lower layers implement them.
Presentation → Application → Domain
↑
Infra (implements interfaces)
The Application layer knows only that “an order gets saved.” Whether that save targets MySQL or MongoDB remains unknown. The Infra layer implements the interface, and a DI container connects the two.
In this structure, Domain depends on nothing. It is the most stable layer. Infra depends on interfaces defined by Application. Dependency arrows converge toward the core business logic.
Code Example
Define interfaces in the Application layer; place implementations in the Infra layer.
// application/abstraction/order-repository.ts
interface OrderRepository {
save(order: Order): Promise<Order>;
findById(id: string): Promise<Order | null>;
}
// infra/persistence/order-repository-impl.ts
class OrderRepositoryImpl implements OrderRepository {
constructor(private readonly db: Database) {}
async save(order: Order): Promise<Order> {
const entity = OrderEntity.from(order);
await this.db.save(entity);
return entity.toDomain();
}
async findById(id: string): Promise<Order | null> {
const entity = await this.db.findById(id);
return entity?.toDomain() ?? null;
}
}
The Application layer service depends only on the interface.
// application/service/order-service.ts
class OrderService {
constructor(private readonly orderRepository: OrderRepository) {}
async createOrder(request: CreateOrderDto): Promise<OrderDto> {
const order = Order.create(request);
const saved = await this.orderRepository.save(order);
return OrderDto.from(saved);
}
}
Swapping databases or injecting mocks for tests requires changing only the Infra layer implementation. OrderService stays untouched.
When It Fits
Layered architecture suits services where business logic concentrates in a single domain. When one API executes a complex processing pipeline, layer-by-layer responsibility separation pays off.
It also works well in environments with frequent technology changes. Replacing a filtering engine from an internal implementation to an external library, or switching databases, requires modifying only the Infra layer. The Application layer’s interfaces block change propagation.
Limitations
For services with multiple domains, it may not be the best fit. When users, products, and orders each form independent domains, Vertical Slice Architecture by domain achieves higher cohesion than horizontal layering.
Service class bloat is another concern. When many use cases accumulate in a single Application service, the code grows long. Splitting services by use case or applying the Facade pattern to reduce the external interface helps.
DTO conversion costs between layers also arise. Separate Presentation DTOs, Application DTOs, Domain entities, and Infra entities mean more conversion code. The benefits of layer boundaries must be balanced against conversion overhead.
As code grows, separation of concerns becomes necessary. Layered architecture performs that separation by technical responsibility. Which direction dependencies point matters more than the layers themselves.