Loading learning content...
So far, we've discussed separation of concerns at the code level—how to organize classes, functions, and modules within a codebase. But the same principles apply at architectural scale, guiding how we decompose entire systems into services, how we structure deployment units, and how we draw boundaries between major functional areas.
Architectural separation of concerns determines whether your system can scale, evolve, and be maintained by multiple teams over years. Poor architectural separation leads to distributed monoliths where every change requires coordinating across multiple teams and services. Good architectural separation creates autonomous components that can evolve independently while maintaining system coherence.
This page explores how separation of concerns manifests in system architecture, from layered designs to microservices to domain-driven bounded contexts.
By the end of this page, you will understand how to apply separation of concerns at the architectural level, including layered architectures (hexagonal, clean, onion), vertical slicing, microservices decomposition, bounded contexts from Domain-Driven Design, and the critical distinction between technical and domain boundaries.
At the architectural level, concerns operate at a higher level of abstraction. Instead of thinking about logging and persistence, we think about entire capability areas: user management, order processing, payment handling, notification delivery. Each represents a major functional area that could be developed, deployed, and scaled independently.
Types of Architectural Concerns:
| Category | Description | Examples |
|---|---|---|
| Functional Domains | Core business capability areas | Orders, Payments, Inventory, Users, Shipping, Billing |
| Technical Capabilities | Infrastructure and platform services | API Gateway, Message Broker, Service Discovery, Configuration |
| Quality Attributes | System-wide non-functional requirements | Scalability, Availability, Security, Performance, Observability |
| Operational Concerns | Deployment and runtime management | CI/CD, Monitoring, Logging Aggregation, Alerting |
The Core Tension: Coupling vs Cohesion
Architectural separation must balance two competing forces:
Perfect cohesion leads to a monolith; perfect decoupling leads to inability to communicate. The art of architecture is finding the right boundaries that maximize internal cohesion while minimizing external coupling.
123456789101112131415161718192021222324252627282930313233343536373839
// THE TENSION ILLUSTRATED // Extreme cohesion: Everything in one service// - Maximum internal cohesion// - Simple communication// - Can't scale pieces independently// - Single point of failure// - All changes affect one deployment class MonolithicEcommerceService { handleOrder(); // All in one... handlePayment(); handleShipping(); handleNotifications(); handleReporting(); handleAuthentication(); handleInventory();} // Extreme decoupling: Every function a separate service// - Maximum independence// - Massive operational overhead// - Simple things become complex// - Network calls for everything// - Impossible to maintain consistency // OrderService, OrderItemService, OrderValidationService,// OrderPricingService, OrderPersistenceService, OrderEventService...// (50+ services for what should be one bounded context) // BALANCED APPROACH: Cohesive domains, decoupled boundaries// Each service owns a coherent business capability// Internal cohesion within each service// Clear, minimal contracts between services OrderingService // Everything about ordersPaymentService // Everything about paymentsShippingService // Everything about shippingNotificationService // All notification channelsAs microservices expert Sam Newman advises: 'Get the boundaries wrong, and you're stuck with the costs of a microservice architecture without the benefits.' The key to good architecture is finding natural seams—places where the system naturally divides along low-coupling, high-cohesion lines.
Horizontal layering separates concerns by technical function: presentation, application logic, domain logic, and data access. This classic approach has been the foundation of enterprise software architecture for decades.
The Classic Three-Tier Architecture:
This separates the 'what users see' from 'what the system does' from 'how data is stored.'
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152
// Classic Layered Architecture Structure//// ┌─────────────────────────────────────────────────────────────────┐// │ PRESENTATION LAYER │// │ Controllers, Views, API Endpoints, Request/Response DTOs │// ├─────────────────────────────────────────────────────────────────┤// │ BUSINESS LOGIC LAYER │// │ Services, Domain Objects, Business Rules, Workflows │// ├─────────────────────────────────────────────────────────────────┤// │ DATA ACCESS LAYER │// │ Repositories, ORM, Database Queries, External API Clients │// ├─────────────────────────────────────────────────────────────────┤// │ DATABASE │// └─────────────────────────────────────────────────────────────────┘ // Presentation Layer@Controller('/orders')class OrderController { constructor(private orderService: OrderService) {} @Post('/') async createOrder(@Body() dto: CreateOrderDto): Promise<OrderResponseDto> { const order = await this.orderService.createOrder(dto.toCommand()); return OrderResponseDto.from(order); }} // Business Logic Layerclass OrderService { constructor(private orderRepository: OrderRepository) {} async createOrder(command: CreateOrderCommand): Promise<Order> { // Business rules here const order = new Order(command.items, command.userId); order.calculateTotal(); order.applyDiscounts(); await this.orderRepository.save(order); return order; }} // Data Access Layerclass OrderRepository { constructor(private db: Database) {} async save(order: Order): Promise<void> { await this.db.query( 'INSERT INTO orders (id, user_id, total) VALUES ($1, $2, $3)', [order.id, order.userId, order.total] ); }}Evolution: Clean Architecture / Hexagonal Architecture
Modern approaches refine layered architecture with a critical insight: dependencies should point inward, toward the domain. The domain (business logic) should be at the center, with no dependencies on outer layers. Infrastructure adapts to the domain, not vice versa.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495
// Clean/Hexagonal Architecture Structure//// ┌───────────────────────┐// │ Infrastructure │// │ (Frameworks, I/O) │// │ ┌───────────────────┐ │// │ │ Adapters │ │// │ │ (API, Database) │ │// │ │ ┌───────────────┐ │ │// │ │ │ Application │ │ │// │ │ │ (Use Cases) │ │ │// │ │ │ ┌───────────┐ │ │ │// │ │ │ │ Domain │ │ │ │// │ │ │ │ (Entities)│ │ │ │// │ │ │ └───────────┘ │ │ │// │ │ └───────────────┘ │ │// │ └───────────────────┘ │// └───────────────────────┘//// Key Principle: Dependencies point INWARD// Domain depends on NOTHING// Application depends only on Domain// Adapters depend on Application and Domain// Infrastructure depends on everything // DOMAIN LAYER - Pure business logic, no dependenciesclass Order { private items: OrderItem[] = []; private status: OrderStatus = OrderStatus.Draft; addItem(item: OrderItem): void { // Pure business rule: no infrastructure code if (this.status !== OrderStatus.Draft) { throw new DomainError('Cannot modify confirmed order'); } this.items.push(item); } confirm(): void { if (this.items.length === 0) { throw new DomainError('Cannot confirm empty order'); } this.status = OrderStatus.Confirmed; } get total(): Money { return this.items.reduce( (sum, item) => sum.add(item.subtotal), Money.zero() ); }} // DOMAIN LAYER - Port (interface for infrastructure to implement)interface OrderRepository { save(order: Order): Promise<void>; findById(id: OrderId): Promise<Order | null>;} // APPLICATION LAYER - Use case orchestrationclass CreateOrderUseCase { constructor( private orderRepository: OrderRepository, private eventPublisher: DomainEventPublisher ) {} async execute(command: CreateOrderCommand): Promise<OrderId> { const order = new Order(); command.items.forEach(item => order.addItem(item)); order.confirm(); await this.orderRepository.save(order); await this.eventPublisher.publish(new OrderCreatedEvent(order.id)); return order.id; }} // ADAPTER LAYER - Implements ports for specific technologiesclass PostgresOrderRepository implements OrderRepository { async save(order: Order): Promise<void> { // PostgreSQL-specific implementation }} class HttpOrderController { constructor(private createOrderUseCase: CreateOrderUseCase) {} async handleCreateOrder(req: Request): Promise<Response> { // HTTP-specific concerns const command = CreateOrderCommand.fromRequest(req); const orderId = await this.createOrderUseCase.execute(command); return Response.created({ orderId }); }}The key insight of Clean Architecture is the Dependency Rule: source code dependencies must point only inward, toward higher-level policies. The domain never knows about databases, HTTP, or external services. This makes business logic easily testable and infrastructure replaceable.
Vertical slicing offers an alternative to horizontal layering: instead of organizing by technical layer (controllers, services, repositories), organize by feature or use case. Each vertical slice contains all the layers needed for that specific feature.
Horizontal vs Vertical:
Handler, Command, Validator
Handler, Command, Validator
Handler, Command, Validator
Handler, Command, Validator
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899
// VERTICAL SLICE ARCHITECTURE// Each feature is a self-contained unit with its own request → response flow // src/features/CreateOrder/// ├── CreateOrderCommand.ts// ├── CreateOrderValidator.ts// ├── CreateOrderHandler.ts// ├── CreateOrderEndpoint.ts// └── CreateOrderTests.ts // Command - input data for this specific featureclass CreateOrderCommand { constructor( public readonly userId: string, public readonly items: OrderItemDto[], public readonly shippingAddress: AddressDto ) {}} // Validator - validation specific to this featureclass CreateOrderValidator { validate(command: CreateOrderCommand): ValidationResult { const errors: string[] = []; if (!command.userId) { errors.push('User ID is required'); } if (!command.items || command.items.length === 0) { errors.push('At least one item is required'); } // Feature-specific validation logic return errors.length === 0 ? ValidationResult.success() : ValidationResult.failure(errors); }} // Handler - all the logic for this featureclass CreateOrderHandler { constructor( private db: Database, private eventBus: EventBus ) {} async handle(command: CreateOrderCommand): Promise<CreateOrderResult> { // This handler does everything for this feature: // - Validation (or delegates to validator) // - Business logic // - Persistence // - Events // Direct database access - no repository abstraction needed per-feature const orderId = generateId(); await this.db.orders.insert({ id: orderId, userId: command.userId, items: command.items, status: 'pending' }); await this.eventBus.publish({ type: 'OrderCreated', orderId, userId: command.userId }); return { orderId, status: 'pending' }; }} // Endpoint - HTTP specifics for this feature@Controller('/orders')class CreateOrderEndpoint { constructor( private validator: CreateOrderValidator, private handler: CreateOrderHandler ) {} @Post('/') async execute(@Body() dto: CreateOrderDto): Promise<Response> { const command = dto.toCommand(); const validation = this.validator.validate(command); if (!validation.isValid) { return Response.badRequest(validation.errors); } const result = await this.handler.handle(command); return Response.created(result); }} // Benefits of Vertical Slicing:// 1. All feature code in one place - easy to understand// 2. Changes to one feature don't ripple to others// 3. Easy to delete a feature entirely// 4. Teams can own vertical slices// 5. Less abstraction overhead for simple featuresVertical slicing works well when features are relatively independent and don't share much logic. It's particularly popular in CQRS (Command Query Responsibility Segregation) architectures where commands and queries are treated as independent features. For domains with significant shared behavior, hybrid approaches work better.
Bounded Contexts, from Domain-Driven Design (DDD), provide the most principled approach to architectural separation. A bounded context is a boundary within which a particular domain model applies. Different contexts can have different models for the same real-world concept.
The Core Insight:
In a large system, the term 'Customer' might mean different things to different parts of the business:
Trying to create one unified 'Customer' model that satisfies all contexts leads to a bloated, confused model. Bounded contexts acknowledge that different contexts need different models.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110
// BOUNDED CONTEXTS EXAMPLE: E-Commerce System// Each context has its own model for concepts like "Order" or "Product" // ═══════════════════════════════════════════════════════════════════// ORDERING CONTEXT// Concerned with: order placement, items, totals, status// ═══════════════════════════════════════════════════════════════════ namespace OrderingContext { // Order in this context is rich with business logic class Order { id: OrderId; customerId: CustomerId; // Just an ID - not the full customer items: OrderItem[]; status: OrderStatus; shippingAddress: Address; addItem(product: ProductSnapshot, quantity: number): void; removeItem(itemId: ItemId): void; place(): void; cancel(reason: string): void; } // ProductSnapshot - just what Ordering needs about a product class ProductSnapshot { productId: ProductId; name: string; price: Money; // No inventory info, no supplier info, etc. } // Customer here is minimal - just identification class CustomerId { constructor(public readonly value: string) {} }} // ═══════════════════════════════════════════════════════════════════// CATALOG CONTEXT // Concerned with: products, categories, descriptions, pricing// ═══════════════════════════════════════════════════════════════════ namespace CatalogContext { // Product here is focused on catalog concerns class Product { id: ProductId; name: string; description: string; categories: Category[]; basePrice: Money; images: ProductImage[]; updatePrice(newPrice: Money): void; addToCategory(category: Category): void; // No order-related behavior }} // ═══════════════════════════════════════════════════════════════════// INVENTORY CONTEXT// Concerned with: stock levels, warehouses, reorder points// ═══════════════════════════════════════════════════════════════════ namespace InventoryContext { // Product here is about stock, not descriptions class StockItem { productId: ProductId; warehouseId: WarehouseId; quantityOnHand: number; reorderPoint: number; reorderQuantity: number; reserve(quantity: number): Reservation; replenish(quantity: number): void; transfer(toWarehouse: WarehouseId, quantity: number): void; } // Order is minimal - just what Inventory needs class OrderReference { orderId: OrderId; items: { productId: ProductId; quantity: number }[]; }} // ═══════════════════════════════════════════════════════════════════// SHIPPING CONTEXT// Concerned with: delivery, carriers, tracking// ═══════════════════════════════════════════════════════════════════ namespace ShippingContext { // Order here becomes a Shipment class Shipment { id: ShipmentId; orderId: OrderId; carrier: Carrier; trackingNumber: string; destination: ShippingAddress; status: ShipmentStatus; assignCarrier(carrier: Carrier): void; updateTracking(status: TrackingUpdate): void; markDelivered(): void; }} // Each context:// 1. Has its own model optimized for its concerns// 2. Owns its own data (no shared database)// 3. Communicates via well-defined interfaces// 4. Can be developed/deployed independentlyContext Mapping: How Contexts Communicate
Bounded contexts don't exist in isolation—they must communicate. DDD defines several relationship patterns:
| Pattern | Description | Use When |
|---|---|---|
| Shared Kernel | Contexts share a small common model | Closely collaborating teams, core domain concepts |
| Customer-Supplier | Upstream context serves downstream context's needs | Clear dependency direction, willing collaboration |
| Conformist | Downstream adopts upstream's model as-is | Upstream unwilling to accommodate, low control |
| Anti-Corruption Layer | Downstream translates upstream model | Integrating with legacy or external systems |
| Open Host Service | Upstream provides well-defined public API | Multiple consumers, stable API needed |
| Published Language | Standard exchange format between contexts | Industry standards, wide interoperability |
Without clear bounded context boundaries, systems tend toward a 'Big Ball of Mud'—everything connects to everything, models become inconsistent, and changes propagate unpredictably. Bounded contexts are the primary tool for preventing this architectural decay.
Microservices take architectural separation to its logical conclusion: each concern becomes a separately deployable service with its own codebase, database, and runtime. This provides maximum independence but introduces significant operational complexity.
Microservices as Bounded Context Implementation:
Microservices work best when aligned with bounded contexts. Each microservice implements one bounded context, giving it a coherent purpose and clear boundaries. Services communicate via network protocols (HTTP, gRPC, messaging) rather than function calls.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091
// MICROSERVICES ALIGNED WITH BOUNDED CONTEXTS//// ┌─────────────────────────────────────────────────────────────────┐// │ API Gateway │// │ (Authentication, Routing, Rate Limiting) │// └───────────────────────────┬─────────────────────────────────────┘// │// ┌───────────────────────┼───────────────────────┐// │ │ │// ▼ ▼ ▼// ┌─────────────┐ ┌─────────────┐ ┌─────────────┐// │ Orders │ │ Inventory │ │ Shipping │// │ Service │────▶│ Service │────▶│ Service │// │ │ │ │ │ │// │ PostgreSQL │ │ MongoDB │ │ PostgreSQL │// └─────────────┘ └─────────────┘ └─────────────┘// │ │ │// └───────────────────┴───────────────────┘// │// ┌───────┴───────┐// │ Message Broker │// │ (Events) │// └───────────────┘ // Order Service - owns everything about orders// src/order-service/class OrderService { // Own database - no shared state private database: OrderDatabase; async createOrder(command: CreateOrderCommand): Promise<Order> { // Check inventory via service call const available = await this.inventoryClient.checkAvailability( command.items.map(i => i.productId) ); if (!available) { throw new InsufficientInventoryError(); } const order = await this.orderRepository.save( Order.create(command) ); // Publish event for other services await this.eventPublisher.publish( new OrderCreatedEvent(order) ); return order; }} // Inventory Service - owns everything about stock// src/inventory-service/class InventoryService { // Subscribe to order events @EventHandler(OrderCreatedEvent) async onOrderCreated(event: OrderCreatedEvent): Promise<void> { // Reserve inventory when order is created for (const item of event.items) { await this.stockRepository.reserve( item.productId, item.quantity, event.orderId ); } } @EventHandler(OrderCancelledEvent) async onOrderCancelled(event: OrderCancelledEvent): Promise<void> { // Release reserved inventory await this.stockRepository.release(event.orderId); }} // Shipping Service - owns delivery concerns// src/shipping-service/class ShippingService { @EventHandler(OrderCreatedEvent) async onOrderCreated(event: OrderCreatedEvent): Promise<void> { // Create shipment for new order const shipment = Shipment.create({ orderId: event.orderId, destination: event.shippingAddress, items: event.items }); await this.shipmentRepository.save(shipment); }}When Microservices Make Sense:
Microservices aren't always the right choice. They introduce network complexity, distributed transactions, eventual consistency, and operational overhead. Consider microservices when:
Many expert practitioners recommend starting with a 'modular monolith'—a monolithic deployment with well-separated internal modules aligned with bounded contexts. This provides architectural clarity with simpler operations. Microservices can be extracted later when specific modules need independent scaling or deployment.
A critical architectural decision is whether to draw boundaries along technical lines or domain lines. This choice fundamentally shapes how the system evolves.
Technical Decomposition:
Separates by technical responsibility—frontend, backend, database layer, API layer. Traditional enterprise architectures often follow this pattern.
1234567891011121314151617181920212223242526272829303132
// TECHNICAL DECOMPOSITION// Services organized by technical layer//// ┌──────────────────────────────────────────────────────────────┐// │ Frontend Service │// │ React App (all UI for all features) │// └──────────────────────────────────────────────────────────────┘// │// ┌──────────────────────────────────────────────────────────────┐// │ API Gateway │// └──────────────────────────────────────────────────────────────┘// │// ┌──────────────────────────────────────────────────────────────┐// │ Backend Services (BFF) │// │ All business logic for all domains │// └──────────────────────────────────────────────────────────────┘// │// ┌──────────────────────────────────────────────────────────────┐// │ Data Services Layer │// │ All data access for all domains │// └──────────────────────────────────────────────────────────────┘// │// ┌──────────────────────────────────────────────────────────────┐// │ Database │// │ Shared database with all tables │// └──────────────────────────────────────────────────────────────┘ // Problems with Technical Decomposition:// 1. Feature changes require coordinated changes across layers// 2. Teams organized by layer rarely deliver features independently// 3. Shared database creates coupling between domains// 4. Business knowledge scattered across technical layersDomain Decomposition:
Separates by business capability—each component owns its slice of every technical layer from UI to database.
1234567891011121314151617181920212223242526272829303132
// DOMAIN DECOMPOSITION// Services organized by business capability//// ┌────────────────┐ ┌────────────────┐ ┌────────────────┐// │ Order Mgmt │ │ Inventory │ │ Shipping │// │ │ │ │ │ │// │ ┌──────────┐ │ │ ┌──────────┐ │ │ ┌──────────┐ │// │ │ Order UI │ │ │ │ Inv UI │ │ │ │ Ship UI │ │// │ └──────────┘ │ │ └──────────┘ │ │ └──────────┘ │// │ ┌──────────┐ │ │ ┌──────────┐ │ │ ┌──────────┐ │// │ │Order API │ │ │ │ Inv API │ │ │ │ Ship API │ │// │ └──────────┘ │ │ └──────────┘ │ │ └──────────┘ │// │ ┌──────────┐ │ │ ┌──────────┐ │ │ ┌──────────┐ │// │ │Order Svc │ │ │ │ Inv Svc │ │ │ │ Ship Svc │ │// │ └──────────┘ │ │ └──────────┘ │ │ └──────────┘ │// │ ┌──────────┐ │ │ ┌──────────┐ │ │ ┌──────────┐ │// │ │Order DB │ │ │ │ Inv DB │ │ │ │ Ship DB │ │// │ └──────────┘ │ │ └──────────┘ │ │ └──────────┘ │// └────────────────┘ └────────────────┘ └────────────────┘ // Benefits of Domain Decomposition:// 1. Features delivered within a single vertical// 2. Teams own complete business capabilities// 3. Each domain has its own isolated data// 4. Business knowledge concentrated within domain // Example: Adding a new Order feature// - BEFORE (technical): Change Frontend, then API, then Backend, then Data// Requires 4 teams, 4 deployments, coordination meetings//// - AFTER (domain): Change Order Management vertically// One team, one deployment, autonomyConway's Law states that system designs mirror communication structures. Technical decomposition often reflects historically siloed teams (frontend team, backend team). Domain decomposition enables cross-functional feature teams. Choose the architecture that enables the team structure you want.
Applying separation of concerns at the architectural level requires balancing theoretical purity with practical constraints. Here are guidelines that help navigate real-world architectural decisions:
123456789101112131415161718192021222324252627282930313233343536373839404142434445
// ARCHITECTURAL BOUNDARY CHECKLIST// Ask these questions when evaluating if a boundary makes sense: interface BoundaryEvaluation { // COUPLING ASSESSMENT dataSharing: "none" | "minimal-ids" | "shared-tables"; // Prefer "minimal-ids" syncCalls: "none" | "rare" | "frequent"; // Prefer "rare" sharedCode: "none" | "utilities-only" | "domain-models"; // Prefer "utilities-only" // COHESION ASSESSMENT teamOwnership: "single-team" | "shared" | "unclear"; // Prefer "single-team" deploymentCadence: "independent" | "coordinated"; // Prefer "independent" changeCorrelation: "unrelated" | "sometimes" | "always"; // Prefer "unrelated" // BUSINESS ALIGNMENT businessCapability: string; // Should be a clear, nameable capability ubiquitousLanguage: boolean; // Does this boundary align with business language? // RED FLAGS distributedTransactions: boolean; // RED FLAG if true circularDependencies: boolean; // RED FLAG if true godService: boolean; // RED FLAG if one service knows about everyone} function evaluateBoundary(evaluation: BoundaryEvaluation): string { if (evaluation.distributedTransactions || evaluation.circularDependencies || evaluation.godService) { return "RECONSIDER - Fundamental boundary problems"; } if (evaluation.dataSharing === "shared-tables" || evaluation.syncCalls === "frequent" || evaluation.sharedCode === "domain-models") { return "CAUTION - High coupling detected"; } if (evaluation.teamOwnership === "single-team" && evaluation.deploymentCadence === "independent" && evaluation.ubiquitousLanguage) { return "GOOD - Well-aligned boundary"; } return "REVIEW - Mixed signals, needs deeper analysis";}The worst outcome is a 'distributed monolith'—microservices that must be deployed together, share databases, or require synchronized changes. You get all the complexity of distribution with none of the benefits. If you find yourself in this situation, consider consolidating back to a monolith or re-drawing boundaries entirely.
Architectural separation of concerns extends the same principles we apply in code to the system level. Let's consolidate what we've learned:
Completing the Module:
You've now learned separation of concerns from code to architecture. From identifying distinct concerns within a function, to isolating them into classes and modules, to handling cross-cutting aspects, and finally organizing systems into bounded, decoupled components. This principle—as Dijkstra envisioned—remains the only available technique for effective ordering of thoughts about complex systems.
You now understand separation of concerns at every level: identification, isolation, cross-cutting management, and architectural organization. This foundational principle—the ability to focus on one aspect at a time while maintaining system coherence—underlies virtually every other design principle and pattern. Apply it thoughtfully, and your systems will be understandable, maintainable, and evolvable for years to come.