Loading content...
In the previous page, we explored what each layer does. Now we turn to an equally critical question: How do layers relate to each other?
The relationships between layers—the dependencies, the contracts, the constraints on who can know about whom—are the invisible architecture of your system. Get these relationships wrong, and your carefully designed layers become a façade over a tangled mess. Get them right, and your system gains remarkable properties: testability, flexibility, independent deployability, and graceful evolution.
The core insight is this: Layered architecture isn't primarily about grouping code—it's about controlling dependencies. The layers are a means to an end; the real goal is a dependency structure that enables change without catastrophe.
By the end of this page, you will understand the fundamental dependency rule, how the Dependency Inversion Principle transforms layer relationships, strategies for enforcing layer boundaries, and how to identify and repair common dependency violations.
The defining rule of layered architecture is deceptively simple:
Dependencies flow in one direction only: from upper layers to lower layers.
The Presentation Layer may depend on the Business Layer. The Business Layer may depend on the Data Access Layer. But the reverse is forbidden: the Data Access Layer must have no knowledge of the Business Layer, and the Business Layer must have no knowledge of the Presentation Layer.
This rule has profound implications:
What Does "Depend On" Mean?
In practical terms, Layer A depends on Layer B if:
Source Code Dependency — Code in Layer A imports, references, or types from Layer B. If Layer B's file changes, Layer A might need to be recompiled.
Knowledge Dependency — Code in Layer A makes assumptions about Layer B's internal structure, behavior, or implementation details.
Runtime Dependency — At runtime, Layer A calls functions, instantiates classes, or interacts with objects defined in Layer B.
The Goal: Lower layers should be unaware of who uses them. A repository doesn't know it's being called by an OrderService. A service doesn't know it's being called by a web controller. This ignorance is a feature—it means lower layers can't accidentally couple to upper layer details.
1234567891011121314151617181920212223242526
// ✅ LEGAL: Presentation depends on Business// presentation/controllers/OrderController.tsimport { OrderService } from '../../business/services/OrderService';import { Order } from '../../business/domain/Order'; // ✅ LEGAL: Business depends on Data (via interface)// business/services/OrderService.tsimport { OrderRepository } from '../interfaces/OrderRepository';import { Order } from '../domain/Order'; // ✅ LEGAL: Data implements interfaces from Business// data/repositories/PostgresOrderRepository.tsimport { OrderRepository } from '../../business/interfaces/OrderRepository';import { Order } from '../../business/domain/Order'; // ❌ ILLEGAL: Business imports from Presentation// business/services/OrderService.tsimport { OrderController } from '../../presentation/controllers/OrderController'; // VIOLATION! // ❌ ILLEGAL: Data imports from Business's internal services// data/repositories/PostgresOrderRepository.ts import { PricingService } from '../../business/services/PricingService'; // VIOLATION! // ❌ ILLEGAL: Data imports from Presentation// data/repositories/PostgresOrderRepository.tsimport { CreateOrderDTO } from '../../presentation/dto/CreateOrderDTO'; // VIOLATION!When the Data Layer implements an interface defined in the Business Layer (like OrderRepository), it's importing from Business—but this is allowed and intentional. The Data Layer knows the interface contract it must fulfill, but it doesn't know how that interface is used. This follows the Dependency Inversion Principle, which we'll explore next.
Within the downward-only rule, there are two approaches to layer access:
Strict Layering — Each layer may only access the layer immediately below it. The Presentation Layer cannot directly access the Data Access Layer; it must go through the Business Layer.
Relaxed Layering — Each layer may access any layer below it. The Presentation Layer could bypass the Business Layer and access the Data Access Layer directly if convenient.
123456789101112131415161718192021222324252627282930313233343536373839
// In RELAXED layering, this might be acceptable:// presentation/controllers/ProductController.tsimport { ProductRepository } from '../../data/repositories/ProductRepository'; class ProductController { constructor(private productRepo: ProductRepository) {} // Simple read operation bypasses Business Layer async getProduct(req: Request, res: Response) { const product = await this.productRepo.findById(req.params.id); res.json(product); }} // In STRICT layering, you would always go through a service:// presentation/controllers/ProductController.ts import { ProductService } from '../../business/services/ProductService'; class ProductController { constructor(private productService: ProductService) {} async getProduct(req: Request, res: Response) { // Even simple reads go through service const product = await this.productService.getProductById(req.params.id); res.json(product); }} // And the service might be a simple pass-through:// business/services/ProductService.tsclass ProductService { constructor(private productRepo: ProductRepository) {} async getProductById(id: string): Promise<Product | null> { // Simple delegation, but provides consistent point // for adding business logic later return this.productRepo.findById(id); }}Which Should You Choose?
Favor Strict Layering when:
Relaxed Layering Can Work when:
In practice, most teams start with strict layering for core business operations but relax it for ancillary features. The key is being intentional about which you're doing, not accidentally allowing layer skipping.
Relaxed layering often starts innocently—'just this one read operation can skip the service.' But once the precedent is set, it spreads. Before long, controllers contain business logic 'because the data was already there.' If you choose relaxed layering, establish clear criteria for when bypassing is allowed.
There's a subtle problem with traditional layered dependencies. Look at this scenario:
Business Layer → Data Access Layer
The Business Layer depends on the Data Access Layer. This means:
This feels backwards. The business rules of "orders can't exceed credit limits" shouldn't know or care whether we use PostgreSQL, MongoDB, or carrier pigeons. The Dependency Inversion Principle (DIP) offers a solution.
High-level modules should not depend on low-level modules. Both should depend on abstractions. In layer terms: The Business Layer should not depend on the Data Access Layer's concrete implementations. Instead, the Business Layer defines interfaces (abstractions), and the Data Access Layer implements them.
The key insight: Notice that in the inverted version, the interface lives in the Business Layer, but the implementation lives in the Data Access Layer. The Data Access Layer now depends on (implements) an abstraction defined by the Business Layer.
This inverts the traditional dependency relationship. Instead of:
Business Layer → Data Access Layer
We have:
Business Layer → OrderRepository (interface, in Business Layer)
↑
Data Access Layer ←┘ (implements the interface)
The arrow from Data Access Layer points upward—it depends on the interface defined in Business Layer. But at runtime, the Business Layer still calls the Data Access Layer. We've decoupled the source code dependency from the runtime call direction.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283
// STEP 1: Define interface in the Business Layer// business/interfaces/OrderRepository.tsimport { Order } from '../domain/Order'; // This interface is defined by the CONSUMER (Business Layer)// Not by the PROVIDER (Data Layer)export interface OrderRepository { findById(id: string): Promise<Order | null>; findByCustomerId(customerId: string): Promise<Order[]>; save(order: Order): Promise<Order>; update(order: Order): Promise<Order>; delete(id: string): Promise<void>;} // STEP 2: Business Layer uses the interface// business/services/OrderService.tsimport { OrderRepository } from '../interfaces/OrderRepository';import { Order } from '../domain/Order'; export class OrderService { // Constructor receives interface, not concrete implementation constructor( private readonly orderRepository: OrderRepository ) {} async createOrder(customerId: string, items: OrderItem[]): Promise<Order> { // Business logic here... const order = Order.create({ customerId, items }); // Call repository through interface // OrderService has NO KNOWLEDGE of PostgreSQL, SQL, or anything database-related return this.orderRepository.save(order); }} // STEP 3: Data Layer implements the interface// data/repositories/PostgresOrderRepository.tsimport { OrderRepository } from '../../business/interfaces/OrderRepository';import { Order } from '../../business/domain/Order';import { Pool } from 'pg'; // This class IMPLEMENTS the interface defined by Business Layer// The Data Layer depends on Business Layer's abstractionexport class PostgresOrderRepository implements OrderRepository { constructor(private readonly pool: Pool) {} async findById(id: string): Promise<Order | null> { // PostgreSQL-specific implementation const result = await this.pool.query( 'SELECT * FROM orders WHERE id = $1', [id] ); return result.rows[0] ? this.mapToDomain(result.rows[0]) : null; } async save(order: Order): Promise<Order> { // PostgreSQL-specific implementation await this.pool.query( `INSERT INTO orders (id, customer_id, total, status) VALUES ($1, $2, $3, $4)`, [order.id, order.customerId, order.total, order.status] ); return order; } // ... other methods} // STEP 4: Wire everything together (composition root)// infrastructure/di/container.tsimport { OrderService } from '../../business/services/OrderService';import { PostgresOrderRepository } from '../../data/repositories/PostgresOrderRepository';import { Pool } from 'pg'; export function createContainer() { const pool = new Pool(/* connection config */); // The CONCRETE implementation is injected at startup const orderRepository = new PostgresOrderRepository(pool); const orderService = new OrderService(orderRepository); return { orderService };}Benefits of Dependency Inversion in Layers:
Testability — You can test OrderService with a mock/stub OrderRepository. No database required.
Database Agnosticism — Switch from PostgreSQL to MongoDB? Implement MongoOrderRepository, inject it instead. OrderService doesn't change.
Parallel Development — Teams can work on Business and Data layers simultaneously, agreeing only on the interface contract.
Semantic Clarity — Interfaces live in the Business Layer because they represent business operations on data ("find orders by customer"), not technical operations ("execute SQL query").
Some teams name the interface after the domain concept (OrderRepository) and the implementation after the technology (PostgresOrderRepository, MongoOrderRepository). Others use an 'I' prefix (IOrderRepository) though this is less common in modern TypeScript/Java. What matters most is consistency and that interfaces live in or near the Business Layer.
Even with good intentions, dependency violations creep into codebases. Here are the most common patterns and how to recognize them:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283
// ❌ VIOLATION: Business logic in Presentation Layer// presentation/controllers/OrderController.tsclass OrderController { async checkout(req: Request, res: Response) { const customer = await this.customerRepo.findById(req.body.customerId); // This is BUSINESS LOGIC in a controller! if (customer.status === 'SUSPENDED') { return res.status(403).json({ error: 'Suspended customers cannot order' }); } // More business logic: discount calculation in controller let discount = 0; if (customer.loyaltyPoints > 1000) { discount = 0.10; } else if (customer.loyaltyPoints > 500) { discount = 0.05; } const total = req.body.items.reduce((sum, item) => sum + item.price, 0); const discountedTotal = total * (1 - discount); // This should all be in a domain service! }} // ❌ VIOLATION: Presentation types in Business Layer// business/services/CustomerService.tsimport { CreateCustomerDTO } from '../../presentation/dto/CreateCustomerDTO';import { CustomerResponseDTO } from '../../presentation/dto/CustomerResponseDTO'; class CustomerService { // Service should work with domain types, not DTOs! async createCustomer(dto: CreateCustomerDTO): Promise<CustomerResponseDTO> { // Now business layer knows about API contracts }} // ❌ VIOLATION: Business rules encoded in Data Layer// data/repositories/CustomerRepository.tsclass PostgresCustomerRepository { // This method embeds business rules in SQL async findEligibleForPromotion(): Promise<Customer[]> { // Business rule "eligible for promotion" is now in the Data Layer return this.pool.query(` SELECT * FROM customers WHERE status = 'ACTIVE' AND last_purchase_date > NOW() - INTERVAL '30 days' AND total_purchases > 500 AND NOT EXISTS ( SELECT 1 FROM promotions_used WHERE customer_id = customers.id AND promo_code = 'SUMMER2024' ) `); // If the promotion rules change, we edit the repository! }} // ❌ VIOLATION: ORM entity used across all layers// data/entities/CustomerEntity.ts@Entity('customers')export class CustomerEntity { @PrimaryColumn() id: string; @Column() name: string; @Column() email: string;} // Then in controller:// presentation/controllers/CustomerController.tsclass CustomerController { async getCustomer(req, res) { // Returning ORM entity directly to API! // Changes in database schema now affect API responses const customer = await this.customerRepo.findById(req.params.id); res.json(customer); // customer is CustomerEntity with ORM decorators }}Why These Violations Hurt:
| Violation | Symptom | Long-term Cost |
|---|---|---|
| Business in Presentation | Controllers grow large, logic duplicates | Changing business rules requires editing UI code |
| Presentation in Business | Services tied to API versioning | Can't reuse service from CLI or message handler |
| Business in Data | Repository methods multiply, hard to test | Database changes cause business logic breaks |
| ORM Entities Everywhere | Tight coupling to database schema | Schema change ripples through entire codebase |
One dependency violation tends to attract more. Once developers see that controllers contain business logic, adding more feels acceptable. Once one DTO leaks into a service, why not others? Vigilance on the first violations prevents cascading degradation.
Architectural rules that exist only in documentation or team agreements inevitably erode. The only boundaries that persist are those enforced automatically. Here are strategies for making layer boundaries real:
Strategy 1: Directory Structure and Import Rules
The simplest enforcement: configure your linter or build tool to prevent cross-layer imports.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061
// .eslintrc.jsmodule.exports = { rules: { 'no-restricted-imports': [ 'error', { patterns: [ // Business layer cannot import from Presentation { group: ['**/presentation/**'], message: 'Business layer cannot depend on Presentation layer' } ] } ] }, overrides: [ { // Rules specific to Business layer files files: ['src/business/**/*.ts'], rules: { 'no-restricted-imports': [ 'error', { patterns: [ { group: ['**/presentation/**'], message: 'Business layer cannot import from Presentation' }, { group: ['**/data/repositories/*Repository.ts'], message: 'Business layer should use interfaces, not concrete repositories' } ] } ] } }, { // Rules specific to Data layer files files: ['src/data/**/*.ts'], rules: { 'no-restricted-imports': [ 'error', { patterns: [ { group: ['**/presentation/**'], message: 'Data layer cannot import from Presentation' }, { group: ['**/business/services/**'], message: 'Data layer cannot import business services' } ] } ] } } ]};Strategy 2: Separate Packages/Modules
For stronger boundaries, put each layer in a separate package or module with explicit dependency declarations.
12345678910111213141516171819202122232425262728293031323334353637383940414243
// Monorepo with separate packages per layer// packages/business/package.json{ "name": "@myapp/business", "version": "1.0.0", "dependencies": { // Business layer has NO dependencies on other layers // (uses interfaces defined in this package) }} // packages/data/package.json{ "name": "@myapp/data", "version": "1.0.0", "dependencies": { // Data layer depends on Business for interfaces "@myapp/business": "^1.0.0", "pg": "^8.0.0" }} // packages/presentation/package.json{ "name": "@myapp/presentation", "version": "1.0.0", "dependencies": { // Presentation depends on Business "@myapp/business": "^1.0.0", "express": "^4.18.0" }} // packages/app/package.json (composition root){ "name": "@myapp/app", "version": "1.0.0", "dependencies": { "@myapp/business": "^1.0.0", "@myapp/data": "^1.0.0", "@myapp/presentation": "^1.0.0" }}Strategy 3: Architecture Testing
Use specialized tools that analyze code structure and fail builds on violations.
12345678910111213141516171819202122232425262728293031323334353637383940414243
// architecture.test.tsimport { analyzeArchitecture } from 'arch-unit-ts'; describe('Layered Architecture Rules', () => { const architecture = analyzeArchitecture({ sourceDirectory: 'src', layers: { presentation: 'presentation/**', business: 'business/**', data: 'data/**' } }); it('Business layer should not depend on Presentation', () => { architecture .layer('business') .shouldNot() .dependOn('presentation'); }); it('Data layer should not depend on Presentation', () => { architecture .layer('data') .shouldNot() .dependOn('presentation'); }); it('Data layer should only import interfaces from Business', () => { architecture .layer('data') .shouldOnly() .importFrom('business', '**/interfaces/**'); }); it('Controllers should be thin (under 100 lines)', () => { architecture .classes() .matching('presentation/controllers/**') .should() .haveLinesUnder(100) .because('Controllers should delegate to services, not contain business logic'); });});Use multiple enforcement layers: (1) Directory conventions for visual clarity, (2) Linter rules for immediate developer feedback, (3) Architecture tests for CI/CD gate, (4) Code review culture for nuanced judgment. Each layer catches what others miss.
Some functionalities don't fit neatly into a single layer. Logging, authentication, caching, monitoring, transaction management, and error handling cut across multiple layers. These are called cross-cutting concerns.
The naive approach—each layer implements its own logging, authentication checks, etc.—leads to duplication and inconsistency. But pulling cross-cutting code into a shared location risks creating dependencies that bypass layer boundaries.
Strategies for Cross-Cutting Concerns:
| Strategy | How It Works | Best For |
|---|---|---|
| Aspect-Oriented Programming (AOP) | Decorators/annotations intercept method calls and apply cross-cutting behavior | Logging, timing, transaction boundaries |
| Middleware/Interceptors | Pipeline components that execute before/after layer operations | Authentication, request validation, error handling |
| Infrastructure Layer | Dedicated shared layer that all layers can access | Configuration, utilities, common types |
| Dependency Injection | Cross-cutting services injected where needed | Logging, metrics, feature flags |
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152
// infrastructure/decorators/logging.tsexport function LogMethod(target: any, propertyKey: string, descriptor: PropertyDescriptor) { const originalMethod = descriptor.value; descriptor.value = async function(...args: any[]) { const logger = Container.get(Logger); logger.info(`Entering ${propertyKey}`, { args }); try { const result = await originalMethod.apply(this, args); logger.info(`Exiting ${propertyKey}`, { result }); return result; } catch (error) { logger.error(`Error in ${propertyKey}`, { error }); throw error; } }; return descriptor;} // business/services/OrderService.tsexport class OrderService { // Logging is applied via decorator - no logging code in method @LogMethod async createOrder(customerId: string, items: OrderItem[]): Promise<Order> { // Pure business logic, no logging clutter const order = Order.create({ customerId, items }); return this.orderRepository.save(order); }} // infrastructure/middleware/authMiddleware.tsexport function authMiddleware(req: Request, res: Response, next: NextFunction) { // Cross-cutting authentication - applies to all routes const token = req.headers.authorization; if (!token) { return res.status(401).json({ error: 'Authentication required' }); } try { const user = validateToken(token); req.user = user; // Attach to request for downstream use next(); } catch { res.status(401).json({ error: 'Invalid token' }); }} // Apply to all routesapp.use(authMiddleware);The Infrastructure Layer Pattern:
Many codebases add an Infrastructure layer that sits alongside (not above or below) the other layers. This layer contains truly shared utilities that have no business meaning:
All layers may import from Infrastructure, but Infrastructure imports from no other layer (except possibly standard libraries).
The Infrastructure layer should contain only genuinely cross-cutting, domain-agnostic code. It's not a place to put code that doesn't obviously belong elsewhere. If something has business meaning, it belongs in the Business layer, even if multiple places use it.
One of the most practical benefits of correct layer dependencies is testability. When dependencies point in the right direction and respect abstraction boundaries, each layer becomes independently testable.
Testing Each Layer:
| Layer | Testing Approach | Dependencies Mocked |
|---|---|---|
| Presentation | Integration tests with fake/mock services | Business Layer services are mocked |
| Business | Unit tests with mock repositories | Data Layer repositories are mocked |
| Data | Integration tests against test database | Actual database (test instance), Business Layer not involved |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
// tests/business/OrderService.test.tsimport { OrderService } from '../../src/business/services/OrderService';import { OrderRepository } from '../../src/business/interfaces/OrderRepository';import { CustomerRepository } from '../../src/business/interfaces/CustomerRepository';import { Customer, Order, OrderItem } from '../../src/business/domain'; describe('OrderService', () => { let orderService: OrderService; let mockOrderRepository: jest.Mocked<OrderRepository>; let mockCustomerRepository: jest.Mocked<CustomerRepository>; beforeEach(() => { // Create mock implementations mockOrderRepository = { findById: jest.fn(), save: jest.fn(), getOutstandingBalance: jest.fn(), } as jest.Mocked<OrderRepository>; mockCustomerRepository = { findById: jest.fn(), } as jest.Mocked<CustomerRepository>; orderService = new OrderService( mockOrderRepository, mockCustomerRepository ); }); describe('createOrder', () => { it('should create order for active customer within credit limit', async () => { // Arrange: Set up test data const customer = Customer.create({ id: 'cust-123', name: 'John Doe', creditLimit: 1000, status: 'ACTIVE' }); const orderItems = [ OrderItem.create({ productId: 'prod-1', quantity: 2, unitPrice: 50 }) ]; mockCustomerRepository.findById.mockResolvedValue(customer); mockOrderRepository.getOutstandingBalance.mockResolvedValue(0); mockOrderRepository.save.mockImplementation(async (order) => order); // Act const order = await orderService.createOrder('cust-123', orderItems); // Assert: Business logic was applied correctly expect(order.total).toBe(100); expect(mockOrderRepository.save).toHaveBeenCalledWith( expect.objectContaining({ customerId: 'cust-123', total: 100 }) ); }); it('should reject order if credit limit exceeded', async () => { // Arrange const customer = Customer.create({ id: 'cust-123', name: 'John Doe', creditLimit: 100, // Low limit status: 'ACTIVE' }); const orderItems = [ OrderItem.create({ productId: 'prod-1', quantity: 10, unitPrice: 50 }) // $500 order ]; mockCustomerRepository.findById.mockResolvedValue(customer); mockOrderRepository.getOutstandingBalance.mockResolvedValue(0); // Act & Assert await expect( orderService.createOrder('cust-123', orderItems) ).rejects.toThrow(CreditLimitExceededError); // Verify order was NOT saved expect(mockOrderRepository.save).not.toHaveBeenCalled(); }); it('should reject order for inactive customer', async () => { // Arrange const customer = Customer.create({ id: 'cust-123', name: 'John Doe', creditLimit: 10000, status: 'SUSPENDED' // Inactive }); mockCustomerRepository.findById.mockResolvedValue(customer); // Act & Assert await expect( orderService.createOrder('cust-123', []) ).rejects.toThrow(InactiveCustomerError); }); });});Notice what's happening:
This is only possible because of correct dependency direction. If OrderService directly imported and instantiated PostgresOrderRepository, we couldn't substitute a mock. We'd need a real database to test business logic.
If you find yourself struggling to write unit tests for business logic—needing database fixtures, web servers, or elaborate setup—it's often a sign that dependencies point in the wrong direction. Correct architecture makes testing natural, almost trivially easy.
Layer dependencies are the skeleton of your architecture—invisible but essential for structure. Let's consolidate the key principles:
What's Next:
Now that we understand layers and their dependencies, we'll examine the benefits and limitations of layered architecture. When does this pattern shine? When does it struggle? What are the alternatives, and when should you reach for them?
You now understand how layers depend on each other, why dependency direction matters so critically, and how to enforce boundaries that keep your architecture healthy over time. Next, we'll evaluate when layered architecture is the right choice—and when you might need something different.