Loading learning content...
While every system is unique, certain responsibility boundaries appear consistently across domains. Experienced engineers develop an intuition for these natural splits—an intuition that takes years to build through trial and error.
This page accelerates that learning by cataloging the most common responsibility boundaries. These aren't rigid rules but proven patterns: ways that responsibilities naturally divide in well-designed systems. Use them as a starting point, adapting to your specific context.
Think of these as a vocabulary for discussing responsibility. When you can say "that class mixes presentation with domain logic" and have your team immediately understand the problem, you're speaking this vocabulary.
By the end of this page, you will have a practical catalog of common responsibility divisions: business logic vs. infrastructure, commands vs. queries, coordination vs. execution, and more. You'll be able to quickly identify where responsibilities should be separated in your own systems.
Perhaps the most fundamental responsibility boundary is between business logic (domain rules) and infrastructure (technical mechanisms). This division appears in virtually every architecture: Clean Architecture, Hexagonal Architecture, Onion Architecture—all emphasize separating what the system does from how it does it technically.
Business Logic (Domain Layer):
Infrastructure (Outer Layers):
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
// MIXED RESPONSIBILITIES (Bad)class OrderService { async createOrder(orderData: CreateOrderDTO): Promise<Order> { // Business logic: validation if (orderData.items.length === 0) { throw new Error('Order must have at least one item'); } // Business logic: calculate pricing let total = 0; for (const item of orderData.items) { total += item.price * item.quantity; } if (total > 1000) total *= 0.95; // 5% discount over $1000 // Infrastructure: database access (mixed in!) const order = await prisma.order.create({ data: { customerId: orderData.customerId, total, items: { create: orderData.items }, status: 'PENDING', }, include: { items: true }, }); // Infrastructure: send notification (mixed in!) await fetch('https://notifications.api.com/send', { method: 'POST', body: JSON.stringify({ type: 'ORDER_CREATED', orderId: order.id }), }); return order; }} // SEPARATED RESPONSIBILITIES (Good) // Domain layer: pure business logic, no infrastructureclass Order { private items: OrderItem[]; private total: Money; private status: OrderStatus; static create(items: OrderItem[]): Order { if (items.length === 0) { throw new DomainError('Order must have at least one item'); } const order = new Order(); order.items = items; order.total = order.calculateTotal(items); order.status = OrderStatus.PENDING; return order; } private calculateTotal(items: OrderItem[]): Money { let total = items.reduce((sum, item) => sum.add(item.price.multiply(item.quantity)), Money.zero() ); if (total.greaterThan(Money.of(1000))) { total = total.multiply(0.95); // 5% discount } return total; }} // Infrastructure layer: persistence responsibilityclass OrderRepository { async save(order: Order): Promise<void> { await this.prisma.order.create({ data: order.toPersistence(), }); }} // Infrastructure layer: notification responsibility class OrderNotificationService { async notifyCreated(orderId: string): Promise<void> { await this.httpClient.post('https://notifications.api.com/send', { type: 'ORDER_CREATED', orderId, }); }} // Application layer: orchestration (thin, no business logic)class CreateOrderUseCase { constructor( private orderRepo: OrderRepository, private notifier: OrderNotificationService, ) {} async execute(input: CreateOrderInput): Promise<CreateOrderOutput> { const order = Order.create(input.items); // Domain await this.orderRepo.save(order); // Infrastructure await this.notifier.notifyCreated(order.id); // Infrastructure return { orderId: order.id }; }}Business logic should never depend on infrastructure. Dependencies point inward: infrastructure → application → domain. This keeps the domain portable and testable. Infrastructure adapts to the domain's needs through interfaces (ports and adapters).
Command-Query Responsibility Segregation (CQRS) separates operations that modify state (commands) from operations that read state (queries). This division often aligns with different actors: those who change the system vs. those who report on it.
Commands:
Queries:
| Aspect | Commands | Queries |
|---|---|---|
| Purpose | Change state | Read state |
| Returns | Minimal (ID, success) | Rich (data, aggregates) |
| Validation | Full business rules | Query parameters only |
| Consistency | Strong (transactional) | Can be eventual |
| Caching | Invalidates caches | Uses caches heavily |
| Actor | Operators, systems | Users, reports, dashboards |
| Rate | Lower volume, higher impact | Higher volume, read-only |
12345678910111213141516171819202122232425262728293031323334353637383940414243
// COMMAND: Modifies state, minimal returnclass CreateOrderCommand { constructor( public readonly customerId: string, public readonly items: OrderItemInput[], ) {}} class CreateOrderHandler { async execute(command: CreateOrderCommand): Promise<{ orderId: string }> { // Full validation, business rules, transaction const order = Order.create(command.customerId, command.items); await this.orderRepo.save(order); await this.eventBus.publish(new OrderCreatedEvent(order)); return { orderId: order.id }; }} // QUERY: Reads state, rich return, optimized for displayclass GetOrderDetailsQuery { constructor(public readonly orderId: string) {}} class GetOrderDetailsHandler { async execute(query: GetOrderDetailsQuery): Promise<OrderDetailsView> { // May use read-optimized model, caching, denormalized data const view = await this.orderReadModel.getDetails(query.orderId); return view; }} interface OrderDetailsView { orderId: string; customerName: string; // Denormalized from customer table items: Array<{ productName: string; // Denormalized from product table quantity: number; price: string; // Formatted for display }>; totalFormatted: string; statusLabel: string; estimatedDelivery: string;}CQRS isn't needed for every system. Apply it when: (1) read and write patterns differ significantly, (2) different teams own reads vs. writes, (3) scaling reads independently from writes is valuable, or (4) query optimization requires different data models.
This boundary separates what happens from how to make it happen. Coordinators (orchestrators) manage workflow and sequencing. Executors perform specific tasks within that workflow.
Coordinators:
Executors:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374
// COORDINATOR: Knows the workflow, delegates executionclass ProcessPaymentUseCase { constructor( private fraudChecker: FraudChecker, private paymentGateway: PaymentGateway, private orderRepo: OrderRepository, private notifier: NotificationService, private auditLog: AuditLogger, ) {} async execute(orderId: string): Promise<PaymentResult> { // Step 1: Load order const order = await this.orderRepo.findById(orderId); // Step 2: Check for fraud const fraudResult = await this.fraudChecker.check(order); if (fraudResult.flagged) { await this.auditLog.log('FRAUD_FLAGGED', order); return PaymentResult.declined('Flagged for review'); } // Step 3: Process payment const paymentResult = await this.paymentGateway.charge( order.paymentMethod, order.total, ); // Step 4: Update order based on outcome if (paymentResult.success) { order.markPaid(); await this.orderRepo.save(order); await this.notifier.sendReceipt(order); await this.auditLog.log('PAYMENT_SUCCESS', order); } else { await this.auditLog.log('PAYMENT_FAILED', order, paymentResult.error); } return paymentResult; }} // EXECUTORS: Know how to perform specific tasks // Executor: Fraud detectionclass FraudChecker { async check(order: Order): Promise<FraudCheckResult> { // Complex fraud detection logic const signals = await this.gatherRiskSignals(order); const score = this.calculateRiskScore(signals); return { flagged: score > THRESHOLD, score, signals }; }} // Executor: Payment processingclass PaymentGateway { async charge(method: PaymentMethod, amount: Money): Promise<ChargeResult> { // Payment provider integration const response = await this.stripe.charges.create({ amount: amount.cents, currency: amount.currency, source: method.token, }); return ChargeResult.fromStripeResponse(response); }} // Executor: Notificationsclass NotificationService { async sendReceipt(order: Order): Promise<void> { const template = await this.templates.load('receipt'); const html = template.render(order.toReceiptData()); await this.emailClient.send(order.customerEmail, 'Receipt', html); }}The key insight: The coordinator's responsibility is knowing what to do. Each executor's responsibility is knowing how to do one thing well. Neither should know the other's job.
This separation enables:
Should data and behavior live together (Rich Domain Model) or separately (Anemic Domain Model + Services)? This is one of the most debated responsibility boundaries. The answer depends on context.
Rich Domain Model:
Anemic Domain + Services:
123456789101112131415161718192021222324252627
// Rich Domain Modelclass BankAccount { private balance: Money; private status: AccountStatus; private overdraftLimit: Money; withdraw(amount: Money): void { // Behavior and data together // Class protects its invariants if (this.status !== 'ACTIVE') { throw new AccountNotActiveError(); } if (amount.greaterThan( this.balance.add(this.overdraftLimit) )) { throw new InsufficientFundsError(); } this.balance = this.balance.subtract(amount); } deposit(amount: Money): void { if (this.status === 'CLOSED') { throw new AccountClosedError(); } this.balance = this.balance.add(amount); }}123456789101112131415161718192021222324252627282930
// Anemic Domain + Serviceclass BankAccount { balance: Money; status: AccountStatus; overdraftLimit: Money; // Just data, no behavior} class BankAccountService { withdraw(account: BankAccount, amount: Money) { // All behavior in service // Data exposed, invariants not protected if (account.status !== 'ACTIVE') { throw new AccountNotActiveError(); } if (amount.greaterThan( account.balance.add(account.overdraftLimit) )) { throw new InsufficientFundsError(); } account.balance = account.balance.subtract(amount); } deposit(account: BankAccount, amount: Money) { if (account.status === 'CLOSED') { throw new AccountClosedError(); } account.balance = account.balance.add(amount); }}Martin Fowler considers anemic domain models an anti-pattern because they abandon the core benefits of OOP: data and behavior together, protected invariants, encapsulation. However, in CRUD-heavy systems with little complex domain logic, the overhead of a rich model may not be justified.
When to use Rich Domain:
When to use Anemic + Services:
Cross-cutting concerns are responsibilities that apply across many parts of a system: logging, security, transactions, caching, monitoring. They don't fit neatly into a single module but affect many modules.
Core Business Logic:
Cross-Cutting Concerns:
| Concern | Actor/Owner | Typical Implementation |
|---|---|---|
| Logging | SRE/Operations | Interceptors, decorators, middleware |
| Authentication | Security | Guards, middleware, annotations |
| Authorization | Security | Policies, decorators, interceptors |
| Transactions | Engineering/DBA | Unit of work, decorators, AOP |
| Caching | Performance Team | Cache-aside, read-through proxies |
| Metrics/Monitoring | SRE | Interceptors, agents, sidecars |
| Rate Limiting | Platform | Middleware, API gateways |
| Error Handling | Engineering | Exception filters, boundaries |
123456789101112131415161718192021222324252627282930313233343536373839404142434445
// BAD: Cross-cutting concerns mixed with business logicclass OrderService { async createOrder(orderData: CreateOrderDTO): Promise<Order> { console.log('Creating order', { customerId: orderData.customerId }); // Logging const startTime = Date.now(); // Metrics // Check auth (cross-cutting) if (!this.authService.hasPermission('orders:create')) { throw new UnauthorizedError(); } // Actual business logic buried in cross-cutting concerns const order = Order.create(orderData); try { await this.database.beginTransaction(); // Transaction await this.orderRepo.save(order); await this.database.commit(); } catch (e) { await this.database.rollback(); throw e; } // More cross-cutting this.metrics.recordDuration('order_creation', Date.now() - startTime); console.log('Order created', { orderId: order.id }); return order; }} // GOOD: Cross-cutting concerns separated using decoratorsclass CreateOrderHandler { @Logged() // Logging as decorator @Authorized('orders:create') // Auth as decorator @Transactional() // Transaction as decorator @Metered('order_creation') // Metrics as decorator async execute(command: CreateOrderCommand): Promise<Order> { // Pure business logic - clean and focused const order = Order.create(command.orderData); await this.orderRepo.save(order); return order; }}Common techniques for separating cross-cutting concerns: (1) Decorators/annotations, (2) Middleware/interceptors, (3) Aspect-Oriented Programming (AOP), (4) Proxy patterns, (5) Dependency injection of policy objects. Choose based on your framework's support and team familiarity.
Beyond the major categories, several other responsibility boundary patterns appear frequently:
Applying these patterns:
You won't use all these boundaries in every system. The appropriate divisions depend on your domain complexity, team structure, and change patterns. But knowing this vocabulary helps you:
Diagnose problems — When a class feels wrong, you can check: "Is this mixing policy with mechanism? Command with query?"
Communicate solutions — Propose refactorings using shared vocabulary: "Let's extract the validation responsibility into a separate validator."
Anticipate changes — When you know that reporting changes frequently but core logic is stable, you naturally separate them.
Common responsibility boundaries provide patterns for dividing code. Let's consolidate the catalog:
Module Conclusion:
With this page, you've completed the exploration of defining responsibility. You now understand:
This knowledge transforms SRP from a vague principle into a practical, actionable guide for designing maintainable software.
You now have a comprehensive understanding of what 'responsibility' means in the Single Responsibility Principle. You can identify actors, measure cohesion, and recognize common responsibility divides. This foundation prepares you for the next module: identifying and fixing SRP violations in real codebases.