1. Introduction
A class with multiple responsibilities tries to handle several unrelated parts of the application inside one place.
In real Java projects, such classes often begin small and gradually become large because developers keep adding new methods wherever existing data or dependencies are already available.
For example, an OrderService may start with order creation and later accumulate:
- Request validation
- Price calculation
- Database access
- Payment processing
- Email notification
- Audit logging
- PDF invoice generation
- Inventory updates
- External API calls
The class may still compile and work, but it becomes harder to understand, test, modify, and review safely.
During Pull Request review, reviewers should look beyond class size. A class with 500 lines is not automatically wrong, and a class with 100 lines is not automatically good.
The more important question is:
How many different reasons can this class change?
If a class changes because payment rules change, email formatting changes, database queries change, and inventory behavior changes, it probably has too many responsibilities.
2. What This Topic Means
Avoiding classes with multiple responsibilities means designing a class so that it owns one clear area of behavior.
This concept is closely related to the Single Responsibility Principle.
The practical interpretation is:
A class should have one primary reason to change.
This does not mean that a class must contain only one method.
A class can contain several methods if those methods belong to the same responsibility.
For example:
public class OrderPriceCalculator {
public BigDecimal calculateSubtotal(Order order) {
...
}
public BigDecimal calculateTax(Order order) {
...
}
public BigDecimal calculateDiscount(Order order) {
...
}
public BigDecimal calculateTotal(Order order) {
...
}
}All these methods belong to one responsibility:
Calculating order pricing.
The following class has a different problem:
public class OrderService {
public void createOrder(...) {
}
public BigDecimal calculateTax(...) {
}
public void sendEmail(...) {
}
public void generateInvoicePdf(...) {
}
public void updateInventory(...) {
}
public void exportOrderReport(...) {
}
}These methods represent several independent business and technical concerns.
That is a multi-responsibility class.
3. Why It Matters in Real Projects
Readability
A developer should quickly understand what a class represents.
A class called:
OrderServicebecomes difficult to understand when it contains logic for:
- Payments
- Inventory
- Emails
- Reporting
- Database operations
- Invoice generation
Developers must scan a large amount of unrelated code before finding the behavior they need.
Maintainability
Different features evolve independently.
For example:
- Tax rules may change because of business requirements.
- Email formatting may change because of branding.
- Payment logic may change because of a gateway migration.
- Database logic may change because of schema optimization.
If all these responsibilities exist in one class, every change touches the same file.
This increases merge conflicts and regression risk.
Debugging
When an order-processing failure occurs, developers may need to inspect one large method containing:
- Validation
- Persistence
- Payment
- Inventory
- Logging
Separating responsibilities creates clearer execution boundaries.
Testability
A class with many responsibilities typically requires many dependencies.
For example:
OrderRepository
PaymentClient
InventoryClient
EmailService
TaxService
InvoiceGenerator
AuditServiceUnit tests then require many mocks even when testing one small behavior.
Reliability
A change intended for one responsibility can accidentally affect another.
For example, modifying invoice-generation logic inside a large OrderService may unintentionally disturb transaction handling.
Team Development
Multiple developers frequently modify the same large service class.
This causes:
- Merge conflicts
- Harder code ownership
- More complex PR reviews
- Higher coordination cost
4. Core Concept
The key concept is cohesion.
A cohesive class contains behavior that belongs together.
High Cohesion
Example:
public class PaymentValidator {
public void validateAmount(BigDecimal amount) {
...
}
public void validateCurrency(String currency) {
...
}
public void validatePaymentMethod(PaymentMethod method) {
...
}
}These methods belong to payment validation.
Low Cohesion
Example:
public class PaymentManager {
public void validatePayment(...) {
}
public void sendEmail(...) {
}
public void generateCsvReport(...) {
}
public void deleteExpiredSessions(...) {
}
}The methods have little conceptual relationship.
Responsibility Is Not the Same as Method Count
A class containing ten related methods may be well designed.
A class containing four unrelated methods may already have too many responsibilities.
Technical and Business Responsibilities
Responsibilities may come from different categories.
Business responsibilities:
- Calculating discounts
- Processing payments
- Reserving inventory
Technical responsibilities:
- Sending HTTP requests
- Persisting entities
- Formatting emails
- Writing files
- Serializing JSON
A service should avoid owning unrelated business and infrastructure concerns simultaneously.
5. Important Rules
- Give every class a clear purpose.
- Identify why the class would need to change.
- Do not use class size alone to detect responsibility problems.
- Keep related behavior together.
- Extract unrelated business logic into focused services.
- Keep persistence logic inside repositories or data-access components.
- Keep external API integration inside dedicated clients or adapters.
- Keep notification logic inside notification components.
- Avoid utility-style service classes containing unrelated methods.
- Do not move methods into new classes merely to reduce line count.
- Prefer meaningful abstractions over arbitrary class splitting.
- Avoid service classes with excessive dependency injection.
- Treat large constructor dependency lists as a design warning.
- Keep orchestration separate from detailed implementation where practical.
- Do not extract every private method into another class.
- Avoid over-engineering simple flows.
- Separate responsibilities only when they represent meaningful independent concerns.
6. Bad Code Example
Consider an e-commerce order-processing service.
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentGatewayClient paymentGatewayClient;
private final InventoryClient inventoryClient;
private final JavaMailSender mailSender;
public OrderService(
OrderRepository orderRepository,
PaymentGatewayClient paymentGatewayClient,
InventoryClient inventoryClient,
JavaMailSender mailSender) {
this.orderRepository = orderRepository;
this.paymentGatewayClient = paymentGatewayClient;
this.inventoryClient = inventoryClient;
this.mailSender = mailSender;
}
@Transactional
public Order createOrder(OrderRequest request) {
if (request == null) {
throw new IllegalArgumentException("Order request cannot be null");
}
if (request.getItems() == null || request.getItems().isEmpty()) {
throw new IllegalArgumentException("Order must contain items");
}
BigDecimal total = BigDecimal.ZERO;
for (OrderItemRequest item : request.getItems()) {
BigDecimal itemTotal = item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()));
total = total.add(itemTotal);
}
PaymentResponse paymentResponse = paymentGatewayClient.charge(
request.getCustomerId(),
total,
request.getPaymentToken()
);
if (!paymentResponse.isSuccessful()) {
throw new PaymentFailedException("Payment failed");
}
for (OrderItemRequest item : request.getItems()) {
inventoryClient.reduceStock(item.getProductId(), item.getQuantity());
}
Order order = new Order();
order.setCustomerId(request.getCustomerId());
order.setTotalAmount(total);
order.setStatus(OrderStatus.CONFIRMED);
Order savedOrder = orderRepository.save(order);
SimpleMailMessage message = new SimpleMailMessage();
message.setTo(request.getEmail());
message.setSubject("Order Confirmation");
message.setText("Your order " + savedOrder.getId() + " has been confirmed.");
mailSender.send(message);
return savedOrder;
}
}At first glance, the method performs one business operation: creating an order.
However, the class owns several detailed responsibilities.
7. Problems in the Bad Code
Request Validation
The service directly owns validation rules:
request == null
items == null
items.isEmpty()Validation may grow as business requirements increase.
Price Calculation
The service calculates order totals itself.
Pricing can become much more complex when the project introduces:
- Discounts
- Promotions
- Taxes
- Shipping costs
- Coupons
- Region-based pricing
Payment Processing
The service directly communicates with the payment gateway.
This couples order orchestration to payment infrastructure.
Inventory Management
The service directly performs inventory updates.
Inventory behavior is a separate business capability.
Persistence
The service constructs and saves the Order entity.
Some persistence responsibility is normal inside application services, but mixing it with every other concern increases coupling.
Email Construction
The service knows:
- Email subject
- Email body
- Mail implementation
Notification formatting is another responsibility.
Excessive Dependencies
The service already depends on four collaborators.
As requirements grow, additional dependencies may appear:
TaxService
DiscountService
AuditService
FraudService
ShippingService
MetricsServiceThe constructor can become increasingly difficult to manage.
Harder Unit Tests
Testing price calculation requires mocking payment, inventory, repository, and mail dependencies because everything happens in one workflow.
Harder Change Isolation
Changing email behavior requires modifying the same class that contains payment and inventory logic.
8. Code Review Findings
During PR review, a senior developer should identify responsibility boundaries.
Finding 1: Pricing Logic Is Embedded in Order Orchestration
The loop calculating:
price × quantityis pricing behavior.
It should not necessarily be owned by the workflow coordinator.
Finding 2: External Payment Logic Is Coupled Directly
OrderService knows the specific payment-gateway interaction.
A payment abstraction would make the workflow cleaner.
Finding 3: Inventory Is Another Independent Business Capability
Stock reduction should usually be handled by an inventory component.
Finding 4: Notification Infrastructure Leaks into Business Service
SimpleMailMessage and JavaMailSender are infrastructure details.
The order service should ideally request:
sendOrderConfirmation(...)instead of constructing an email.
Finding 5: Method Has Several Failure Modes
The method can fail because of:
- Validation
- Payment
- Inventory
- Database
These failures have different business implications.
Finding 6: Transaction Boundary Needs Review
@Transactional covers database operations, but remote API calls are not rolled back by a database transaction.
The reviewer should carefully examine the consistency strategy.
9. Reviewer Comment Example
createOrder()currently handles validation, price calculation, payment, stock updates, persistence, and email construction. Could we keep this service focused on order orchestration and extract the independent responsibilities into dedicated collaborators?
Another valid review comment:
OrderServiceis directly coupled to JavaMailSender and email formatting. Consider moving order-confirmation delivery behind a notification service so changes to email infrastructure do not require modifying the order workflow.
10. Improved Code
@Service
public class OrderService {
private final OrderValidator orderValidator;
private final OrderPriceCalculator orderPriceCalculator;
private final PaymentService paymentService;
private final InventoryService inventoryService;
private final OrderRepository orderRepository;
private final OrderNotificationService orderNotificationService;
public OrderService(
OrderValidator orderValidator,
OrderPriceCalculator orderPriceCalculator,
PaymentService paymentService,
InventoryService inventoryService,
OrderRepository orderRepository,
OrderNotificationService orderNotificationService) {
this.orderValidator = orderValidator;
this.orderPriceCalculator = orderPriceCalculator;
this.paymentService = paymentService;
this.inventoryService = inventoryService;
this.orderRepository = orderRepository;
this.orderNotificationService = orderNotificationService;
}
@Transactional
public Order createOrder(OrderRequest request) {
orderValidator.validate(request);
BigDecimal total = orderPriceCalculator.calculate(request.getItems());
paymentService.processPayment(
request.getCustomerId(),
total,
request.getPaymentToken()
);
inventoryService.reserveItems(request.getItems());
Order order = createOrderEntity(request, total);
Order savedOrder = orderRepository.save(order);
orderNotificationService.sendConfirmation(savedOrder, request.getEmail());
return savedOrder;
}
private Order createOrderEntity(OrderRequest request, BigDecimal total) {
Order order = new Order();
order.setCustomerId(request.getCustomerId());
order.setTotalAmount(total);
order.setStatus(OrderStatus.CONFIRMED);
return order;
}
}
@Component
public class OrderValidator {
public void validate(OrderRequest request) {
if (request == null) {
throw new IllegalArgumentException("Order request cannot be null");
}
if (request.getItems() == null || request.getItems().isEmpty()) {
throw new IllegalArgumentException("Order must contain at least one item");
}
}
}
@Component
public class OrderPriceCalculator {
public BigDecimal calculate(List<OrderItemRequest> items) {
BigDecimal total = BigDecimal.ZERO;
for (OrderItemRequest item : items) {
BigDecimal itemTotal = item.getPrice()
.multiply(BigDecimal.valueOf(item.getQuantity()));
total = total.add(itemTotal);
}
return total;
}
}
@Service
public class PaymentService {
private final PaymentGatewayClient paymentGatewayClient;
public PaymentService(PaymentGatewayClient paymentGatewayClient) {
this.paymentGatewayClient = paymentGatewayClient;
}
public void processPayment(
Long customerId,
BigDecimal amount,
String paymentToken) {
PaymentResponse response = paymentGatewayClient.charge(
customerId,
amount,
paymentToken
);
if (!response.isSuccessful()) {
throw new PaymentFailedException("Payment failed");
}
}
}
@Service
public class InventoryService {
private final InventoryClient inventoryClient;
public InventoryService(InventoryClient inventoryClient) {
this.inventoryClient = inventoryClient;
}
public void reserveItems(List<OrderItemRequest> items) {
for (OrderItemRequest item : items) {
inventoryClient.reduceStock(
item.getProductId(),
item.getQuantity()
);
}
}
}
@Service
public class OrderNotificationService {
private final JavaMailSender mailSender;
public OrderNotificationService(JavaMailSender mailSender) {
this.mailSender = mailSender;
}
public void sendConfirmation(Order order, String email) {
SimpleMailMessage message = new SimpleMailMessage();
message.setTo(email);
message.setSubject("Order Confirmation");
message.setText(
"Your order " + order.getId() + " has been confirmed."
);
mailSender.send(message);
}
}11. Improved Code Explanation
OrderService Becomes an Orchestrator
The service still owns the use case:
Create an order.
However, it delegates detailed responsibilities.
The method now reads like the business workflow:
validate
calculate
pay
reserve inventory
save
notifyThis improves readability.
Validation Is Isolated
OrderValidator owns order-input validation.
When validation rules change, developers know where to modify them.
Pricing Has Its Own Responsibility
OrderPriceCalculator can evolve independently.
Future requirements such as:
- Discount calculations
- Tax
- Coupons
- Promotions
can be implemented without turning OrderService into a large calculation class.
Payment Infrastructure Is Hidden
The workflow now calls:
paymentService.processPayment(...)instead of working directly with the gateway response.
Inventory Responsibility Is Clear
InventoryService owns stock operations.
Email Infrastructure Is Removed from Order Workflow
OrderService no longer creates SimpleMailMessage.
The notification implementation can later move from SMTP to:
- Kafka
- Event-driven notification
- Third-party email provider
without changing order workflow logic.
12. Bad Code vs Improved Code
| Area | Bad Code | Improved Code |
|---|---|---|
| Responsibility | One class handles many concerns | Each component owns focused behavior |
| Readability | Workflow mixed with implementation details | Workflow reads at business level |
| Maintainability | Unrelated changes touch same class | Changes are isolated |
| Testing | Many dependencies required for every test | Components can be tested independently |
| Debugging | Failure origin harder to identify | Clear component boundaries |
| Team development | Many developers modify same file | Ownership can be distributed |
| Reusability | Logic trapped inside one service | Pricing, validation, notification reusable |
| Coupling | Order logic knows infrastructure details | Infrastructure hidden behind collaborators |
13. Real Project Scenario
Consider a healthcare system that processes insurance claims.
Initially, a ClaimService handles:
- Claim validation
- Eligibility verification
- Insurance API calls
- Claim amount calculation
- Database persistence
- PDF generation
- Audit logging
- Notification
- Rejection reason generation
At first, the implementation works.
Over time, different teams request changes.
Compliance team changes validation.
Insurance integration team changes external APIs.
Finance team modifies claim calculations.
Operations team changes audit requirements.
Customer communication team modifies notification templates.
Every team must modify:
ClaimService.javaThe file becomes a hotspot.
PRs frequently conflict.
Developers hesitate to modify the service because changes have unpredictable side effects.
A better design separates responsibilities:
ClaimValidator
EligibilityService
ClaimCalculator
InsuranceGateway
ClaimRepository
ClaimDocumentGenerator
ClaimAuditService
ClaimNotificationServiceA higher-level ClaimProcessingService can coordinate the workflow.
14. Production Impact
Increased Regression Risk
Changes to one responsibility can unexpectedly affect another.
Difficult Troubleshooting
When one class controls an entire workflow, logs and stack traces often point back to the same service.
Finding the true failure domain becomes harder.
Deployment Risk
Small changes require retesting a large amount of behavior.
Slow Feature Delivery
Developers spend more time understanding existing code before making changes.
Merge Conflicts
Large frequently modified classes become conflict hotspots.
Partial Failure Problems
When external APIs, database operations, and notifications are mixed together, failure recovery becomes difficult.
For example:
- Payment succeeds.
- Database save succeeds.
- Email fails.
The application must decide whether email failure should cause the whole order operation to fail.
A multi-responsibility method often hides these important decisions.
15. Common Developer Mistakes
Mistake 1: Treating Every Service as a Container for Related Features
Developers add anything involving orders into:
OrderServicebecause the method deals with an order.
That is too broad.
Mistake 2: Measuring Responsibility by Line Count
A class with 300 lines may still have one cohesive responsibility.
A class with 70 lines may contain four unrelated responsibilities.
Mistake 3: Extracting Every Private Method
This creates excessive fragmentation.
Not every method deserves another class.
Mistake 4: Creating Generic Manager Classes
Names such as:
CommonManager
DataManager
UtilityService
ApplicationHelperoften become dumping grounds.
Mistake 5: Mixing Domain Logic and Infrastructure
Examples:
- Business service directly creates HTTP requests.
- Business service directly formats SQL.
- Business service directly builds HTML emails.
Mistake 6: Excessive Constructor Dependencies
A service with ten or fifteen injected dependencies often indicates that it coordinates too many concerns.
This is a warning sign, not an automatic violation.
Mistake 7: Extracting Responsibility Without Clear Ownership
Moving code from:
OrderServiceto:
OrderHelperdoes not improve design if OrderHelper becomes another miscellaneous class.
Mistake 8: Applying SRP Too Literally
SRP does not mean:
"One class = one method."
That interpretation creates unnecessary micro-classes and navigation overhead.
16. Edge Cases
Small Applications
A small application may not require many abstractions.
Separating every minor operation into its own service can introduce unnecessary complexity.
Orchestration Services
A service may legitimately depend on several collaborators.
For example:
CheckoutServicemay orchestrate:
- Cart validation
- Payment
- Inventory
- Order creation
Its responsibility can still be one thing:
Executing checkout.
The question is whether it coordinates these concerns or implements all their detailed logic itself.
Transaction Boundaries
Splitting responsibilities across classes does not automatically change Spring transaction behavior if calls remain within the same transaction.
However, reviewers must understand proxy behavior and transaction boundaries.
Shared Validation
Validation can sometimes remain inside a domain object when it represents intrinsic invariants.
Do not automatically create a validator class for every rule.
Concurrency
If a large class maintains mutable shared state, multiple responsibilities can make concurrency problems harder to reason about.
Spring singleton services should generally avoid request-specific mutable fields.
Exception Handling
Different responsibilities may require different exception strategies.
For example:
- Payment failure
- Inventory failure
- Notification failure
should not necessarily be handled identically.
17. Performance Considerations
Multiple responsibilities do not inherently create poor runtime performance.
A large class is not slower merely because it contains more methods.
The main performance risks are indirect.
Database Calls
A large service may hide database calls inside multiple loops.
Example:
for (OrderItem item : items) {
productRepository.findById(item.getProductId());
}This may create an N+1-style access pattern.
External API Calls
A multi-purpose workflow can easily accumulate sequential external calls.
For example:
paymentClient.call()
inventoryClient.call()
fraudClient.call()
shippingClient.call()Latency can grow significantly.
Duplicate Work
When responsibilities are poorly separated, different methods may repeat:
- Validation
- Data loading
- Mapping
- External calls
Performance Optimization Becomes Harder
When pricing, persistence, API communication, and notification are mixed together, profiling and optimization become less targeted.
The goal of responsibility separation is primarily maintainability, not raw performance.
18. Security Considerations
Security can become harder to enforce when one class handles many concerns.
Authorization Mixing
A service may perform:
- User lookup
- Data modification
- Report generation
- Administrative operations
Different operations may require different authorization policies.
A large service increases the risk that security checks are inconsistently applied.
Sensitive Data Exposure
A class that handles payment, logging, and notification may accidentally log or email sensitive information.
Examples:
- Payment tokens
- Customer identifiers
- Personal information
- Authentication tokens
Input Validation
When validation is scattered across large workflow methods, some code paths may bypass required checks.
Secrets
External-service credentials should remain in dedicated configuration or integration components.
They should not be passed throughout large business-service classes.
19. Testing Considerations
Test Responsibilities Independently
OrderPriceCalculator should be tested without payment or database mocks.
Example scenarios:
- Single item
- Multiple items
- Quantity greater than one
- Decimal prices
- Large amounts
Test Validation Separately
OrderValidator should cover:
- Null request
- Empty item list
- Valid order
Payment Tests
Verify:
- Successful response
- Failed response
- Gateway exception
- Timeout handling where applicable
Inventory Tests
Verify:
- Correct product IDs
- Correct quantities
- Inventory client failure
Workflow Tests
OrderService tests should focus on orchestration.
For example:
- Validator called
- Price calculated
- Payment processed
- Inventory reserved
- Order persisted
- Confirmation triggered
Integration Tests
Use integration tests for critical boundaries such as:
- Repository persistence
- Payment API integration
- Email infrastructure
Failure Cases
Important workflow tests should include:
- Payment fails before persistence
- Inventory fails after payment
- Database save fails
- Notification fails after order creation
These scenarios expose consistency and recovery requirements.
20. Refactoring Guidelines
Refactoring a large multi-responsibility class should be incremental.
Step 1: Add Characterization Tests
Before changing structure, protect existing behavior.
Test the current workflow.
Step 2: Identify Responsibilities
Group methods by reason to change.
For example:
Validation
Pricing
Payment
Inventory
NotificationStep 3: Start with Low-Risk Pure Logic
Pricing calculation is often easier to extract because it has minimal infrastructure dependencies.
Step 4: Extract One Responsibility
Create:
OrderPriceCalculatorMove calculation logic without changing behavior.
Step 5: Update Tests
Verify existing tests still pass.
Step 6: Extract Infrastructure Logic
Move email creation to:
OrderNotificationServiceStep 7: Extract External Integrations
Move payment and inventory logic behind dedicated components.
Step 8: Keep Workflow in Application Service
Do not eliminate the orchestrating service merely because other responsibilities were extracted.
Step 9: Review Transaction Semantics
Ensure extracting methods into Spring beans does not unintentionally change:
- Transactions
- Exception behavior
- Retry behavior
Step 10: Refactor Gradually
Avoid rewriting a large production service in one PR unless necessary.
Smaller changes are easier to review and rollback.
21. Best Practices
Name Classes by Their Responsibility
Prefer:
OrderPriceCalculator
PaymentProcessor
InventoryReservationService
OrderNotificationServiceover:
OrderHelper
CommonService
ProcessingManagerKeep Application Services at Workflow Level
A method such as:
checkout()should read like business orchestration rather than low-level implementation.
Separate External Integrations
Use dedicated clients or adapters for:
- Payment gateways
- Shipping providers
- Email services
- Identity providers
Keep Pure Business Logic Independent
Calculators and validators are easier to test when they do not depend on Spring or infrastructure.
Watch Constructor Size
Many injected dependencies may indicate too many responsibilities.
Investigate rather than mechanically rejecting the design.
Design Around Reasons to Change
Ask:
"Which requirement would cause this class to change?"
If several independent teams or requirements can change the class, consider separating responsibilities.
22. Practices to Avoid
God Classes
Avoid classes that control large portions of the application.
Generic Helpers
Avoid moving unrelated behavior into:
AppUtils
CommonHelper
GlobalServiceRepository Logic Inside Controllers
Controllers should not accumulate business and persistence responsibilities.
HTTP Client Logic Inside Domain Services
Keep external integration details behind dedicated boundaries.
Email Formatting Inside Core Business Logic
Notification concerns evolve independently.
One Class Per Method
This creates unnecessary fragmentation and does not represent SRP correctly.
Arbitrary Splitting Based on File Length
Do not divide a class simply because it crossed an arbitrary number of lines.
Premature Architecture
A simple CRUD feature does not necessarily require ten separate services.
Separate responsibilities where the distinction provides real maintenance value.
23. Code Review Checklist
- Does this class have one clear primary purpose?
- How many independent reasons could cause this class to change?
- Does the class contain unrelated business logic?
- Does the class mix business logic with infrastructure details?
- Is external API code embedded directly in the service?
- Is email or notification formatting mixed with core business processing?
- Is persistence logic mixed with unrelated concerns?
- Is calculation logic reusable outside this class?
- Does the constructor have too many dependencies?
- Are dependencies from unrelated domains being injected?
- Does this class act as both an orchestrator and detailed implementer?
- Could an independent responsibility be extracted cleanly?
- Would extracting this responsibility improve testing?
- Would another team likely own or change this behavior independently?
- Is the proposed extraction meaningful or merely reducing line count?
- Are we introducing unnecessary classes for trivial logic?
- Are failure responsibilities clearly separated?
- Does the class maintain request-specific mutable state?
- Can each important responsibility be tested independently?
- Is the class becoming a hotspot for unrelated PR changes?
24. Common Pull Request Review Comments
This service currently owns validation, pricing, persistence, and notification logic. Could we extract the independently changing responsibilities and keep this class focused on orchestration?
The email construction appears unrelated to the order-processing responsibility. Consider moving it behind an OrderNotificationService.
Price calculation is embedded in the workflow. A dedicated calculator would make the business rule easier to test without mocking repository and API dependencies.
This class now has nine injected dependencies across several domains. Could we review whether it has accumulated multiple responsibilities?
The payment gateway response handling is infrastructure-specific. Consider keeping the order workflow dependent on a payment abstraction instead.
Moving these methods into a generic OrderHelper would only relocate the problem. Can we name the extracted component after the actual responsibility it owns?
I would keep this orchestration method here, but move the detailed inventory update logic into the inventory component.
This change adds reporting behavior to a class already responsible for transaction processing. These concerns appear to change independently and should probably remain separate.
Before splitting this class further, can we identify the distinct reasons it changes? Some of these private methods still belong to the same responsibility.
The class is not problematic because of its line count; the concern is that unrelated payment, notification, and persistence changes all require modifying the same component.
25. Code Review Exercise
Review the following Spring Boot service.
Identify:
- Responsibilities
- Code smells
- Maintainability risks
- Testing problems
- Production risks
- Appropriate extractions
@Service public class EmployeeService { private final EmployeeRepository employeeRepository; private final JavaMailSender mailSender; private final PayrollClient payrollClient;
``` public EmployeeService( EmployeeRepository employeeRepository, JavaMailSender mailSender, PayrollClient payrollClient) { this.employeeRepository = employeeRepository; this.mailSender = mailSender; this.payrollClient = payrollClient; }
public Employee createEmployee(EmployeeRequest request) { if (request == null) { throw new IllegalArgumentException("Request cannot be null"); }
if (request.getEmail() == null || request.getEmail().isBlank()) { throw new IllegalArgumentException("Email is required"); }
Employee employee = new Employee(); employee.setName(request.getName()); employee.setEmail(request.getEmail()); employee.setDepartment(request.getDepartment());
Employee savedEmployee = employeeRepository.save(employee);
payrollClient.createPayrollAccount( savedEmployee.getId(), savedEmployee.getName() );
SimpleMailMessage message = new SimpleMailMessage(); message.setTo(savedEmployee.getEmail()); message.setSubject("Welcome"); message.setText("Welcome " + savedEmployee.getName());
mailSender.send(message);
return savedEmployee; }
public BigDecimal calculateAnnualBonus(Employee employee) { if ("MANAGER".equals(employee.getRole())) { return employee.getSalary().multiply(new BigDecimal("0.15")); }
return employee.getSalary().multiply(new BigDecimal("0.05")); }
public String generateEmployeeCsv(Employee employee) { return employee.getId() + "," + employee.getName() + "," + employee.getEmail() + "," + employee.getDepartment(); } ```
}
Review questions:
- How many responsibilities does this class contain?
- Which responsibilities change independently?
- Which code should remain in the application service?
- Which code should be extracted?
- What would make this class easier to test?
- What happens if payroll succeeds but email delivery fails?
- Should CSV generation belong here?
- Should bonus calculation belong here?
Do not modify the code before identifying the responsibility boundaries.
26. Exercise Solution
The class has several distinct responsibilities:
- Employee creation workflow
- Employee validation
- Database persistence
- Payroll integration
- Welcome email delivery
- Bonus calculation
- CSV generation
These concerns can change independently.
Problem 1: Validation Embedded in Workflow
Employee validation can grow independently.
Problem 2: Payroll Integration Detail
The employee service directly communicates with the payroll system.
Problem 3: Email Infrastructure
The service constructs and sends emails directly.
Problem 4: Bonus Calculation
Compensation rules are separate from employee creation.
Problem 5: CSV Export
Export formatting is another independent responsibility.
Improved Code
@Service
public class EmployeeService {
private final EmployeeValidator employeeValidator;
private final EmployeeRepository employeeRepository;
private final PayrollService payrollService;
private final EmployeeNotificationService employeeNotificationService;
public EmployeeService(
EmployeeValidator employeeValidator,
EmployeeRepository employeeRepository,
PayrollService payrollService,
EmployeeNotificationService employeeNotificationService) {
this.employeeValidator = employeeValidator;
this.employeeRepository = employeeRepository;
this.payrollService = payrollService;
this.employeeNotificationService = employeeNotificationService;
}
public Employee createEmployee(EmployeeRequest request) {
employeeValidator.validate(request);
Employee employee = createEmployeeEntity(request);
Employee savedEmployee = employeeRepository.save(employee);
payrollService.createAccount(savedEmployee);
employeeNotificationService.sendWelcomeEmail(savedEmployee);
return savedEmployee;
}
private Employee createEmployeeEntity(EmployeeRequest request) {
Employee employee = new Employee();
employee.setName(request.getName());
employee.setEmail(request.getEmail());
employee.setDepartment(request.getDepartment());
return employee;
}
}
@Component
public class EmployeeValidator {
public void validate(EmployeeRequest request) {
if (request == null) {
throw new IllegalArgumentException("Employee request cannot be null");
}
if (request.getEmail() == null || request.getEmail().isBlank()) {
throw new IllegalArgumentException("Employee email is required");
}
}
}
@Service
public class PayrollService {
private final PayrollClient payrollClient;
public PayrollService(PayrollClient payrollClient) {
this.payrollClient = payrollClient;
}
public void createAccount(Employee employee) {
payrollClient.createPayrollAccount(
employee.getId(),
employee.getName()
);
}
}
@Service
public class EmployeeNotificationService {
private final JavaMailSender mailSender;
public EmployeeNotificationService(JavaMailSender mailSender) {
this.mailSender = mailSender;
}
public void sendWelcomeEmail(Employee employee) {
SimpleMailMessage message = new SimpleMailMessage();
message.setTo(employee.getEmail());
message.setSubject("Welcome");
message.setText("Welcome " + employee.getName());
mailSender.send(message);
}
}
@Component
public class BonusCalculator {
public BigDecimal calculate(Employee employee) {
if ("MANAGER".equals(employee.getRole())) {
return employee.getSalary()
.multiply(new BigDecimal("0.15"));
}
return employee.getSalary()
.multiply(new BigDecimal("0.05"));
}
}
@Component
public class EmployeeCsvExporter {
public String export(Employee employee) {
return employee.getId()
+ ","
+ employee.getName()
+ ","
+ employee.getEmail()
+ ","
+ employee.getDepartment();
}
}Why Each Change Helps
EmployeeService now focuses on employee creation orchestration.
EmployeeValidator owns input validation.
PayrollService isolates payroll-system integration.
EmployeeNotificationService owns email delivery.
BonusCalculator contains compensation calculations.
EmployeeCsvExporter owns export formatting.
The resulting design improves:
- Test isolation
- Change isolation
- Code ownership
- Readability
- Failure analysis
However, these components should be introduced because they represent real responsibilities, not simply because the original class contained several methods.
27. Interview Perspective
Interviewers commonly test SRP using real project scenarios rather than asking only for the textbook definition.
A typical question might be:
"You find a Spring Boot service that validates requests, calls repositories, sends emails, generates reports, and processes payments. What would you change?"
A weak answer is:
"I will split every method into a separate class."
A stronger answer is:
"I would first identify the independent reasons the class changes. Workflow orchestration can remain in the application service, but payment integration, report generation, notification, and reusable calculation logic should be separated if they evolve independently."
Senior-level discussions may include:
- Single Responsibility Principle
- Cohesion
- Coupling
- God classes
- Service boundaries
- Application services
- Domain services
- Infrastructure adapters
- Dependency injection
- Unit-test complexity
- Transaction boundaries
- Microservice boundaries
- Refactoring legacy systems
Interviewers want to see judgment, not memorized rules.
28. Interview Questions and Answers
Basic Question
Question: What does it mean when a Java class has multiple responsibilities?
Answer:
It means the class owns several independent concerns that can change for different reasons.
For example, a service that calculates prices, sends emails, calls payment APIs, and generates reports has several responsibilities.
The issue is not the number of methods but the number of independent reasons the class must change.
Intermediate Question
Question: How can you identify a multi-responsibility class during code review?
Answer:
I look for signals such as:
- Unrelated method groups
- Many dependencies from different domains
- Business logic mixed with infrastructure
- Large orchestration methods containing implementation details
- Different teams frequently modifying the same class
- Many unrelated reasons for changes
I would not use line count as the primary indicator.
Advanced Question
Question: Does SRP mean every class should have only one method?
Answer:
No.
A class can contain many methods as long as they belong to the same cohesive responsibility.
For example, an OrderPriceCalculator can contain methods for subtotal, tax, discounts, and final total because all belong to pricing.
Splitting every method into a separate class would create unnecessary fragmentation.
Scenario-Based Question
Question: A checkout service validates the cart, processes payment, reserves inventory, creates the order, and sends confirmation. Is that automatically an SRP violation?
Answer:
Not necessarily.
If the service is orchestrating those collaborators at a high level, its single responsibility may be executing the checkout use case.
It becomes problematic when the service itself contains the detailed implementation for payment communication, inventory algorithms, email formatting, pricing, and persistence.
The distinction between orchestration and implementation is important.
Code-Review Question
Question: What would you say if a PR adds JavaMailSender directly to an already complex payment service?
Answer:
I would ask whether notification is an independently changing responsibility.
A review comment could be:
"PaymentService now owns both payment processing and email-delivery details. Could notification be moved behind a dedicated component so payment logic remains isolated from messaging infrastructure?"
Real-Project Question
Question: A legacy service has 2,000 lines. Would you immediately split it?
Answer:
No.
I would first protect its behavior with tests, identify cohesive responsibility groups, and refactor incrementally.
Splitting a large legacy class aggressively can introduce regressions.
I would start with low-risk responsibilities such as pure calculations or formatting, then gradually extract infrastructure and independent business concerns.
Spring Boot Question
Question: A Spring service has ten constructor dependencies. Does that prove it violates SRP?
Answer:
No, but it is a strong design signal worth investigating.
An orchestration service may legitimately coordinate several collaborators.
I would examine whether those dependencies all support one workflow or whether the class has accumulated unrelated responsibilities.
Design Question
Question: What is the relationship between cohesion and SRP?
Answer:
High cohesion means a class contains closely related behavior.
SRP encourages high cohesion by keeping behavior that changes for the same reason together and separating behavior that changes independently.
Low cohesion is often a sign that a class has accumulated multiple responsibilities.
29. Quick Rule to Remember
Do not ask how many methods a class has; ask how many independent reasons it has to change.
30. Final Takeaway
Avoiding classes with multiple responsibilities is not about making every Java class tiny.
The goal is to create clear responsibility boundaries.
A production-quality class should represent a meaningful area of behavior.
Developers should remember:
- Keep related logic together.
- Separate concerns that evolve independently.
- Keep infrastructure details away from unrelated business logic.
- Use collaborators for meaningful responsibilities.
- Keep orchestration readable at the business-workflow level.
- Do not create classes merely to reduce line count.
During Pull Request review, check for:
- Unrelated methods
- Excessive dependencies
- Business and infrastructure logic mixed together
- Notification, reporting, persistence, and integration logic accumulated in one service
- Classes modified by many unrelated requirements
- Difficult unit-test setup
- Large workflows containing detailed implementation logic
Avoid both extremes in production code.
Do not build giant god classes that own the entire application.
But also do not create dozens of tiny classes with no meaningful responsibility.
The practical rule is:
Keep one cohesive responsibility per class, delegate independent concerns, and let high-level services orchestrate rather than implement every detail themselves.