Loading learning content...
The abstraction principles we've explored—identifying boundaries, hiding implementation, designing stable interfaces—apply not just to individual classes but to entire architectures. As systems grow, abstraction becomes the primary tool for managing complexity at scale.
Layered architecture is the fundamental pattern for applying abstraction to large systems. By organizing code into layers with clear responsibilities and defined interfaces between them, we create systems where:
This page explores how abstraction principles manifest in layered architectures—from the classic three-tier model to Clean Architecture, Hexagonal Architecture, and beyond. You'll learn to apply abstraction not just to classes, but to the very structure of your systems.
By the end of this page, you will understand how abstraction applies to architectural layers, the principles behind Clean/Hexagonal/Onion architectures, and how to design systems where layers interact through clean abstractions.
Layered architecture organizes a system into horizontal layers, where each layer has a specific responsibility and depends only on the layers below it. This is abstraction at the architectural level—each layer presents a simplified interface to the layer above while hiding its implementation complexity.
The Classic Three-Tier Architecture:
┌──────────────────────────────────────────────────────────────┐│ PRESENTATION LAYER ││ ││ Responsibility: Handle user interaction, render UI ││ Contains: Controllers, Views, DTOs, Validators ││ Depends on: Application Layer ││ ││ Interface Exposed: HTTP endpoints, GraphQL schema ││ Interface Hidden: UI frameworks, request handling │├──────────────────────────────────────────────────────────────┤│ APPLICATION LAYER ││ ││ Responsibility: Orchestrate business use cases ││ Contains: Services, Commands, Queries, Event handlers ││ Depends on: Domain Layer ││ ││ Interface Exposed: Use case methods, application services ││ Interface Hidden: Workflow orchestration, transaction mgmt │├──────────────────────────────────────────────────────────────┤│ DOMAIN LAYER ││ ││ Responsibility: Core business logic and rules ││ Contains: Entities, Value Objects, Domain Services ││ Depends on: Nothing (or only abstractions) ││ ││ Interface Exposed: Domain models, business operations ││ Interface Hidden: Business rule implementation │├──────────────────────────────────────────────────────────────┤│ INFRASTRUCTURE LAYER ││ ││ Responsibility: Technical concerns, external systems ││ Contains: Repositories, API clients, file systems ││ Depends on: Domain interfaces (implements them) ││ ││ Interface Exposed: Implements domain-defined interfaces ││ Interface Hidden: Database, network, file I/O details │└──────────────────────────────────────────────────────────────┘ Traditional dependency flow: Top → BottomEach layer depends only on the layer directly belowEach layer is an abstraction:
| Layer | Abstracts Away | Presents As |
|---|---|---|
| Presentation | HTTP, HTML, JSON serialization | Clean API endpoints |
| Application | Transaction management, event dispatching | Simple use case operations |
| Domain | Business rule complexity, invariant enforcement | Intuitive business operations |
| Infrastructure | Database queries, network calls, file I/O | Repository and service interfaces |
The power of layered architecture is that changes stay contained. A database migration affects only the Infrastructure layer. A UI redesign affects only the Presentation layer. New business rules affect only the Domain layer. Each layer's abstraction boundary prevents change propagation.
The fundamental rule of layered architecture: dependencies point downward. Upper layers know about lower layers, never the reverse. This ensures that core business logic (bottom) doesn't depend on volatile presentation concerns (top).
The classic layered architecture has a critical flaw: the Domain layer depends on the Infrastructure layer. Since Infrastructure contains database code, external APIs, and other volatile dependencies, this couples your most stable code (Domain) to your least stable code (Infrastructure).
The Dependency Inversion Principle solves this by inverting the dependency direction:
This is the foundation of Clean Architecture, Hexagonal Architecture, and Onion Architecture—they all share this insight.
BEFORE: Traditional Layering (Dependencies point down)═══════════════════════════════════════════════════ ┌────────────────┐ Domain depends on │ Domain │ ───────► Infrastructure! └────────────────┘ (BAD: Domain is coupled │ to volatile code) ▼ ┌────────────────┐ │ Infrastructure │ └────────────────┘ AFTER: Dependency Inversion (Dependencies point inward)═══════════════════════════════════════════════════ ┌────────────────┐ Domain defines │ Domain │ interfaces │ │ (Stable, independent) │ «interface» │ │ Repository │◄────── Infrastructure implements └────────────────┘ domain interfaces! ▲ │ implements │ ┌────────────────┐ │ Infrastructure │ Infrastructure depends on │ │ Domain interfaces │ PostgresRepo │ (Domain untouched if we └────────────────┘ swap databases)Practical Implementation:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133
// ═══════════════════════════════════════════════════// DOMAIN LAYER - Defines interfaces, knows nothing about infrastructure// ═══════════════════════════════════════════════════ // domain/entities/Order.tsclass Order { constructor( readonly id: OrderId, private items: OrderItem[], private status: OrderStatus ) {} addItem(item: OrderItem): void { if (this.status !== OrderStatus.DRAFT) { throw new OrderAlreadySubmittedError(this.id); } this.items.push(item); } submit(): void { if (this.items.length === 0) { throw new EmptyOrderError(this.id); } this.status = OrderStatus.SUBMITTED; } calculateTotal(): Money { return this.items.reduce( (sum, item) => sum.add(item.price.multiply(item.quantity)), Money.zero() ); }} // domain/ports/OrderRepository.ts// Domain DEFINES what it needs via an interfaceinterface OrderRepository { findById(id: OrderId): Promise<Order | null>; save(order: Order): Promise<void>; findByCustomer(customerId: CustomerId): Promise<Order[]>;} // domain/ports/PaymentGateway.ts// Another domain-defined interfaceinterface PaymentGateway { charge(amount: Money, method: PaymentMethod): Promise<PaymentResult>; refund(paymentId: PaymentId, amount?: Money): Promise<RefundResult>;} // ═══════════════════════════════════════════════════// APPLICATION LAYER - Orchestrates use cases using domain interfaces// ═══════════════════════════════════════════════════ // application/services/OrderService.tsclass OrderService { constructor( private orderRepo: OrderRepository, // Interface, not implementation private paymentGateway: PaymentGateway // Interface, not implementation ) {} async submitOrder(orderId: OrderId, payment: PaymentMethod): Promise<void> { const order = await this.orderRepo.findById(orderId); if (!order) throw new OrderNotFoundError(orderId); const total = order.calculateTotal(); const result = await this.paymentGateway.charge(total, payment); if (!result.success) throw new PaymentFailedError(result.error); order.submit(); await this.orderRepo.save(order); }} // ═══════════════════════════════════════════════════// INFRASTRUCTURE LAYER - Implements domain interfaces// ═══════════════════════════════════════════════════ // infrastructure/persistence/PostgresOrderRepository.tsclass PostgresOrderRepository implements OrderRepository { constructor(private db: DatabaseConnection) {} async findById(id: OrderId): Promise<Order | null> { const row = await this.db.query( 'SELECT * FROM orders WHERE id = $1', [id.value] ); return row ? this.toDomain(row) : null; } async save(order: Order): Promise<void> { await this.db.query( 'INSERT INTO orders ... ON CONFLICT DO UPDATE ...', this.toRecord(order) ); } private toDomain(row: DbRow): Order { // Map database row to domain entity } private toRecord(order: Order): DbRecord { // Map domain entity to database record }} // infrastructure/payments/StripePaymentGateway.tsclass StripePaymentGateway implements PaymentGateway { constructor(private stripe: Stripe) {} async charge(amount: Money, method: PaymentMethod): Promise<PaymentResult> { const charge = await this.stripe.charges.create({ amount: amount.cents, currency: amount.currency.code, source: this.toStripeSource(method), }); return this.toPaymentResult(charge); }} // ═══════════════════════════════════════════════════// COMPOSITION ROOT - Wires everything together// ═══════════════════════════════════════════════════ // main.tsconst db = new PostgresConnection(config.database);const stripe = new Stripe(config.stripe.apiKey); const orderRepo = new PostgresOrderRepository(db);const paymentGateway = new StripePaymentGateway(stripe); const orderService = new OrderService(orderRepo, paymentGateway);// Domain code has NO IDEA it's using Postgres or Stripe!All the wiring happens in one place: the Composition Root (often main.ts or a DI container). This single location knows about all implementations and wires them together. The rest of the application works only with interfaces.
Clean Architecture, popularized by Robert Martin (Uncle Bob), takes dependency inversion to its logical conclusion. It organizes code in concentric circles, where dependencies always point inward toward the center.
┌─────────────────────────────────────────┐ │ FRAMEWORKS & DRIVERS │ │ (outermost - most volatile) │ │ • Web frameworks (Express, Nest) │ │ • Database drivers (pg, mongoose) │ │ • External service SDKs │ │ ┌─────────────────────────┐ │ │ │ INTERFACE ADAPTERS │ │ │ │ • Controllers │ │ │ │ • Gateways │ │ │ │ • Presenters │ │ │ │ • Repositories │ │ │ │ ┌───────────────┐ │ │ │ │ │ APPLICATION │ │ │ │ │ │ • Use Cases │ │ │ │ │ │ • Services │ │ │ │ │ │ ┌───────────┐ │ │ │ │ │ │ │ DOMAIN │ │ │ │ │ │ │ │ • Entities│ │ │ │ │ │ │ │ • Rules │ │ │ │ │ │ │ │ (center) │ │ │ │ │ │ │ └───────────┘ │ │ │ │ │ └───────────────┘ │ │ │ └─────────────────────────┘ │ └─────────────────────────────────────────┘ All arrows point INWARD → Nothing in inner circles knows anything about outer circles| Layer | Contains | Depends On | Knows About |
|---|---|---|---|
| Domain (Entities) | Entities, Value Objects, Domain Services, Domain Events | Nothing | Core business rules only |
| Application (Use Cases) | Use case implementations, Application services, Commands/Queries | Domain | What operations exist, not how they're invoked |
| Interface Adapters | Controllers, Presenters, Gateways, Repository implementations | Application + Domain | How to convert between external formats and domain |
| Frameworks & Drivers | Web frameworks, databases, external services | Adapters | Technical infrastructure details |
The Key Insight:
The most important code—your business logic—is at the center, completely isolated from frameworks, databases, and external services. This provides several powerful benefits:
Framework Independence — Your business logic doesn't depend on Express, Nest, or any web framework. You can switch frameworks without touching business code.
Database Independence — Your domain doesn't know about SQL, MongoDB, or any persistence technology. You can migrate databases without changing business logic.
Testability — You can test business logic with simple unit tests. No database setup, no HTTP mocking, no framework initialization.
Independent Deployment — Outer layers can be deployed and scaled independently of inner layers.
Technology Evolution — As frameworks and databases evolve, only the outer layers need updates. The core remains stable.
When business logic depends on your web framework or ORM, you're not just coupled to today's version—you're coupled to the framework's evolution decisions. Breaking changes in frameworks force changes to business logic. Clean Architecture prevents this by keeping business logic framework-free.
Hexagonal Architecture (also called Ports and Adapters), introduced by Alistair Cockburn, provides the same isolation as Clean Architecture with a different mental model. Instead of concentric circles, it uses the metaphor of a hexagon with ports and adapters.
Core Concepts:
DRIVING SIDE (Primary Adapters - trigger actions) ┌─────────────┐ ┌─────────────┐ │ REST │ │ GraphQL │ │ Adapter │ │ Adapter │ └──────┬──────┘ └──────┬──────┘ │ Driving Ports │ ═══════╪═══════════════════════╪═══════════ │ │ ▼ ▼ ╔═══════════════════════════════════════╗ ║ ║ ║ APPLICATION CORE ║ ║ ║ ║ ┌─────────────────────────────┐ ║ ║ │ DOMAIN MODEL │ ║ ║ │ Entities, Value Objects │ ║ ║ │ Domain Services │ ║ ║ └─────────────────────────────┘ ║ ║ ║ ║ ┌─────────────────────────────┐ ║ ║ │ APPLICATION SERVICES │ ║ ║ │ Use Cases, Orchestration │ ║ ║ └─────────────────────────────┘ ║ ║ ║ ╚═══════════════════════════════════════╝ │ │ ▼ ▼ ═══════╪═══════════════════════╪═══════════ │ Driven Ports │ ┌──────┴──────┐ ┌──────┴──────┐ │ Database │ │ Email │ │ Adapter │ │ Adapter │ └─────────────┘ └─────────────┘ DRIVEN SIDE (Secondary Adapters - called by app)Ports and Adapters in Detail:
| Type | Role | Examples |
|---|---|---|
| Driving Ports | Interfaces for actions triggered FROM outside | OrderingAPI, AdminAPI |
| Driving Adapters | Implementations that receive external requests | REST Controller, CLI Handler |
| Driven Ports | Interfaces for capabilities the app NEEDS | OrderRepository, EmailSender |
| Driven Adapters | Implementations that fulfill those needs | PostgreSQL Repository, SMTP Adapter |
The Metaphor:
Think of the application as a device with multiple plugs (ports). Each port defines a specific interface. Adapters are the cables that connect those ports to the outside world. You can swap any adapter without affecting the application—just like swapping an HDMI cable for DisplayPort.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192
// ════════════════════════════════════════════════// DRIVING PORT - Interface for external triggers// ════════════════════════════════════════════════ // ports/input/OrderingPort.tsinterface OrderingPort { createOrder(request: CreateOrderRequest): Promise<OrderId>; submitOrder(orderId: OrderId, payment: PaymentInfo): Promise<void>; cancelOrder(orderId: OrderId, reason: string): Promise<void>;} // ════════════════════════════════════════════════// DRIVEN PORT - Interface for app's needs// ════════════════════════════════════════════════ // ports/output/OrderPersistence.tsinterface OrderPersistence { save(order: Order): Promise<void>; findById(id: OrderId): Promise<Order | null>; findByCustomer(customerId: CustomerId): Promise<Order[]>;} // ports/output/PaymentProcessor.tsinterface PaymentProcessor { processPayment(amount: Money, info: PaymentInfo): Promise<PaymentResult>;} // ════════════════════════════════════════════════// APPLICATION CORE - Uses ports, knows no adapters// ════════════════════════════════════════════════ // application/OrderingService.tsclass OrderingService implements OrderingPort { constructor( private persistence: OrderPersistence, private payments: PaymentProcessor ) {} async createOrder(request: CreateOrderRequest): Promise<OrderId> { const order = Order.create(request.customerId, request.items); await this.persistence.save(order); return order.id; } async submitOrder(orderId: OrderId, payment: PaymentInfo): Promise<void> { const order = await this.persistence.findById(orderId); if (!order) throw new OrderNotFoundError(orderId); const result = await this.payments.processPayment( order.calculateTotal(), payment ); if (!result.success) throw new PaymentFailedError(result.error); order.submit(); await this.persistence.save(order); }} // ════════════════════════════════════════════════// DRIVING ADAPTER - Connects external to port// ════════════════════════════════════════════════ // adapters/input/rest/OrderController.tsclass OrderController { constructor(private ordering: OrderingPort) {} async handleCreateOrder(req: HttpRequest): Promise<HttpResponse> { const request = this.parseCreateOrderRequest(req.body); const orderId = await this.ordering.createOrder(request); return HttpResponse.created({ orderId: orderId.value }); }} // ════════════════════════════════════════════════// DRIVEN ADAPTER - Implements port for external// ════════════════════════════════════════════════ // adapters/output/postgres/PostgresOrderPersistence.tsclass PostgresOrderPersistence implements OrderPersistence { constructor(private db: DatabaseConnection) {} async save(order: Order): Promise<void> { await this.db.query('INSERT INTO orders ...', this.toRow(order)); } async findById(id: OrderId): Promise<Order | null> { const row = await this.db.query('SELECT ...', [id.value]); return row ? this.toDomain(row) : null; }}Not all functionality fits neatly into layers. Cross-cutting concerns—logging, security, caching, transactions, monitoring—touch multiple layers. How do we handle these without violating our abstraction boundaries?
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
// ════════════════════════════════════════════════// DECORATOR PATTERN for cross-cutting concerns// ════════════════════════════════════════════════ // Core interfaceinterface OrderRepository { save(order: Order): Promise<void>; findById(id: OrderId): Promise<Order | null>;} // Base implementationclass PostgresOrderRepository implements OrderRepository { async save(order: Order): Promise<void> { /* ... */ } async findById(id: OrderId): Promise<Order | null> { /* ... */ }} // Logging decorator - adds logging without modifying coreclass LoggingOrderRepository implements OrderRepository { constructor( private inner: OrderRepository, private logger: Logger ) {} async save(order: Order): Promise<void> { this.logger.info('Saving order', { orderId: order.id.value }); await this.inner.save(order); this.logger.info('Order saved', { orderId: order.id.value }); } async findById(id: OrderId): Promise<Order | null> { this.logger.debug('Finding order', { orderId: id.value }); return this.inner.findById(id); }} // Caching decorator - adds caching without modifying coreclass CachingOrderRepository implements OrderRepository { constructor( private inner: OrderRepository, private cache: Cache ) {} async save(order: Order): Promise<void> { await this.inner.save(order); await this.cache.set(`order:${order.id.value}`, order); } async findById(id: OrderId): Promise<Order | null> { const cached = await this.cache.get(`order:${id.value}`); if (cached) return cached as Order; const order = await this.inner.findById(id); if (order) await this.cache.set(`order:${id.value}`, order); return order; }} // Metrics decorator - adds metrics without modifying coreclass MetricsOrderRepository implements OrderRepository { constructor( private inner: OrderRepository, private metrics: MetricsClient ) {} async save(order: Order): Promise<void> { const timer = this.metrics.startTimer('order_save_duration'); try { await this.inner.save(order); this.metrics.increment('order_save_success'); } catch (error) { this.metrics.increment('order_save_failure'); throw error; } finally { timer.stop(); } } async findById(id: OrderId): Promise<Order | null> { return this.inner.findById(id); // Or add metrics here too }} // ════════════════════════════════════════════════// COMPOSITION - Stack decorators as needed// ════════════════════════════════════════════════ function createOrderRepository(config: Config): OrderRepository { let repo: OrderRepository = new PostgresOrderRepository(config.db); // Add cross-cutting concerns as decorators repo = new LoggingOrderRepository(repo, config.logger); repo = new MetricsOrderRepository(repo, config.metrics); if (config.cache.enabled) { repo = new CachingOrderRepository(repo, config.cache); } return repo;} // The application layer receives a fully-decorated repository// It has no idea about logging, caching, or metrics!const orderService = new OrderService(createOrderRepository(config));The beauty of decorators is that they maintain the same interface. The OrderService doesn't know if it's talking to a cached, logged, metriced repository or a plain one. Cross-cutting concerns are added without breaking abstractions.
In distributed systems, abstraction applies not just within a service but between services. Each service presents an abstraction to others through its API—hiding internal complexity behind a contract.
| Abstraction | What It Hides | Contract Defines |
|---|---|---|
| API Schema | Implementation language, framework, internal structure | Request/response format, endpoints |
| Domain Model | Internal entities, database schema | Public DTOs, resources |
| Communication Protocol | REST vs gRPC vs messaging | Semantic operations |
| Deployment Topology | Number of instances, locations | Availability SLA |
| Data Storage | Database type, caching strategy | Consistency guarantees |
Anti-Corruption Layers Between Services:
When one service depends on another, it should define the interface it expects and translate the external service's actual API into that interface. This prevents external APIs from polluting your domain model.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
// ════════════════════════════════════════════════// OUR SERVICE'S VIEW of what it needs from inventory// ════════════════════════════════════════════════ // domain/ports/InventoryChecker.ts// We define what WE need, not what inventory service providesinterface InventoryChecker { checkAvailability(productId: ProductId, quantity: number): Promise<InventoryStatus>; reserveStock(productId: ProductId, quantity: number): Promise<ReservationId>; releaseReservation(reservationId: ReservationId): Promise<void>;} // Our domain's view of inventory statusinterface InventoryStatus { available: boolean; currentStock: number; expectedRestockDate?: Date;} // ════════════════════════════════════════════════// ANTI-CORRUPTION LAYER - Translates inventory service's API// ════════════════════════════════════════════════ // infrastructure/inventory/InventoryServiceClient.tsclass InventoryServiceClient implements InventoryChecker { constructor(private httpClient: HttpClient) {} async checkAvailability(productId: ProductId, quantity: number): Promise<InventoryStatus> { // External service has different API shape const response = await this.httpClient.get<ExternalInventoryResponse>( `/api/v2/products/${productId.value}/stock` ); // Translate external model to our domain model return this.toInventoryStatus(response, quantity); } private toInventoryStatus(ext: ExternalInventoryResponse, needed: number): InventoryStatus { // External service uses different concepts // We translate to our domain's vocabulary return { available: ext.qty_on_hand >= needed && ext.status === 'ACTIVE', currentStock: ext.qty_on_hand - ext.qty_reserved, expectedRestockDate: ext.next_shipment_eta ? new Date(ext.next_shipment_eta) : undefined, }; } async reserveStock(productId: ProductId, quantity: number): Promise<ReservationId> { const response = await this.httpClient.post<ExternalReservationResponse>( '/api/v2/reservations', { sku: productId.value, // External uses 'sku', we use 'productId' qty: quantity, // External uses 'qty', we use 'quantity' ttl_minutes: 30 } ); return new ReservationId(response.reservation_id); }} // External service's response shape (we don't let this leak into domain)interface ExternalInventoryResponse { sku: string; qty_on_hand: number; qty_reserved: number; status: 'ACTIVE' | 'DISCONTINUED' | 'PENDING'; next_shipment_eta: string | null; warehouse_code: string;} // ════════════════════════════════════════════════// DOMAIN SERVICE uses the abstracted interface// ════════════════════════════════════════════════ // domain/services/OrderFulfillmentService.tsclass OrderFulfillmentService { constructor(private inventory: InventoryChecker) {} async canFulfill(order: Order): Promise<FulfillmentStatus> { for (const item of order.items) { const status = await this.inventory.checkAvailability( item.productId, item.quantity ); if (!status.available) { return FulfillmentStatus.cannotFulfill( item.productId, status.expectedRestockDate ); } } return FulfillmentStatus.canFulfill(); }} // The domain has NO IDEA about:// - The external service's URL structure// - The 'sku' vs 'productId' naming difference// - The 'qty' vs 'quantity' naming difference // - The TTL on reservations// - The warehouse_code field we don't useWell-designed layers with clean abstractions enable powerful testing strategies. Each layer can be tested independently, with outer layers mocked or stubbed.
| Layer | Test Type | Dependencies Mocked | Test Characteristics |
|---|---|---|---|
| Domain | Unit tests | None needed | Fast, pure logic, no I/O |
| Application | Unit/Integration | Repositories, external services | Business logic with mocked infrastructure |
| Adapters | Integration tests | None (test real integration) | Verify translation is correct |
| Full Stack | E2E tests | None (or external services) | Verify entire flow works |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102
// ════════════════════════════════════════════════// DOMAIN LAYER TESTS - Pure unit tests, no mocks needed// ════════════════════════════════════════════════ describe('Order', () => { it('calculates total correctly', () => { const order = Order.create(customerId, [ new OrderItem(productA, Money.usd(10), 2), // $20 new OrderItem(productB, Money.usd(15), 1), // $15 ]); expect(order.calculateTotal()).toEqual(Money.usd(35)); }); it('prevents adding items after submission', () => { const order = Order.create(customerId, items); order.submit(); expect(() => order.addItem(newItem)).toThrow(OrderAlreadySubmittedError); }); // Pure domain logic - fast, reliable, no external dependencies}); // ════════════════════════════════════════════════// APPLICATION LAYER TESTS - Mock driven ports// ════════════════════════════════════════════════ describe('OrderService', () => { let mockOrderRepo: jest.Mocked<OrderRepository>; let mockPayments: jest.Mocked<PaymentGateway>; let orderService: OrderService; beforeEach(() => { mockOrderRepo = { findById: jest.fn(), save: jest.fn(), }; mockPayments = { charge: jest.fn(), }; orderService = new OrderService(mockOrderRepo, mockPayments); }); it('submits order when payment succeeds', async () => { const order = Order.create(customerId, items); mockOrderRepo.findById.mockResolvedValue(order); mockPayments.charge.mockResolvedValue({ success: true }); await orderService.submitOrder(order.id, paymentInfo); expect(mockPayments.charge).toHaveBeenCalledWith( order.calculateTotal(), paymentInfo ); expect(mockOrderRepo.save).toHaveBeenCalled(); expect(order.status).toBe(OrderStatus.SUBMITTED); }); it('does not save order when payment fails', async () => { const order = Order.create(customerId, items); mockOrderRepo.findById.mockResolvedValue(order); mockPayments.charge.mockResolvedValue({ success: false, error: 'Declined' }); await expect(orderService.submitOrder(order.id, paymentInfo)) .rejects.toThrow(PaymentFailedError); expect(mockOrderRepo.save).not.toHaveBeenCalled(); });}); // ════════════════════════════════════════════════// ADAPTER INTEGRATION TESTS - Test real integration// ════════════════════════════════════════════════ describe('PostgresOrderRepository', () => { let db: TestDatabase; let repo: PostgresOrderRepository; beforeAll(async () => { db = await TestDatabase.start(); // Real Postgres in Docker repo = new PostgresOrderRepository(db.connection); }); afterAll(async () => { await db.stop(); }); it('persists and retrieves orders correctly', async () => { const order = Order.create(customerId, items); await repo.save(order); const retrieved = await repo.findById(order.id); expect(retrieved).toEqual(order); }); // Tests actual database integration});Layered architecture naturally supports the testing pyramid: many fast unit tests for domain logic, fewer integration tests for adapters, and minimal E2E tests for critical paths. The abstraction boundaries define exactly where to mock.
Abstraction principles scale from individual classes to entire architectures. Layered design is abstraction applied at the system level. Let's consolidate the key principles:
Module Complete:
You've now mastered the four foundational aspects of designing with abstraction:
These skills form the foundation for building maintainable, evolvable systems. Apply them consistently, and your systems will remain comprehensible and changeable even as they grow in complexity.
Congratulations! You've completed the Designing for Abstraction module. You now understand how to identify boundaries, hide implementations, design stable interfaces, and apply these principles across architectural layers. These skills are fundamental to building systems that stand the test of time.