Loading learning content...
In physical construction, the most critical points in any structure are the joints—the places where different materials or components meet. A building's strength is often determined not by the strength of its individual materials, but by the quality of its joints. The same principle applies to software architecture.
Interfaces are the joints of software systems. They are the boundaries where different components, layers, or modules meet and interact. Place them correctly, and your system becomes modular, testable, and adaptable. Place them incorrectly—or fail to place them at all—and you build a monolith in disguise.
This page explores the art and science of identifying boundaries in your system and using abstractions to mark them explicitly. We'll learn to see architecture as a series of decisions about where to draw lines—and how abstractions make those lines enforceable.
By the end of this page, you will understand how to identify architectural boundaries in your systems, why abstractions should mark these boundaries, and how boundary placement affects testing, deployment, and evolution. You'll learn the different types of boundaries and the specific abstraction strategies appropriate for each.
An architectural boundary is a line of separation between components that have different reasons to change. When we identify a boundary, we're recognizing that two parts of our system:
The Nature of Boundaries:
Boundaries are not arbitrary divisions—they emerge from the fundamental structure of the problem domain and the forces acting on the system. Consider these categories:
Natural Boundaries (Domain-Driven):
Technical Boundaries:
Organizational Boundaries:
| Boundary Type | Separates | Why It Matters | Abstraction Strategy |
|---|---|---|---|
| Presentation/Domain | UI from business logic | UI changes frequently, rules change rarely | Service interfaces, View Models |
| Domain/Infrastructure | Business logic from tech details | Tech stack can change, logic shouldn't | Repository, Gateway interfaces |
| Core/Plugin | Stable core from extensions | Extends without modifying core | Extension points, Strategy interfaces |
| Sync/Async | Real-time from background | Different execution models | Command/Event interfaces |
| Service/Service | Microservices from each other | Independent deployment | API contracts, Event schemas |
Recognition vs. Design:
It's crucial to understand that boundaries are recognized, not invented. They exist in the problem domain; your job is to discover them and make them explicit in your architecture. Inventing artificial boundaries creates unnecessary complexity. Missing natural boundaries creates tight coupling.
The discipline is to place abstractions at boundaries that already exist conceptually—making implicit structure explicit through interfaces.
Every boundary has a cost—increased indirection, more code to maintain, more concepts to understand. This is why we don't place boundaries everywhere. We place them where the cost of coupling exceeds the cost of the boundary. This judgment is central to software architecture.
The most important boundary in most applications is between domain logic and infrastructure. Infrastructure includes:
Why This Boundary Matters:
Domain logic encapsulates the business rules and policies that define what your application does. Infrastructure provides the technical mechanisms for how it does it. These change for completely different reasons:
Without a clean boundary, business rule changes require navigating database code, and database migrations require understanding business rules. The system becomes increasingly difficult to reason about.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
// ============================================// DOMAIN LAYER - Pure business logic// ============================================ // Domain entities - rich with behaviorclass Order { private status: OrderStatus; private items: OrderItem[]; private paymentStatus: PaymentStatus; addItem(product: Product, quantity: number): void { if (this.status !== OrderStatus.Draft) { throw new DomainError('Cannot modify submitted order'); } const existingItem = this.items.find(i => i.productId === product.id); if (existingItem) { existingItem.increaseQuantity(quantity); } else { this.items.push(OrderItem.create(product, quantity)); } } calculateTotal(): Money { return this.items.reduce( (sum, item) => sum.add(item.subtotal()), Money.zero(this.currency) ); } submit(): void { if (this.items.length === 0) { throw new DomainError('Cannot submit empty order'); } this.status = OrderStatus.Submitted; } markAsPaid(paymentReference: PaymentReference): void { if (this.paymentStatus !== PaymentStatus.Pending) { throw new DomainError('Order payment already processed'); } this.paymentStatus = PaymentStatus.Paid; this.paymentReference = paymentReference; }} // BOUNDARY: Interfaces define what the domain NEEDS from infrastructure// These are the "ports" in hexagonal architecture interface OrderRepository { save(order: Order): Promise<void>; findById(id: OrderId): Promise<Order | null>; findByCustomer(customerId: CustomerId): Promise<Order[]>; nextId(): OrderId;} interface PaymentGateway { authorize(amount: Money, method: PaymentMethod): Promise<Authorization>; capture(authorization: Authorization): Promise<PaymentReference>; refund(reference: PaymentReference, amount: Money): Promise<RefundReference>;} interface InventoryChecker { checkAvailability(productId: ProductId, quantity: number): Promise<Availability>; reserve(productId: ProductId, quantity: number): Promise<Reservation>; release(reservation: Reservation): Promise<void>;} // Domain service - coordinates domain objects, uses infrastructure through interfacesclass OrderSubmissionService { constructor( private orderRepo: OrderRepository, private paymentGateway: PaymentGateway, private inventoryChecker: InventoryChecker, ) {} async submitOrder(orderId: OrderId, paymentMethod: PaymentMethod): Promise<void> { const order = await this.orderRepo.findById(orderId); if (!order) throw new OrderNotFoundError(orderId); // Check inventory for (const item of order.items) { const availability = await this.inventoryChecker.checkAvailability( item.productId, item.quantity ); if (!availability.isAvailable) { throw new InsufficientInventoryError(item.productId); } } // Reserve inventory const reservations = await this.reserveAllItems(order); try { // Process payment const authorization = await this.paymentGateway.authorize( order.calculateTotal(), paymentMethod ); const reference = await this.paymentGateway.capture(authorization); // Complete order order.markAsPaid(reference); order.submit(); await this.orderRepo.save(order); } catch (error) { // Compensate on failure await this.releaseReservations(reservations); throw error; } }} // ============================================// INFRASTRUCTURE LAYER - Technical implementations// ============================================ // Implements the repository interface for PostgreSQLclass PostgresOrderRepository implements OrderRepository { constructor(private pool: Pool, private mapper: OrderMapper) {} async save(order: Order): Promise<void> { const dto = this.mapper.toDTO(order); await this.pool.query(` INSERT INTO orders (id, customer_id, status, total, ...) VALUES ($1, $2, $3, $4, ...) ON CONFLICT (id) DO UPDATE SET ... `, [dto.id, dto.customerId, dto.status, ...]); } async findById(id: OrderId): Promise<Order | null> { const result = await this.pool.query( 'SELECT * FROM orders WHERE id = $1', [id.value] ); if (result.rows.length === 0) return null; return this.mapper.toDomain(result.rows[0]); } // Other methods...} // Implements payment gateway for Stripeclass StripePaymentGateway implements PaymentGateway { constructor(private stripe: Stripe) {} async authorize(amount: Money, method: PaymentMethod): Promise<Authorization> { const paymentIntent = await this.stripe.paymentIntents.create({ amount: amount.toCents(), currency: amount.currency.toLowerCase(), payment_method: method.stripeMethodId, confirm: false, }); return new Authorization(paymentIntent.id, paymentIntent.client_secret); } // Other methods...}The Repository pattern is the most common abstraction at the infrastructure boundary. It provides a collection-like interface for domain objects while hiding persistence details. Think of it as a 'collection that knows how to save itself' without exposing SQL, document structure, or any storage mechanism.
The boundary between the presentation layer (UI, API responses) and the domain layer is equally important. This boundary ensures that:
The Presentation/Domain Contract:
At this boundary, we use Application Services (also called Use Cases) as the abstraction. These services define what the application can do from the outside world's perspective.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147
// ============================================// APPLICATION LAYER - Use Cases / Application Services// ============================================ // Input/Output DTOs - cross the boundary safelyinterface CreateOrderCommand { customerId: string; items: Array<{ productId: string; quantity: number; }>; shippingAddress: { street: string; city: string; postalCode: string; country: string; };} interface OrderDTO { id: string; customerId: string; status: string; items: Array<{ productId: string; productName: string; quantity: number; unitPrice: number; subtotal: number; }>; total: number; currency: string; createdAt: string;} // Application Service interface - the USE CASE boundaryinterface OrderService { createOrder(command: CreateOrderCommand): Promise<OrderDTO>; getOrder(orderId: string): Promise<OrderDTO | null>; submitOrder(orderId: string, paymentMethod: PaymentMethodDTO): Promise<OrderDTO>; cancelOrder(orderId: string, reason: string): Promise<void>; listCustomerOrders(customerId: string): Promise<OrderDTO[]>;} // Implementation coordinates domain objects and infrastructureclass OrderServiceImpl implements OrderService { constructor( private orderRepo: OrderRepository, private productRepo: ProductRepository, private orderSubmissionService: OrderSubmissionService, private mapper: OrderDTOMapper, ) {} async createOrder(command: CreateOrderCommand): Promise<OrderDTO> { // Translate from DTO to domain const customerId = new CustomerId(command.customerId); const address = ShippingAddress.create(command.shippingAddress); // Create domain entity const order = Order.create(customerId, address); // Add items for (const item of command.items) { const product = await this.productRepo.findById(new ProductId(item.productId)); if (!product) throw new ProductNotFoundError(item.productId); order.addItem(product, item.quantity); } // Persist await this.orderRepo.save(order); // Translate back to DTO return this.mapper.toDTO(order); } async submitOrder(orderId: string, paymentMethod: PaymentMethodDTO): Promise<OrderDTO> { await this.orderSubmissionService.submitOrder( new OrderId(orderId), PaymentMethod.fromDTO(paymentMethod) ); const order = await this.orderRepo.findById(new OrderId(orderId)); return this.mapper.toDTO(order!); } // Other methods...} // ============================================// PRESENTATION LAYER - HTTP, GraphQL, etc.// ============================================ class OrderController { constructor(private orderService: OrderService) {} async createOrder(req: HttpRequest): Promise<HttpResponse> { // Validate HTTP-specific concerns const validation = this.validateRequest(req); if (!validation.valid) { return HttpResponse.badRequest(validation.errors); } try { // Delegate to application service const command: CreateOrderCommand = { customerId: req.body.customerId, items: req.body.items, shippingAddress: req.body.shippingAddress, }; const order = await this.orderService.createOrder(command); // HTTP-specific response formatting return HttpResponse.created(order, { 'Location': `/api/orders/${order.id}`, }); } catch (error) { return this.handleError(error); } } private handleError(error: unknown): HttpResponse { if (error instanceof ProductNotFoundError) { return HttpResponse.badRequest({ error: 'Product not found' }); } if (error instanceof DomainError) { return HttpResponse.unprocessableEntity({ error: error.message }); } // Log unexpected errors, return generic message console.error(error); return HttpResponse.internalError({ error: 'An unexpected error occurred' }); }} // GraphQL resolver using the same application serviceclass OrderResolver { constructor(private orderService: OrderService) {} async createOrder(_, args: CreateOrderArgs): Promise<OrderDTO> { return this.orderService.createOrder({ customerId: args.input.customerId, items: args.input.items, shippingAddress: args.input.shippingAddress, }); }}Key Design Decisions at This Boundary:
1. Use DTOs, Not Domain Entities:
Never return domain entities from application services. Use Data Transfer Objects (DTOs) specifically designed for the boundary. This prevents:
2. Commands for Writes, Queries for Reads:
Input objects (commands) represent intentions; output objects (DTOs) represent results. This clarity helps when evolving the API independently of the domain.
3. Error Translation:
The presentation layer translates domain exceptions to appropriate HTTP/GraphQL responses. Domain code throws domain exceptions; presentation code interprets them.
4. Validation at Multiple Layers:
Beware of application services that contain business logic instead of coordinating domain objects. If OrderService is checking inventory, calculating totals, and validating business rules directly, you've moved domain logic to the application layer. Keep business rules in domain entities and services; application services only coordinate and translate.
In distributed systems and microservice architectures, services communicate across network boundaries. These boundaries require special attention because:
The Contract at Service Boundaries:
At service boundaries, the abstraction takes the form of a published API contract—a formal specification of how services interact. This is more than an interface; it's a commitment that must be versioned and maintained.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136
// ============================================// DEFINING SERVICE CONTRACTS// ============================================ // Option 1: REST API Contract (OpenAPI/Swagger approach)// Defined in YAML, generates client code // Option 2: TypeScript interface as contract// Shared through a dedicated contract package // @package: order-service-contracts// This package is shared between Order Service and its consumers export interface OrderServiceClient { createOrder(request: CreateOrderRequest): Promise<CreateOrderResponse>; getOrder(orderId: string): Promise<GetOrderResponse>; submitOrder(request: SubmitOrderRequest): Promise<SubmitOrderResponse>; cancelOrder(request: CancelOrderRequest): Promise<void>;} export interface CreateOrderRequest { customerId: string; items: OrderItemRequest[]; shippingAddress: AddressRequest; idempotencyKey: string; // For retry safety} export interface CreateOrderResponse { orderId: string; status: OrderStatusResponse; total: MoneyResponse; createdAt: string;} export type OrderStatusResponse = 'draft' | 'submitted' | 'processing' | 'shipped' | 'delivered' | 'cancelled'; export interface MoneyResponse { amount: string; // String to avoid floating-point issues currency: string;} // Error types are part of the contractexport interface ServiceError { code: string; message: string; details?: Record<string, unknown>;} export class OrderNotFoundError extends Error { readonly code = 'ORDER_NOT_FOUND';} export class InvalidOrderStateError extends Error { readonly code = 'INVALID_ORDER_STATE';} // ============================================// CLIENT IMPLEMENTATION (consumer side)// ============================================ import { OrderServiceClient, CreateOrderRequest, CreateOrderResponse } from '@mycompany/order-service-contracts'; class HttpOrderServiceClient implements OrderServiceClient { constructor( private httpClient: HttpClient, private baseUrl: string, private circuitBreaker: CircuitBreaker, ) {} async createOrder(request: CreateOrderRequest): Promise<CreateOrderResponse> { return this.circuitBreaker.execute(async () => { const response = await this.httpClient.post( `${this.baseUrl}/orders`, { body: request, headers: { 'Idempotency-Key': request.idempotencyKey, 'Content-Type': 'application/json', }, timeout: 5000, } ); if (!response.ok) { throw this.translateError(response); } return response.json() as CreateOrderResponse; }); } private translateError(response: HttpResponse): Error { const error = response.json() as ServiceError; switch (error.code) { case 'ORDER_NOT_FOUND': return new OrderNotFoundError(error.message); case 'INVALID_ORDER_STATE': return new InvalidOrderStateError(error.message); default: return new ServiceError(error.code, error.message); } }} // ============================================// CONSUMING THE SERVICE// ============================================ class ShippingService { constructor(private orderServiceClient: OrderServiceClient) {} async createShipment(orderId: string): Promise<Shipment> { // Uses the interface, not the HTTP implementation const order = await this.orderServiceClient.getOrder(orderId); if (order.status !== 'processing') { throw new Error('Order not ready for shipping'); } // Create shipment based on order data return Shipment.create(order); }} // Testing uses a mock implementationclass MockOrderServiceClient implements OrderServiceClient { private orders: Map<string, CreateOrderResponse> = new Map(); async getOrder(orderId: string): Promise<GetOrderResponse> { const order = this.orders.get(orderId); if (!order) throw new OrderNotFoundError(`Order ${orderId} not found`); return order; } // Other methods...}Service Boundary Best Practices:
Consumer-Driven Contract (CDC) testing inverts traditional API testing. Instead of the provider defining the contract, consumers write tests expressing their expectations. The provider runs all consumer tests to verify compatibility. This ensures that provider changes don't break consumers and that contracts reflect actual usage.
Not all inter-component communication is request-response. Event-driven architectures use events to communicate between services asynchronously. These boundaries require a different abstraction strategy.
Events vs. Requests:
Request interfaces tie components together in time (synchronous coupling). Events allow temporal decoupling—the publisher doesn't wait for consumers, and consumers process at their own pace.
Event Contracts:
Events are published to topics or queues, consumed by multiple subscribers. The contract is the event schema—the structure and meaning of event data.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
// ============================================// EVENT SCHEMA CONTRACT// ============================================ // @package: order-events// Shared event schemas // Base event structureexport interface DomainEvent { eventId: string; eventType: string; aggregateId: string; aggregateType: string; occurredAt: string; version: number; metadata: EventMetadata;} export interface EventMetadata { correlationId: string; causationId: string; userId?: string;} // Specific eventsexport interface OrderCreatedEvent extends DomainEvent { eventType: 'OrderCreated'; aggregateType: 'Order'; payload: { customerId: string; items: Array<{ productId: string; quantity: number; unitPrice: string; }>; shippingAddress: Address; total: Money; };} export interface OrderSubmittedEvent extends DomainEvent { eventType: 'OrderSubmitted'; aggregateType: 'Order'; payload: { paymentReference: string; submittedAt: string; };} export interface OrderShippedEvent extends DomainEvent { eventType: 'OrderShipped'; aggregateType: 'Order'; payload: { shipmentId: string; carrier: string; trackingNumber: string; estimatedDelivery: string; };} // ============================================// PUBLISHER SIDE// ============================================ // Abstraction for publishing eventsinterface EventPublisher { publish<T extends DomainEvent>(event: T): Promise<void>; publishBatch(events: DomainEvent[]): Promise<void>;} // Order aggregate raises eventsclass Order { private domainEvents: DomainEvent[] = []; static create(customerId: CustomerId, address: ShippingAddress): Order { const order = new Order(OrderId.generate(), customerId, address); order.raiseEvent({ eventType: 'OrderCreated', aggregateType: 'Order', aggregateId: order.id.value, eventId: EventId.generate(), occurredAt: new Date().toISOString(), version: 1, metadata: this.currentMetadata(), payload: { customerId: customerId.value, items: [], shippingAddress: address.toDTO(), total: Money.zero('USD').toDTO(), }, }); return order; } submit(): void { this.status = OrderStatus.Submitted; this.raiseEvent({ eventType: 'OrderSubmitted', // ... }); } pullDomainEvents(): DomainEvent[] { const events = [...this.domainEvents]; this.domainEvents = []; return events; } private raiseEvent(event: DomainEvent): void { this.domainEvents.push(event); }} // Application service publishes events after persistenceclass OrderApplicationService { constructor( private orderRepo: OrderRepository, private eventPublisher: EventPublisher, ) {} async submitOrder(orderId: OrderId, paymentMethod: PaymentMethod): Promise<void> { const order = await this.orderRepo.findById(orderId); order.submit(); // Persist and publish atomically (or with outbox pattern) await this.orderRepo.save(order); await this.eventPublisher.publishBatch(order.pullDomainEvents()); }} // ============================================// SUBSCRIBER SIDE// ============================================ // Event handler interfaceinterface EventHandler<T extends DomainEvent> { eventType: string; handle(event: T): Promise<void>;} // Inventory service subscribes to order eventsclass OrderCreatedHandler implements EventHandler<OrderCreatedEvent> { eventType = 'OrderCreated'; constructor(private inventoryService: InventoryReservationService) {} async handle(event: OrderCreatedEvent): Promise<void> { for (const item of event.payload.items) { await this.inventoryService.createTentativeReservation( event.aggregateId, item.productId, item.quantity ); } }} // Shipping service subscribes to submitted ordersclass OrderSubmittedHandler implements EventHandler<OrderSubmittedEvent> { eventType = 'OrderSubmitted'; constructor(private shippingService: ShippingService) {} async handle(event: OrderSubmittedEvent): Promise<void> { await this.shippingService.scheduleShipment(event.aggregateId); }} // Analytics subscribes to all order eventsclass OrderAnalyticsHandler implements EventHandler<DomainEvent> { eventType = '*'; // Wildcard - handles all constructor(private analyticsService: AnalyticsService) {} async handle(event: DomainEvent): Promise<void> { await this.analyticsService.track({ event: event.eventType, properties: event.payload, timestamp: event.occurredAt, }); }}Event Schema Evolution:
Events are particularly challenging for versioning because they're historical records. You can't change old events—they already happened. Strategies include:
OrderCreatedV2 instead of changing OrderCreatedEvents create coupling through shared schemas. If Order Service changes event structure without coordinating with consumers, downstream services break. Use schema registries, contract testing, and careful versioning to manage this coupling.
Knowing that boundaries exist is one thing; identifying where to place them is another skill entirely. Here are practical heuristics for recognizing boundaries in your systems.
Heuristic 1: The Rate of Change Test
Draw boundaries where components change at different rates. If your payment integration changes monthly while order logic changes quarterly, there's a natural boundary between them.
Heuristic 2: The Stakeholder Test
Draw boundaries where different stakeholders have authority. If the marketing team controls promotional rules while engineering controls order processing, these are separate bounded contexts.
Heuristic 3: The Deployment Test
If you wish you could deploy one part without redeploying another, there should be a boundary between them. The desire for independent deployment reveals implicit boundaries.
Heuristic 4: The Testing Test
If testing one component requires elaborate setup of another component, there's a missing boundary. You should be able to test each side of a boundary in isolation.
Heuristic 5: The Replacement Test
If you can imagine replacing one component with a completely different implementation (PostgreSQL → DynamoDB, Stripe → Braintree), there should be a boundary there.
| Signal | What It Indicates | Appropriate Boundary |
|---|---|---|
| Different change frequencies | Technology vs. business | Infrastructure boundary |
| Different teams/owners | Organizational divide | Service/module boundary |
| Different deployment schedules | Release independence needed | Deployable unit boundary |
| Complex test setup | Hidden coupling | Missing abstraction |
| "We might switch from X to Y" | Volatility expected | Adapter boundary |
| Third-party integration | External control | Anti-corruption layer |
| Async processing needed | Temporal decoupling | Event boundary |
Anti-Pattern: Boundaries Everywhere
The opposite mistake is placing boundaries where none are needed. Signs you've over-boundaried:
The Judgment Call:
Boundary placement is a judgment call balancing:
Start with minimal boundaries; add more when the cost of coupling becomes apparent. It's easier to add boundaries than to remove misguided ones.
Boundaries should emerge from real needs, not speculative future requirements. Start with a simple structure. When you feel pain—coupling, testing difficulty, deployment friction—that pain reveals where a boundary is needed. Add the abstraction then, with full understanding of what it's separating.
Boundaries should be visible—not just in code structure, but in documentation and team understanding. Here are ways to make boundaries explicit.
Diagrams That Show Boundaries:
1. The Onion/Clean Architecture Diagram:
Concentric circles showing layers, with dependencies pointing inward:
┌─────────────────────────────────────────┐
│ Frameworks & Drivers │
│ ┌───────────────────────────────────┐ │
│ │ Interface Adapters │ │
│ │ ┌─────────────────────────────┐ │ │
│ │ │ Application Services │ │ │
│ │ │ ┌─────────────────────┐ │ │ │
│ │ │ │ Domain Entities │ │ │ │
│ │ │ └─────────────────────┘ │ │ │
│ │ └─────────────────────────────┘ │ │
│ └───────────────────────────────────┘ │
└─────────────────────────────────────────┘
2. The Component Diagram with Interfaces:
boxes connected by lines, with interface symbols at connection points:
┌──────────────┐ ┌──────────────┐
│ Order │──◯ ─────│ Payment │
│ Processing │ │ Gateway │
└──────────────┘ └──────────────┘
│ ◯
│ (OrderRepository)
▼
┌──────────────┐
│ PostgreSQL │
│ Adapter │
└──────────────┘
The ◯ symbols indicate interface boundaries.
12345678910111213141516171819202122232425262728293031323334353637383940414243
# ADR-0012: Boundary Between Order Processing and Payment ## StatusAccepted ## ContextThe Order Processing module needs to charge customers, but we don't want direct coupling to any specific payment provider (Stripe, PayPal, etc.). We anticipate:- Switching payment providers as negotiating rates- Using different providers for different regions- Testing without real payment transactions ## DecisionWe will introduce a PaymentGateway interface owned by Order Processing.Payment provider integrations will implement this interface as adapters. ### Boundary Definition```Order Processing Module: - OrderProcessor (uses PaymentGateway) - PaymentGateway interface (owned here) Payment Adapters Module: - StripePaymentGateway implements PaymentGateway - BraintreePaymentGateway implements PaymentGateway - MockPaymentGateway implements PaymentGateway``` ### Interface Contract```typescriptinterface PaymentGateway { charge(amount: Money, method: PaymentMethod): Promise<ChargeResult>; refund(chargeId: ChargeId, amount?: Money): Promise<RefundResult>;}``` ## Consequences- Order Processing can be tested with MockPaymentGateway- New payment providers require only new adapters- Payment provider changes don't affect order logic- Small overhead of interface definition and implementation mappingDocumenting Boundaries:
1. Architecture Decision Records (ADRs):
For each significant boundary, create an ADR explaining:
2. README files in boundary directories:
adapters/
payment/
README.md # Explains that this implements PaymentGateway
stripe/
braintree/
3. Dependency Rules Documentation:
Explicitly document what can depend on what:
4. Automated Enforcement:
Use tools to enforce boundaries:
The best boundary documentation is enforced by tooling. If a developer can accidentally create a dependency from domain to infrastructure, your documentation says one thing while the code does another. Invest in automated enforcement so documentation stays synchronized with reality.
Boundaries are the seams that give software architecture its structure. Understanding where they belong and marking them with abstractions is essential to building systems that can evolve. Let's consolidate the key insights:
The Architectural Mindset:
Software architecture is largely about deciding where boundaries should exist and what abstractions should mark them. These decisions determine how independently components can evolve, how easily they can be tested, and how gracefully the system handles change.
What's Next:
We've explored interfaces as abstractions, the principle of consumer ownership, and where boundaries should appear. The final piece of our exploration is the Stable Abstractions Principle—the idea that abstractions should be designed for stability, allowing concrete implementations to change freely. This principle ties together everything we've learned about depending on abstractions.
You now understand how to identify architectural boundaries and mark them with appropriate abstractions. You've seen how different boundary types—infrastructure, presentation, service, and event-based—require different abstraction strategies. Next, we'll explore how to design abstractions that remain stable over time.