Loading content...
You've learned to break systems into pieces—but decomposition alone doesn't guarantee good design. A poorly decomposed system can be just as unmaintainable as a monolith. The difference lies in responsibilities and boundaries: understanding exactly what each component should do and where its authority begins and ends.
The greatest software architects share a common skill: they can look at a complex system and see clean lines of responsibility. They know intuitively which component should handle authentication, which should manage user data, and why those concerns must never be mixed. This isn't magic—it's a learned discipline rooted in fundamental principles.
This page equips you with those principles. By the end, you'll understand how to identify responsibilities, assign them correctly, and establish boundaries that keep components focused, cohesive, and maintainable.
By the end of this page, you will master the Single Responsibility Principle (SRP), understand cohesion and coupling as measurable design qualities, learn techniques for identifying and assigning responsibilities, and recognize boundary violations before they cause damage.
The Single Responsibility Principle is often stated as:
A class should have only one reason to change.
This formulation, from Robert C. Martin, sounds simple but has profound implications. The 'reason to change' refers to an actor—a person, role, or stakeholder whose requirements might evolve. If a class changes when marketing wants new reports AND when operations wants better performance AND when compliance wants new auditing, that class serves three masters and violates SRP.
Why 'Reason to Change' Matters:
Consider an Employee class that handles:
These are three different stakeholders with different timelines, different requirements, and different change patterns. When HR changes the bonus calculation, you risk breaking the reporting logic or the database code—not because they're related, but because they're inappropriately bundled.
12345678910111213141516171819202122232425262728293031323334
// VIOLATION: Employee class has three reasons to changeclass Employee { private name: string; private salary: number; private hoursWorked: number; // Reason 1: HR changes pay calculation rules calculatePay(): number { return this.salary + this.calculateOvertime(); } private calculateOvertime(): number { const overtimeHours = Math.max(0, this.hoursWorked - 40); return overtimeHours * (this.salary / 40) * 1.5; } // Reason 2: Management changes report format/content generateReport(): string { return ` Employee Report Name: ${this.name} Hours Worked: ${this.hoursWorked} Total Pay: ${this.calculatePay()} `; } // Reason 3: IT changes database schema/ORM saveToDatabase(db: Database): void { db.execute( 'INSERT INTO employees (name, salary, hours) VALUES (?, ?, ?)', [this.name, this.salary, this.hoursWorked] ); }}1234567891011121314151617181920212223242526272829303132333435363738394041
// SOLUTION: Separate classes for separate responsibilities // Pure domain entity - changes only when the concept of Employee changesclass Employee { constructor( public readonly id: string, public name: string, public hourlyRate: number, public hoursWorked: number ) {}} // Changes when pay calculation rules change (HR stakeholder)class PayrollCalculator { calculate(employee: Employee): PayStatement { const regularPay = Math.min(employee.hoursWorked, 40) * employee.hourlyRate; const overtimePay = Math.max(0, employee.hoursWorked - 40) * employee.hourlyRate * 1.5; return new PayStatement(employee.id, regularPay, overtimePay); }} // Changes when report requirements change (Management stakeholder)class EmployeeReporter { generateReport(employee: Employee, payStatement: PayStatement): string { return `Employee: ${employee.name}, Total Pay: ${payStatement.total}`; }} // Changes when persistence strategy changes (IT stakeholder)class EmployeeRepository { constructor(private db: Database) {} save(employee: Employee): void { this.db.upsert('employees', { id: employee.id, name: employee.name, hourlyRate: employee.hourlyRate, hoursWorked: employee.hoursWorked }); }}To check for SRP violations, ask: 'Who would request changes to this class?' List the actors (HR, marketing, operations, customers, etc.). If you list more than one actor, consider whether those responsibilities should be separate components. Not all separations are necessary—but you should consciously decide, not accidentally mix.
Cohesion measures how strongly related the elements within a component are. High cohesion means every part of the component works toward a single, focused purpose. Low cohesion means the component is a grab-bag of loosely related or unrelated functionality.
The Cohesion Spectrum:
Cohesion isn't binary—it exists on a spectrum from worst to best:
| Type | Description | Example |
|---|---|---|
| Coincidental (Worst) | Elements grouped arbitrarily, no relationship | Utils class with unrelated methods |
| Logical | Elements grouped by category, but different functions | InputHandler doing keyboard, mouse, and touchscreen |
| Temporal | Elements grouped because they run at the same time | StartupInitializer doing logging, DB, and cache setup |
| Procedural | Elements grouped because they follow a sequence | CheckoutProcessor with steps that should be separate |
| Communicational | Elements work on the same data | CustomerProcessor reading and writing customer records |
| Sequential | Output of one element is input to another | DataPipeline where each step transforms data |
| Functional (Best) | All elements contribute to a single, well-defined task | PasswordHasher that only hashes passwords |
Functional Cohesion: The Goal
The ideal is functional cohesion—every method, every field, every line of code in the component contributes directly to its single purpose. Nothing is incidental.
Example of High Functional Cohesion:
A JwtTokenValidator class that:
Every method directly supports the single purpose: validating JWT tokens. Nothing else.
Example of Low Cohesion (Coincidental):
A Helpers class containing:
formatDate() for UI displaycalculateDistance() for geo calculationssendEmail() for notificationscompressImage() for media processingThese have no relationship other than 'someone needed them.' This is a red flag indicating responsibilities scattered inappropriately.
When you see classes named Utils, Helpers, Common, or Misc, it's almost always a cohesion problem. These classes become dumping grounds for homeless functionality. Instead, find where each method truly belongs—or create a properly named class that owns its responsibility.
Measuring Cohesion: LCOM
Lack of Cohesion of Methods (LCOM) is a metric that counts how many method pairs in a class share no instance variables. High LCOM indicates low cohesion.
While formal metrics are useful for analyzing existing code, the more practical approach is to ask yourself:
If the answer to all three is yes, you likely have high cohesion.
If cohesion is about internal unity, coupling is about external dependencies. Coupling measures how strongly components are connected—how much one component must know about another to function.
The Coupling Spectrum:
Like cohesion, coupling exists on a spectrum:
| Type | Description | Why It's Problematic |
|---|---|---|
| Content (Worst) | One component directly modifies another's internals | Changes anywhere break everything |
| Common | Components share global state | Race conditions, hidden dependencies |
| External | Components share external format/protocol | External changes ripple widely |
| Control | One component tells another how to behave via flags | Breaks encapsulation, couples logic |
| Stamp/Data-Structure | Components share composite data structures | Structure changes affect all users |
| Data (Best) | Components share only primitive data via parameters | Minimal assumptions, maximum flexibility |
| Message (Best) | Components interact only via messages | Complete decoupling, async-friendly |
Loose Coupling: The Goal
Ideal systems have loose coupling—components interact through well-defined interfaces, know nothing about each other's internals, and can be modified independently.
Benefits of Loose Coupling:
123456789101112131415161718192021222324252627282930313233343536373839404142
// TIGHT COUPLING: OrderService depends on concrete EmailServiceclass OrderService { private emailService: EmailService; constructor() { // Direct instantiation creates tight coupling this.emailService = new EmailService('smtp.example.com', 587, 'user', 'pass'); } processOrder(order: Order): void { // Business logic... // Calling with specific EmailService assumptions this.emailService.connect(); // You shouldn't know this method exists this.emailService.sendHtml(order.customer.email, 'Order Confirmed', '...'); this.emailService.disconnect(); // Managing connection state externally }} // LOOSE COUPLING: OrderService depends on abstractioninterface NotificationSender { send(recipient: string, message: NotificationMessage): Promise<void>;} class OrderService { constructor(private notifier: NotificationSender) {} async processOrder(order: Order): Promise<void> { // Business logic... // Simple, abstracted call - doesn't know if it's email, SMS, or push await this.notifier.send( order.customer.id, new OrderConfirmation(order) ); }} // Now we can inject any implementation:// new OrderService(new EmailNotifier(config))// new OrderService(new SmsNotifier(config))// new OrderService(new MockNotifier()) // For testingZero coupling is impossible—components must interact somehow. The goal isn't eliminating coupling but minimizing unnecessary coupling and ensuring remaining connections are through stable, abstract interfaces rather than volatile implementation details.
Given a set of requirements, how do you decide which class handles what? Several established techniques help you systematically assign responsibilities.
Technique 1: GRASP Principles
General Responsibility Assignment Software Patterns (GRASP) provide nine principles for assigning responsibilities. The most foundational are:
Order contains line items, Order should calculate the total.Order creates LineItem; Customer creates Order.CheckoutController coordinates checkout, but doesn't do checkout logic itself.OrderRepository isn't a real domain thing but provides a necessary abstraction.PaymentGateway interface decouples business logic from specific payment providers.Technique 2: The Information Expert Pattern
This is often the most useful starting point. Ask: 'What information is needed to perform this responsibility?' Assign the responsibility to the class that holds that information.
Example: Who calculates order total?
To calculate total, you need:
The Order class already contains the line items. Making it the information expert for totaling makes sense—it has the data and should have the method.
1234567891011121314151617181920212223242526272829303132333435363738
// Information Expert: Order calculates its own totalclass Order { private lineItems: LineItem[] = []; private discount?: Discount; addLineItem(product: Product, quantity: number): void { this.lineItems.push(new LineItem(product, quantity)); } applyDiscount(discount: Discount): void { this.discount = discount; } // Order is the Information Expert - it has the data needed calculateTotal(): Money { const subtotal = this.lineItems.reduce( (sum, item) => sum.add(item.calculateSubtotal()), Money.zero() ); if (this.discount) { return this.discount.apply(subtotal); } return subtotal; }} class LineItem { constructor( private product: Product, private quantity: number ) {} // LineItem is Information Expert for its own subtotal calculateSubtotal(): Money { return this.product.price.multiply(this.quantity); }}Technique 3: CRC Cards
Class-Responsibility-Collaboration (CRC) cards are a physical or conceptual tool for responsibility assignment. For each class:
Walking through scenarios with CRC cards helps identify missing classes, misplaced responsibilities, and excessive coupling.
| Class: ShoppingCart | |
|---|---|
| Responsibilities | Collaborators |
| Maintain list of items | CartItem |
| Calculate subtotal | CartItem |
| Apply discount codes | DiscountService |
| Check product availability | InventoryService |
| Track last modified time | (internal) |
After creating initial CRC cards, simulate user scenarios: 'A user adds an item to cart—which class handles it? What collaborations occur?' If the flow feels awkward or requires many collaborators for simple operations, reconsider your responsibility assignments.
Assigning responsibilities is only half the equation. You must also establish boundaries—clear lines that separate components and define how they interact. Boundaries prevent responsibilities from leaking, coupling from creeping, and complexity from spreading.
Types of Boundaries:
| Boundary Type | Mechanism | When to Use |
|---|---|---|
| Package/Module | Directory structure, namespace | Group related classes within a bounded context |
| Interface | Abstract contract | Decouple consumers from implementations |
| Access Modifier | Public/private/protected | Hide internals within a class |
| Service | Network/IPC separation | When deployment independence is needed |
| Database Schema | Separate tables/databases | When data ownership must be explicit |
The Law of Demeter (LoD)
Also known as the 'principle of least knowledge,' the Law of Demeter states:
A method should only call methods on:
- Its own object (
this)- Objects passed as parameters
- Objects it creates
- Its direct component objects (fields)
Violating LoD creates hidden dependencies through 'train wrecks':
1234567891011121314151617181920212223242526
// VIOLATION: 'Train wreck' - reaching through multiple objectsfunction getCustomerCity(order: Order): string { // This method knows too much about Order's internal structure return order.getCustomer().getAddress().getCity();}// If any link in the chain changes, this code breaks // COMPLIANT: Ask, don't tellclass Order { getShippingCity(): string { // Order knows how to get its shipping city // Caller doesn't need to know about Customer or Address return this.customer.getShippingCity(); }} class Customer { getShippingCity(): string { return this.shippingAddress.getCity(); }} // Now the call is simple and encapsulatedfunction getOrderCity(order: Order): string { return order.getShippingCity(); // One level, clear boundary}Tell, Don't Ask
A related principle: instead of asking objects for data and then acting on it, tell objects what to do and let them act.
Ask (Bad):
if (user.getPermissions().contains('ADMIN')) {
// perform admin action
}
Tell (Good):
user.performAdminAction(action); // Let User check its own permissions
This keeps logic with the data, maintains boundaries, and prevents knowledge from leaking across components.
Boundaries aren't just between services. Within a single class, private methods establish boundaries. Within a module, internal classes are bounded. Think of boundaries at every level of abstraction—they all serve the same purpose: encapsulating change and limiting knowledge.
Boundaries erode over time as developers add 'quick fixes' and 'temporary workarounds.' Learning to spot boundary violations is essential for maintaining system integrity.
Feature Envy in Practice:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
// FEATURE ENVY: ReportGenerator uses Customer's data more than its ownclass ReportGenerator { generateCustomerSummary(customer: Customer): string { // This method 'envies' Customer - it knows all about Customer internals const totalSpent = customer.orders .map(o => o.total) .reduce((a, b) => a + b, 0); const avgOrder = totalSpent / customer.orders.length; const loyaltyLevel = totalSpent > 10000 ? 'Gold' : totalSpent > 1000 ? 'Silver' : 'Bronze'; return `Customer: ${customer.name}, Spent: ${totalSpent}, Avg: ${avgOrder}, Level: ${loyaltyLevel}`; }} // SOLUTION: Move behavior to where the data isclass Customer { getTotalSpent(): number { return this.orders.reduce((sum, order) => sum + order.total, 0); } getAverageOrderValue(): number { return this.getTotalSpent() / this.orders.length; } getLoyaltyLevel(): LoyaltyLevel { const total = this.getTotalSpent(); if (total > 10000) return LoyaltyLevel.Gold; if (total > 1000) return LoyaltyLevel.Silver; return LoyaltyLevel.Bronze; } getSummary(): CustomerSummary { return { name: this.name, totalSpent: this.getTotalSpent(), averageOrder: this.getAverageOrderValue(), loyaltyLevel: this.getLoyaltyLevel() }; }} class ReportGenerator { generateCustomerSummary(customer: Customer): string { const summary = customer.getSummary(); // Ask for summary, don't compute it return `Customer: ${summary.name}, Level: ${summary.loyaltyLevel}`; }}Each boundary violation makes the next one easier. 'We already reach through Customer to get Orders, why not also reach through Order to get LineItems?' Soon the entire system is a tangled web. Catch violations early and refactor immediately.
Let's apply these principles to a practical example: designing boundaries for an e-commerce checkout system.
The Scenario:
Checkout must:
Naive Approach (Poor Boundaries):
A single CheckoutService doing everything. It knows about carts, pricing, shipping tables, payment APIs, order schemas, email templates, and inventory databases.
12345678910111213141516171819202122232425
// POOR BOUNDARIES: Monolithic serviceclass CheckoutService { constructor( private cartDb: CartDatabase, private productDb: ProductDatabase, private pricingRules: PricingRulesEngine, private shippingTables: ShippingRates, private paymentGateway: StripeAPI, private orderDb: OrderDatabase, private emailClient: SendgridClient, private inventoryDb: InventoryDatabase ) {} // 500 lines of checkout logic mixing all concerns... async checkout(userId: string): Promise<Order> { // Cart validation logic // Pricing calculation logic // Shipping rate lookup // Payment processing logic // Order creation logic // Email sending logic // Inventory update logic // All intertwined with shared local variables }}Better Approach (Clear Boundaries):
Separate services with clear interfaces and responsibilities:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677
// GOOD BOUNDARIES: Separated concerns with clear interfaces // Each service has a single responsibility and clear interfaceinterface CartService { getCart(userId: string): Cart; validateCart(cart: Cart): ValidationResult; clearCart(userId: string): void;} interface PricingService { calculatePrice(cart: Cart): PricedCart; applyDiscounts(pricedCart: PricedCart, codes: string[]): PricedCart;} interface ShippingService { calculateShipping(cart: Cart, address: Address): ShippingQuote; getDeliveryEstimate(quote: ShippingQuote): DateRange;} interface PaymentService { authorize(amount: Money, method: PaymentMethod): AuthorizationResult; capture(authorization: Authorization): CaptureResult;} interface OrderService { createOrder(checkout: CheckoutData): Order; getOrder(orderId: string): Order;} interface NotificationService { sendOrderConfirmation(order: Order): Promise<void>;} interface InventoryService { reserve(items: OrderItem[]): ReservationResult; commit(reservationId: string): void; release(reservationId: string): void;} // CheckoutOrchestrator coordinates - it knows WHAT to do, not HOWclass CheckoutOrchestrator { constructor( private cart: CartService, private pricing: PricingService, private shipping: ShippingService, private payment: PaymentService, private orders: OrderService, private notifications: NotificationService, private inventory: InventoryService ) {} async checkout(userId: string, paymentMethod: PaymentMethod): Promise<Order> { // Each step is delegated to the responsible service const cart = this.cart.getCart(userId); const validation = this.cart.validateCart(cart); if (!validation.isValid) throw new ValidationError(validation.errors); const pricedCart = this.pricing.calculatePrice(cart); const shippingQuote = this.shipping.calculateShipping(cart, cart.shippingAddress); const reservation = await this.inventory.reserve(cart.items); try { const auth = await this.payment.authorize(pricedCart.total, paymentMethod); const order = await this.orders.createOrder({ pricedCart, shippingQuote, auth }); await this.payment.capture(auth); await this.inventory.commit(reservation.id); await this.notifications.sendOrderConfirmation(order); this.cart.clearCart(userId); return order; } catch (error) { this.inventory.release(reservation.id); throw error; } }}With clear boundaries: pricing rules can change without touching checkout flow, payment provider can be swapped by implementing a new adapter, each service can be tested independently, teams can own different services, and failures are isolated to specific domains.
We've covered the essential principles for identifying responsibilities and establishing boundaries. Let's consolidate the key lessons:
What's Next:
With clear responsibilities and boundaries established, the next challenge is understanding how components interact. Before implementing any code, experienced designers think through interactions—the messages passed, the sequences of calls, the failure modes. That's the topic of our next page.
You now understand how to identify and assign responsibilities to components, and how to establish clear boundaries that enable loose coupling and high cohesion. These principles are the bedrock of maintainable design. Next, we'll explore how to think about component interactions before rushing to implementation.