Loading content...
You've identified that a class has too many responsibilities. You understand class extraction. But now comes the harder question: where exactly do you split?
A UserService class handles registration, authentication, profile management, preferences, and notification settings. There are clearly multiple responsibilities here—but should you have two classes, three, or five? Which methods go together? When does splitting become over-engineering?
Responsibility splitting is where SRP becomes an art as much as a science. It requires judgment, domain knowledge, and a clear understanding of the trade-offs involved. This page provides frameworks for making these decisions systematically rather than arbitrarily.
By the end of this page, you will understand multiple frameworks for deciding where to split responsibilities, how to handle ambiguous cases where reasonable engineers might disagree, when splitting adds value versus when it adds unnecessary complexity, and how to communicate and justify your splitting decisions to your team.
Unlike extraction—where the question is "should this cluster become its own class?"—splitting addresses a higher-level challenge: given a class with N responsibilities, how should those N responsibilities be organized?
Consider a PaymentProcessor class with these capabilities:
Each of these could be a separate class. But should it be? Do we want ten tiny classes, or can we find natural groupings that balance separation with practical usability?
Just as under-splitting (God classes) is problematic, over-splitting creates 'ravioli code'—many tiny classes that individually do little, require extensive coordination, and make it hard to understand the system as a whole. The goal is appropriate granularity, not maximum granularity.
The fundamental tension:
Finding the right balance requires a systematic approach, not gut feel. Let's establish frameworks for making these decisions.
Robert C. Martin's (Uncle Bob) formulation of SRP focuses on actors—the people or systems that might request changes to the code. A responsibility is defined as "a reason to change," and reasons to change come from actors.
The Actor Test:
For each capability in your class, ask: "Who would request a change to this functionality?"
Different actors = different responsibilities = candidates for splitting.
| Capability | Primary Actor | Change Frequency | Responsibility Group |
|---|---|---|---|
| Validate payment information | Security/Compliance Team | Medium | Validation |
| Calculate transaction fees | Finance Team | High | Pricing |
| Apply discounts and promotions | Marketing Team | Very High | Pricing |
| Process credit card transactions | Payment Provider | Low | Processing |
| Process PayPal transactions | Payment Provider | Low | Processing |
| Process cryptocurrency transactions | Payment Provider | Medium | Processing |
| Generate transaction receipts | Legal/Compliance Team | Low | Reporting |
| Send confirmation emails | Marketing Team | Medium | Notification |
| Log transactions for auditing | Audit/Compliance Team | Low | Reporting |
| Handle failed payment retries | Ops Team | Medium | Resilience |
Analysis reveals natural groupings:
This gives us 6 focused classes instead of 1 monolith or 10 micro-classes. The groupings make sense because they align with how the organization actually changes the system.
When two capabilities have the same actor and change together (like fee calculation and discount application—both change when pricing strategy changes), they likely belong in the same class. SRP is about change reasons, not surface-level function groupings.
Another powerful framework examines rate of change. Code that changes frequently should be separated from code that rarely changes. This protects stable code from the risk introduced by frequent modifications.
The Volatility Principle:
Group together things that change at the same rate and for the same reasons. Separate things that have different change frequencies or different change drivers.
This principle helps prevent a common failure mode: stable, well-tested code being destabilized by changes to volatile, frequently-modified code that happens to live in the same class.
12345678910111213141516171819202122
// Volatility Analysis of an E-commerce Order classpublic class Order { // STABLE (changes rarely - core domain) private OrderId id; private List<LineItem> items; private Customer customer; private OrderStatus status; public Money calculateSubtotal() { /* Core calculation - stable */ } public void addItem(Product product, int quantity) { /* Core - stable */ } public void removeItem(LineItemId id) { /* Core - stable */ } // VOLATILE (changes frequently - business rules) public Money calculateDiscount() { /* Changes every sale season! */ } public Money calculateTax() { /* Changes with tax law updates */ } public boolean isEligibleForFreeShipping() { /* Changes with promotions */ } // VERY VOLATILE (changes constantly - presentation) public String formatForInvoice() { /* Changes with design updates */ } public String formatForEmail() { /* Changes with email templates */ } public Map<String, Object> toApiResponse() { /* Changes with API versions */ }}Splitting based on volatility:
The analysis reveals three volatility tiers:
Stable Core (changes rarely):
Order class retains core domain logicLineItem, OrderStatus as value objectsVolatile Business Rules (changes frequently):
DiscountCalculator — Changes with every promotionTaxCalculator — Changes with tax jurisdiction updatesShippingEligibility — Changes with logistics strategyVery Volatile Presentation (changes constantly):
OrderFormatter — Changes with UI/UX updatesInvoiceRenderer — Changes with legal requirementsEmailTemplateEngine — Changes with marketing campaignsYour version control history is a goldmine for volatility analysis. Commands like 'git log --oneline --all [file]' or tools that visualize code churn reveal which parts of your codebase change frequently. Let data drive your splitting decisions.
Domain-Driven Design (DDD) provides another lens for responsibility splitting. It emphasizes that software structure should reflect the business domain, and that "bounded contexts" define natural responsibility boundaries.
The Domain Alignment Principle:
Split responsibilities along the same lines that domain experts split their mental models. If business stakeholders describe things separately, those things should likely be separate classes.
Example: An "Account" That's Really Three Things
Consider a banking application with an Account class. In conversations with domain experts, you notice they discuss three different concepts:
These aren't three aspects of one thing—they're three genuinely different concepts that happen to share an identifier. Splitting along these domain lines creates classes that match how the business thinks:
123456789101112131415161718192021222324252627282930313233343536373839404142
// Domain-driven split of a Banking Account // Identity context: Who owns whatpublic class AccountIdentity { private final AccountNumber number; private final CustomerId owner; private final LocalDate openedDate; private AccountStatus status; public boolean isActive() { return status == AccountStatus.ACTIVE; } public boolean belongsTo(CustomerId customer) { return owner.equals(customer); }} // Balance context: What's availablepublic class AccountBalance { private final AccountNumber accountId; private Money currentBalance; private Money availableBalance; private List<Hold> holds; public Money getSpendableAmount() { return availableBalance.subtract(sumHolds()); } public void applyHold(Hold hold) { holds.add(hold); } public void credit(Money amount) { currentBalance = currentBalance.add(amount); recalculateAvailable(); }} // History context: What happenedpublic class TransactionHistory { private final AccountNumber accountId; private final TransactionRepository repository; public List<Transaction> getTransactions(DateRange range) { return repository.findByAccountAndDateRange(accountId, range); } public Statement generateStatement(Month month) { return new Statement(getTransactions(month.toDateRange())); }}The Ubiquitous Language Test:
Do domain experts use different words or phrases for different aspects of a concept? If they say:
...they're unconsciously revealing domain boundaries. Align your code with their language.
Event Storming and other collaborative modeling techniques help surface domain boundaries. When domain experts naturally cluster events and commands differently, those clusters suggest responsibility splits. The structure comes from the domain, not arbitrary technical decisions.
Real-world code rarely offers clean splitting lines. Often, functionality seems to partially belong in multiple places, or frameworks conflict in their recommendations. Here's how to navigate ambiguity.
When Actor-Based and Volatility-Based Disagree:
Sometimes the same team requests changes (single actor), but some of their requests are frequent while others are rare. In such cases:
1234567891011121314151617181920212223
// Situation: Marketing team owns both email templates (volatile) // and customer segmentation rules (stable) // Option 1: Single class (Actor-based says same team)public class MarketingEngine { public void sendCampaignEmail(Campaign c) { /* Volatile */ } public Segment classifyCustomer(Customer c) { /* Stable */ }} // Option 2: Split by volatility, organize by actorpackage marketing.campaigns; // Volatilepublic class CampaignEngine { public void sendCampaignEmail(Campaign c) { }} package marketing.segmentation; // Stablepublic class SegmentationEngine { public Segment classifyCustomer(Customer c) { }} // The marketing package groups them conceptually// The subpackages separate them physically// Marketing team still "owns" both, but volatility is managedWhen Functionality Spans Boundaries:
Some operations genuinely span multiple responsibilities. A transfer() operation involves source account, destination account, transaction logging, and notification. No single class "owns" it.
Solution: The Coordinator Pattern
Create a dedicated coordinator (or "use case" or "application service") that orchestrates the cross-cutting operation without containing domain logic:
123456789101112131415161718192021222324252627
// Coordinator for cross-cutting transfer operationpublic class TransferCoordinator { private final AccountBalance sourceBalance; private final AccountBalance destBalance; private final TransactionLogger logger; private final NotificationService notifier; public TransferResult execute(TransferRequest request) { // Validate preconditions if (!sourceBalance.canWithdraw(request.amount())) { return TransferResult.insufficientFunds(); } // Orchestrate the operation sourceBalance.debit(request.amount()); destBalance.credit(request.amount()); Transaction tx = logger.log(request); notifier.sendTransferConfirmation(request); return TransferResult.success(tx); }} // The coordinator doesn't own business rules// Each collaborator handles its own responsibility// Testing is straightforward with mock collaboratorsIf your coordinator is reaching into collaborators' internals (accessing their fields, calling many private-ish methods), it may be that the 'coordination' actually belongs in one of the domain classes. Coordinators should delegate, not micromanage.
The ideal level of splitting isn't about following rules mechanically—it's about finding the "Goldilocks zone" where classes are neither too big nor too small. Here are heuristics for recognizing when you've found it.
| Category | Under-Split (Too Big) | Over-Split (Too Small) | Right-Sized |
|---|---|---|---|
| Naming | Class name is vague or compound ('UserServiceManager') | Class name is awkward or forced ('UserNameValidator') | Class name is a single, clear noun |
| Description | Requires 'and' to describe ('handles auth and profiles and prefs') | Description is trivially obvious from name | One sentence, no 'and', adds insight |
| Methods | Methods cluster into groups that don't interact | Class has only 1-2 methods | 5-15 methods that collaborate |
| Fields | Many fields unused by most methods | No fields or only injected deps | Most methods use most fields |
| Testing | Tests require complex setup with many irrelevant parts | Tests are trivial, almost redundant | Tests are focused but meaningful |
| Changes | Changes often affect unrelated methods | Simple changes require multiple class updates | Changes are localized to one class |
Quantitative Guidelines (Rough):
While not hard rules, these ranges indicate healthy class sizes:
Imagine explaining your class to a new team member. If you can explain its purpose in 20-30 seconds without hand-waving, it's probably right-sized. If you need 5 minutes or say 'it handles a bunch of stuff,' it's too big. If explaining seems pointless because it's obvious, it might be too small.
Understanding what not to do is as valuable as knowing the right approach. These anti-patterns create the illusion of SRP compliance while actually making code worse.
Anti-Pattern: Splitting by Technical Layer
Splitting a class into UserValidator, UserProcessor, UserPersister because you read about layers somewhere. This often scatters a single responsibility across multiple classes.
The Problem:
12345678910111213141516171819202122
// ANTI-PATTERN: Technical layer splittingpublic class UserValidator { public ValidationResult validate(UserDto dto) { /* ... */ }} public class UserProcessor { public User process(UserDto dto, ValidationResult result) { /* ... */ }} public class UserPersister { public void persist(User user) { /* ... */ }} // All three are tightly coupled and change together// A new field requires changes to ALL THREE classes// This isn't SRP - it's one responsibility scattered across three files // BETTER: Single UserService or domain-based splitpublic class UserRegistration { // Validates, processes, and persists - one responsibility: registering users public User register(RegistrationRequest request) { /* ... */ }}Splitting responsibilities is where engineering judgment meets SRP theory. There's no formula that produces the "right" split—but there are frameworks that guide us toward good decisions.
What's next:
Once you've split responsibilities, a new challenge emerges: maintaining cohesion after the split. The next page explores how to ensure that extracted and split classes remain internally cohesive, and how to recognize when a split has gone wrong and needs adjustment.
You now have multiple frameworks for deciding where to split responsibilities, along with guidance on handling ambiguous cases and avoiding common anti-patterns. Apply these systematically rather than relying on intuition alone.