1. Introduction
The Single Responsibility Principle, commonly called SRP, is one of the most useful object-oriented design principles in real Java projects.
It is frequently misunderstood as:
A class should contain only one method.
That is incorrect.
SRP is about keeping related responsibilities together and preventing one class from becoming responsible for several unrelated areas of the application.
In a typical Spring Boot project, a service class may gradually start handling:
- Request validation
- Business calculations
- Database operations
- Email notifications
- PDF generation
- External REST API calls
- Audit logging
- File processing
- Entity-to-DTO mapping
The application may continue working, but the class becomes harder to modify safely.
When one class owns several unrelated responsibilities, changes from different business requirements begin affecting the same file.
This creates:
- Large Pull Requests
- Merge conflicts
- Complicated testing
- Increased regression risk
- Difficult debugging
- Tight coupling
- Poor maintainability
SRP helps developers organize code around meaningful responsibilities so that each component has a clear purpose.
2. What This Topic Means
The Single Responsibility Principle states that a software component should have one primary responsibility and therefore one primary reason to change.
In practical Java development, this means a class should not combine unrelated concerns simply because they participate in the same workflow.
Consider an invoice-processing class.
If one class is responsible for:
- Calculating invoice totals
- Saving invoices
- Generating PDF documents
- Sending invoice emails
- Sending invoice information to an accounting system
then several independent requirements can force changes to the same class.
For example:
- Tax rules change.
- Database schema changes.
- PDF layout changes.
- Email provider changes.
- Accounting API changes.
These changes come from different reasons.
That is a strong indication that the class has too many responsibilities.
A better design separates those concerns into focused collaborators.
For example:
InvoiceService
InvoiceCalculator
InvoiceRepository
InvoicePdfGenerator
InvoiceNotificationService
AccountingClientInvoiceService can coordinate the business workflow while specialized components handle their respective responsibilities.
3. Why It Matters in Real Projects
Readability
A developer should be able to determine a class's purpose quickly.
A class called:
PaymentServiceshould primarily deal with payment-related business operations.
It should not unexpectedly contain:
- CSV parsing
- User authorization
- Email template generation
- Report formatting
Focused classes are easier to understand.
Maintainability
When responsibilities are separated, changing one concern requires touching fewer unrelated areas.
For example, changing an email provider should mainly affect notification-related code rather than payment calculation logic.
Debugging
Focused components make production failures easier to isolate.
If an invoice email fails, developers can investigate:
InvoiceNotificationServiceinstead of searching through a 1,500-line InvoiceService.
Reliability
Smaller responsibility boundaries reduce the chance that a change in one concern accidentally affects another concern.
Team Development
Different developers frequently work on:
- Business rules
- Database changes
- Integrations
- Notifications
When these concerns are separated, developers are less likely to modify the same large class simultaneously.
Testability
A class responsible for one concern usually requires fewer unrelated dependencies during testing.
A pricing calculation should not require mocking:
- Email services
- Repositories
- Kafka publishers
- PDF generators
unless those dependencies are genuinely part of the calculation.
4. Core Concept
The key SRP question is:
What kinds of changes can cause this class to be modified?
Suppose a class changes when:
- Discount policy changes
- Database schema changes
- Email template changes
- Payment provider changes
That class has multiple reasons to change.
A more focused design separates those responsibilities.
Consider order processing.
The orchestration service may look like:
@Service
public class OrderService {
private final OrderValidator orderValidator;
private final PricingService pricingService;
private final PaymentService paymentService;
private final OrderRepository orderRepository;
private final OrderNotificationService notificationService;
public OrderService(OrderValidator orderValidator,
PricingService pricingService,
PaymentService paymentService,
OrderRepository orderRepository,
OrderNotificationService notificationService) {
this.orderValidator = orderValidator;
this.pricingService = pricingService;
this.paymentService = paymentService;
this.orderRepository = orderRepository;
this.notificationService = notificationService;
}
@Transactional
public Order createOrder(OrderRequest request) {
orderValidator.validate(request);
BigDecimal totalAmount =
pricingService.calculateTotal(request.getItems());
PaymentResult paymentResult =
paymentService.processPayment(
request.getCustomerId(),
totalAmount
);
Order order = buildOrder(
request,
totalAmount,
paymentResult
);
Order savedOrder = orderRepository.save(order);
notificationService.sendOrderConfirmation(savedOrder);
return savedOrder;
}
private Order buildOrder(OrderRequest request,
BigDecimal totalAmount,
PaymentResult paymentResult) {
Order order = new Order();
order.setCustomerId(request.getCustomerId());
order.setTotalAmount(totalAmount);
order.setPaymentReference(
paymentResult.transactionReference()
);
order.setStatus(OrderStatus.CONFIRMED);
return order;
}
}The service still coordinates multiple steps.
That does not automatically violate SRP.
Its primary responsibility is:
Coordinate the order creation use case.
The specialized responsibilities remain elsewhere.
5. Important Rules
- Identify the primary responsibility of each class.
- Check how many independent reasons can cause the class to change.
- Keep unrelated infrastructure concerns separate from business logic.
- Avoid placing every operation inside one service class.
- Do not confuse SRP with "one method per class."
- Do not split classes mechanically based only on line count.
- Separate business calculations from notification delivery where appropriate.
- Separate external integration logic from core domain calculations.
- Keep persistence responsibilities in repository or persistence-oriented components.
- Avoid mixing file generation with business processing.
- Keep reusable validation rules in appropriate validators when they become substantial.
- Keep mapping logic separate when mapping becomes complex.
- Use orchestration services to coordinate focused collaborators.
- Prefer constructor injection so dependencies are explicit.
- Avoid creating a separate class for every few lines of code.
- Split responsibilities only when the separation represents a meaningful design boundary.
- Keep transaction boundaries in mind during refactoring.
- Preserve business behavior while extracting responsibilities.
- Avoid circular dependencies between extracted services.
- Do not use SRP as an excuse for unnecessary architecture.
6. Bad Code Example
Consider a Spring Boot customer-registration service.
@Service
public class CustomerService {
private final CustomerRepository customerRepository;
private final PasswordEncoder passwordEncoder;
private final EmailClient emailClient;
private final AuditRepository auditRepository;
private final CRMClient crmClient;
public CustomerService(CustomerRepository customerRepository,
PasswordEncoder passwordEncoder,
EmailClient emailClient,
AuditRepository auditRepository,
CRMClient crmClient) {
this.customerRepository = customerRepository;
this.passwordEncoder = passwordEncoder;
this.emailClient = emailClient;
this.auditRepository = auditRepository;
this.crmClient = crmClient;
}
@Transactional
public CustomerResponse registerCustomer(
CustomerRegistrationRequest request) {
if (request == null) {
throw new IllegalArgumentException(
"Customer request cannot be null"
);
}
if (request.getEmail() == null
|| request.getEmail().isBlank()) {
throw new IllegalArgumentException(
"Email is required"
);
}
if (customerRepository.existsByEmail(
request.getEmail())) {
throw new DuplicateCustomerException(
request.getEmail()
);
}
if (request.getPassword() == null
|| request.getPassword().length() < 8) {
throw new IllegalArgumentException(
"Password must contain at least 8 characters"
);
}
String encodedPassword =
passwordEncoder.encode(
request.getPassword()
);
Customer customer = new Customer();
customer.setName(request.getName());
customer.setEmail(request.getEmail());
customer.setPassword(encodedPassword);
customer.setStatus(CustomerStatus.ACTIVE);
customer.setCreatedAt(LocalDateTime.now());
Customer savedCustomer =
customerRepository.save(customer);
String subject = "Welcome to our service";
String body =
"Hello "
+ savedCustomer.getName()
+ ", your account has been created.";
try {
emailClient.send(
savedCustomer.getEmail(),
subject,
body
);
} catch (RuntimeException ex) {
System.out.println(
"Email failed: "
+ ex.getMessage()
);
}
AuditLog auditLog = new AuditLog();
auditLog.setAction("CUSTOMER_CREATED");
auditLog.setEntityId(
savedCustomer.getId().toString()
);
auditLog.setCreatedAt(LocalDateTime.now());
auditRepository.save(auditLog);
CRMCustomerRequest crmRequest =
new CRMCustomerRequest();
crmRequest.setCustomerId(
savedCustomer.getId()
);
crmRequest.setName(
savedCustomer.getName()
);
crmRequest.setEmail(
savedCustomer.getEmail()
);
crmClient.createCustomer(crmRequest);
CustomerResponse response =
new CustomerResponse();
response.setId(savedCustomer.getId());
response.setName(savedCustomer.getName());
response.setEmail(savedCustomer.getEmail());
response.setStatus(savedCustomer.getStatus());
return response;
}
}7. Problems in the Bad Code
Too Many Responsibilities
CustomerService is responsible for:
- Registration validation
- Password preparation
- Customer entity creation
- Database persistence
- Email content creation
- Email delivery
- Audit persistence
- CRM integration
- Response mapping
These concerns can change independently.
High Coupling
The service directly depends on several unrelated infrastructure components.
Adding more registration features will likely add even more dependencies.
Difficult Unit Testing
Testing customer registration requires mocking:
- Repository
- Password encoder
- Email client
- Audit repository
- CRM client
Even tests that only care about validation must deal with a class containing all these dependencies.
Notification Logic Is Embedded
A change to the welcome email template requires changing the customer business service.
CRM Integration Is Embedded
A change in CRM API contracts requires modification to the same class responsible for registration rules.
Audit Construction Is Embedded
Audit structure and persistence are infrastructure concerns that clutter the primary workflow.
Mapping Logic Is Embedded
Response mapping adds additional responsibility.
Poor Error Handling
Email errors are printed using System.out.println().
Production Risk
External CRM communication occurs inside the same transaction method.
Depending on transaction and integration semantics, this can create difficult partial-failure scenarios.
8. Code Review Findings
A senior reviewer should notice:
- The class has several unrelated reasons to change.
- Registration rules, notification behavior, auditing, CRM synchronization, and response mapping are combined.
- The number of dependencies indicates the service may have accumulated too many responsibilities.
- Email generation and delivery should not be embedded in registration business logic.
- CRM DTO construction should belong near the CRM integration layer.
- Audit creation should be handled through an audit-oriented component.
- External API behavior inside a transaction deserves careful review.
- Broad service responsibilities will make unit tests unnecessarily complex.
- Future changes will continue making this class larger.
- The service should remain an orchestrator rather than implementing every step itself.
9. Reviewer Comment Example
A professional PR comment could be:
CustomerServicecurrently owns registration validation, persistence, email notification, auditing, CRM synchronization, and response mapping. These concerns have independent reasons to change. Could we keep the service focused on orchestrating registration and move notification, auditing, CRM integration, and mapping into focused collaborators?
Another comment:
The welcome-email construction is unrelated to customer persistence rules. Please consider moving this into a CustomerNotificationService so email template/provider changes do not require modifying the registration service.
Another:
The CRM client call is inside the transaction. Please review whether a remote service failure should roll back the customer database transaction or whether synchronization should occur after commit/asynchronously.
10. Improved Code
Customer Registration Service
@Service
public class CustomerRegistrationService {
private final CustomerRegistrationValidator validator;
private final CustomerRepository customerRepository;
private final PasswordEncoder passwordEncoder;
private final CustomerNotificationService notificationService;
private final CustomerAuditService auditService;
private final CustomerCrmService crmService;
private final CustomerMapper customerMapper;
public CustomerRegistrationService(
CustomerRegistrationValidator validator,
CustomerRepository customerRepository,
PasswordEncoder passwordEncoder,
CustomerNotificationService notificationService,
CustomerAuditService auditService,
CustomerCrmService crmService,
CustomerMapper customerMapper) {
this.validator = validator;
this.customerRepository = customerRepository;
this.passwordEncoder = passwordEncoder;
this.notificationService = notificationService;
this.auditService = auditService;
this.crmService = crmService;
this.customerMapper = customerMapper;
}
@Transactional
public CustomerResponse register(
CustomerRegistrationRequest request) {
validator.validate(request);
Customer customer = createCustomer(request);
Customer savedCustomer =
customerRepository.save(customer);
auditService.recordCustomerCreated(
savedCustomer
);
crmService.synchronizeCustomer(
savedCustomer
);
notificationService.sendWelcomeEmail(
savedCustomer
);
return customerMapper.toResponse(
savedCustomer
);
}
private Customer createCustomer(
CustomerRegistrationRequest request) {
Customer customer = new Customer();
customer.setName(request.getName());
customer.setEmail(request.getEmail());
customer.setPassword(
passwordEncoder.encode(
request.getPassword()
)
);
customer.setStatus(
CustomerStatus.ACTIVE
);
customer.setCreatedAt(
LocalDateTime.now()
);
return customer;
}
}Registration Validator
@Component
public class CustomerRegistrationValidator {
private final CustomerRepository customerRepository;
public CustomerRegistrationValidator(
CustomerRepository customerRepository) {
this.customerRepository = customerRepository;
}
public void validate(
CustomerRegistrationRequest request) {
if (request == null) {
throw new IllegalArgumentException(
"Customer request cannot be null"
);
}
validateEmail(request.getEmail());
validatePassword(request.getPassword());
if (customerRepository.existsByEmail(
request.getEmail())) {
throw new DuplicateCustomerException(
request.getEmail()
);
}
}
private void validateEmail(String email) {
if (email == null || email.isBlank()) {
throw new IllegalArgumentException(
"Email is required"
);
}
}
private void validatePassword(
String password) {
if (password == null
|| password.length() < 8) {
throw new IllegalArgumentException(
"Password must contain at least 8 characters"
);
}
}
}Notification Service
@Service
public class CustomerNotificationService {
private static final Logger log =
LoggerFactory.getLogger(
CustomerNotificationService.class
);
private final EmailClient emailClient;
public CustomerNotificationService(
EmailClient emailClient) {
this.emailClient = emailClient;
}
public void sendWelcomeEmail(
Customer customer) {
try {
emailClient.send(
customer.getEmail(),
"Welcome to our service",
createWelcomeMessage(customer)
);
} catch (EmailDeliveryException ex) {
log.error(
"Failed to send welcome email for customerId={}",
customer.getId(),
ex
);
}
}
private String createWelcomeMessage(
Customer customer) {
return "Hello "
+ customer.getName()
+ ", your account has been created.";
}
}Audit Service
@Service
public class CustomerAuditService {
private final AuditRepository auditRepository;
public CustomerAuditService(
AuditRepository auditRepository) {
this.auditRepository = auditRepository;
}
public void recordCustomerCreated(
Customer customer) {
AuditLog auditLog = new AuditLog();
auditLog.setAction(
"CUSTOMER_CREATED"
);
auditLog.setEntityId(
customer.getId().toString()
);
auditLog.setCreatedAt(
LocalDateTime.now()
);
auditRepository.save(auditLog);
}
}CRM Integration Service
@Service
public class CustomerCrmService {
private final CRMClient crmClient;
public CustomerCrmService(
CRMClient crmClient) {
this.crmClient = crmClient;
}
public void synchronizeCustomer(
Customer customer) {
CRMCustomerRequest request =
new CRMCustomerRequest();
request.setCustomerId(
customer.getId()
);
request.setName(
customer.getName()
);
request.setEmail(
customer.getEmail()
);
crmClient.createCustomer(request);
}
}Customer Mapper
@Component
public class CustomerMapper {
public CustomerResponse toResponse(
Customer customer) {
CustomerResponse response =
new CustomerResponse();
response.setId(customer.getId());
response.setName(customer.getName());
response.setEmail(customer.getEmail());
response.setStatus(
customer.getStatus()
);
return response;
}
}11. Improved Code Explanation
Registration Service Becomes an Orchestrator
CustomerRegistrationService now focuses on one use case:
Register a customer.
It coordinates the necessary collaborators without implementing every technical detail itself.
Validation Has Its Own Responsibility
CustomerRegistrationValidator owns registration-specific validation rules.
Future changes to password or email rules can be handled there.
Notification Is Separate
CustomerNotificationService handles:
- Email construction
- Email delivery
- Notification-specific failure handling
Changing the email provider does not require changing customer registration rules.
Audit Logic Is Separate
CustomerAuditService owns audit recording.
Changes to audit persistence or format remain localized.
CRM Integration Is Separate
CustomerCrmService owns CRM communication and CRM-specific DTO creation.
A CRM API change no longer directly affects the core registration implementation.
Mapping Is Separate
CustomerMapper converts domain data into the API response.
This becomes especially useful when mapping grows beyond a few assignments.
Dependencies Communicate Architecture
The service's dependencies now describe the registration workflow clearly.
12. Bad Code vs Improved Code
| Area | Bad Code | Improved Code |
|---|---|---|
| Responsibility | One class performs many unrelated operations | Responsibilities are separated |
| Readability | Business flow mixed with technical details | Registration flow is visible |
| Maintainability | Many independent changes affect one class | Changes remain localized |
| Testability | Many unrelated dependencies required | Components can be tested independently |
| Notification changes | Modify registration service | Modify notification service |
| CRM changes | Modify registration service | Modify CRM integration |
| Audit changes | Modify registration service | Modify audit service |
| Code review | Large mixed-responsibility PRs | Responsibility-specific review is easier |
| Reliability | Higher regression risk | Smaller change surface |
13. Real Project Scenario
Consider a healthcare appointment microservice.
Initially, one class called:
AppointmentServicehandles:
- Appointment validation
- Doctor availability lookup
- Appointment creation
- Database persistence
- Insurance eligibility verification
- SMS notification
- Email notification
- Calendar integration
- Audit logging
- Billing request creation
Over several releases, different teams modify the same class.
The insurance team changes eligibility rules.
The notification team changes SMS providers.
The billing team changes payment requirements.
The platform team changes audit infrastructure.
Although these requirements are unrelated, every change affects AppointmentService.
The class eventually grows beyond one thousand lines.
A small notification modification now requires regression testing the complete appointment workflow because concerns are tightly mixed.
A better design may contain:
AppointmentService
AppointmentValidator
DoctorAvailabilityService
InsuranceEligibilityService
AppointmentNotificationService
AppointmentBillingService
AppointmentAuditServiceThe primary service coordinates appointment creation while each collaborator owns a clear concern.
14. Production Impact
Violating SRP does not automatically cause production failure.
The main risk appears during future modifications.
Regression Bugs
Changing one responsibility inside a large class may accidentally affect another responsibility.
Difficult Incident Investigation
When one class handles everything, stack traces and logs provide less architectural guidance about where the failure belongs.
Integration Failures
External API behavior mixed directly with database processing can create complex partial-failure scenarios.
Slow Production Fixes
Engineers require more time to understand large multipurpose classes before making emergency fixes.
Merge Conflicts
Different teams modifying the same large service increase source-control conflicts.
Testing Complexity
Large classes require extensive mocking and complicated test setup.
Maintenance Problems
Developers may avoid improving code because the impact of change becomes difficult to predict.
15. Common Developer Mistakes
Thinking SRP Means One Method Per Class
A class can contain many methods and still satisfy SRP if those methods contribute to the same responsibility.
Splitting Every Method Into a Separate Class
This creates unnecessary fragmentation.
SRP requires meaningful responsibility boundaries, not maximum class count.
Using Class Size as the Only Indicator
A 500-line class may have too many responsibilities.
However, a 50-line class can also violate SRP if it mixes unrelated concerns.
Creating "Manager" or "Helper" Classes
Classes such as:
ApplicationManager
CommonHelper
UtilityServiceoften become dumping grounds.
Extracting Responsibilities Without Clear Ownership
Moving code into another class helps only when the new class represents a clear responsibility.
Creating Circular Dependencies
Poorly chosen boundaries can result in:
OrderService -> PaymentService
PaymentService -> OrderServiceThis indicates that responsibilities need reconsideration.
Splitting Closely Related Business Logic
Do not separate code that naturally belongs to the same business responsibility simply to satisfy a theoretical interpretation of SRP.
Moving Everything Into Services
Entities, repositories, validators, mappers, and clients each have appropriate roles.
Not every extracted concern needs another @Service.
16. Edge Cases
Small Classes
Do not force SRP extraction when a small class already has a clear responsibility.
Shared Validation
If validation is tiny and specific to one operation, keeping it inside the service may be clearer.
If validation becomes substantial or reused, extraction may become appropriate.
Transactions
Moving logic between Spring beans can affect transaction behavior.
Review:
- Transaction propagation
- Rollback behavior
- Database consistency
- Remote calls
Spring Proxy Behavior
Calling a transactional method from another method within the same object may not behave as expected when relying on Spring proxy-based interception.
Refactoring responsibilities into separate Spring beans can change proxy boundaries and should be reviewed carefully.
External Service Failure
If notification, CRM, or message publication is extracted, define whether failure should:
- Fail the business operation
- Be retried
- Be logged and ignored
- Be handled asynchronously
Concurrency
Extraction does not automatically solve race conditions.
If several components modify the same entity, transaction and locking behavior still require careful review.
17. Performance Considerations
SRP is primarily a design and maintainability principle.
Separating responsibilities into classes does not normally create meaningful runtime overhead in typical Spring Boot applications.
Method and Bean Calls
The cost of normal Java method calls or Spring bean delegation is generally negligible compared with:
- Database queries
- Network calls
- File operations
- Serialization
Excessive Abstraction
Do not create unnecessary remote calls or serialization boundaries merely to separate responsibilities.
SRP does not mean converting each responsibility into a microservice.
Database Operations
Extraction can help expose inefficient persistence logic.
For example, separating:
InventoryServicemay make it easier to notice that inventory is queried inside a loop.
External API Calls
Dedicated integration components make expensive network operations easier to identify, monitor, retry, and optimize.
Performance-Critical Logic
If a performance optimization requires responsibilities to be combined, measure the actual impact before sacrificing maintainability.
18. Security Considerations
SRP can improve security by keeping security-sensitive responsibilities explicit.
Examples include:
AuthorizationService
TokenValidator
PasswordService
DataMaskingServiceThis makes security behavior easier to review.
However, excessive separation can also hide the complete security flow.
Reviewers should ensure:
- Authorization is not accidentally bypassed.
- Input validation remains applied at the correct boundary.
- Sensitive information is not passed unnecessarily between components.
- Password handling remains isolated from general business logic.
- Logs do not expose credentials or personal information.
- Security responsibilities are not duplicated inconsistently.
For example, password hashing belongs in a clearly defined security-related component rather than being repeated across unrelated services.
19. Testing Considerations
SRP usually improves testability because individual responsibilities can be tested with focused dependencies.
Registration Service Tests
Verify:
- Valid registration succeeds.
- Validator is called.
- Customer is saved.
- Audit operation is requested.
- CRM synchronization is requested.
- Notification is requested.
- Response mapping is performed.
Validator Tests
Test:
- Null request
- Missing email
- Blank email
- Missing password
- Short password
- Duplicate email
- Valid registration request
Notification Tests
Test:
- Welcome email contents
- Correct recipient
- Email provider failure
- Logging or failure behavior
CRM Tests
Test:
- Correct CRM request mapping
- CRM client failure
- Required fields
Unit Tests
Each component can have small focused tests rather than one large service test containing dozens of mocks.
Integration Tests
Integration tests should still verify that collaborators work together correctly for critical workflows.
SRP does not eliminate the need for end-to-end or integration testing.
20. Refactoring Guidelines
Step 1: Identify Reasons to Change
Review the class and ask:
- What business requirements modify this code?
- What infrastructure requirements modify this code?
- Which unrelated teams might modify this class?
Step 2: Identify Responsibility Clusters
Group related behavior.
Examples:
- Validation
- Pricing
- Notification
- Persistence
- Mapping
- External integration
- Audit
Step 3: Protect Existing Behavior
Add or verify tests before extraction.
Step 4: Extract One Responsibility at a Time
Do not redesign the whole module in one step.
For example:
First extract notification logic.
Then verify tests.
Next extract CRM integration.
Step 5: Keep the Original Service as Coordinator
After extraction, the primary service may continue to coordinate the use case.
Step 6: Review Dependency Direction
Ensure extracted components have sensible dependencies.
Avoid circular references.
Step 7: Review Transaction Boundaries
Moving code to another bean may affect:
@Transactional- Rollback
- Lazy loading
- Database consistency
Step 8: Review Failure Semantics
Do not accidentally change whether notification or external API failures cause the business transaction to fail.
Step 9: Avoid Over-Extraction
Stop when responsibilities are clear.
Do not create classes merely to satisfy an arbitrary design metric.
21. Best Practices
- Define one clear purpose for each service.
- Keep orchestration separate from substantial specialized logic.
- Use domain-specific component names.
- Extract external integrations into dedicated clients or integration services.
- Keep persistence access in repositories.
- Separate complex notification behavior.
- Extract reusable or substantial validation rules.
- Separate complex mapping when appropriate.
- Prefer constructor injection.
- Keep dependency direction clear.
- Avoid circular service dependencies.
- Keep transaction behavior visible.
- Preserve cohesive business logic together.
- Use package structure to communicate responsibilities.
- Add tests around each responsibility.
- Keep abstractions proportional to complexity.
- Separate responsibilities because they change independently, not merely because the class is long.
- Review responsibilities whenever a class continuously gains new dependencies.
22. Practices to Avoid
God Classes
Avoid classes responsible for large portions of the application.
Example:
ApplicationServicecontaining customer, order, payment, reporting, and notification logic.
Utility Dumping Grounds
Avoid putting unrelated operations inside:
CommonUtil
ProjectUtils
HelperServiceOne Class Per Method
Excessive fragmentation makes code navigation difficult.
Artificial Interfaces
Do not create interfaces for every class simply because SOLID principles are being discussed.
Create abstractions when they provide useful boundaries.
Unnecessary Microservices
SRP is a class/module design principle.
It does not imply that every responsibility needs its own deployable service.
Circular Dependencies
These usually indicate unclear boundaries.
Shared Mutable State Across Responsibilities
Avoid components that coordinate through hidden mutable fields.
Splitting Business Rules That Belong Together
Keep cohesive domain logic together.
SRP should improve understanding, not scatter a single rule across many files.
23. Code Review Checklist
A reviewer can ask:
- What is the primary responsibility of this class?
- Can that responsibility be explained clearly in one sentence?
- Does the class have several independent reasons to change?
- Are business logic and infrastructure concerns unnecessarily mixed?
- Does this service directly handle email formatting or delivery?
- Does this class directly construct external API-specific request objects?
- Is audit logic mixed with core business processing?
- Is complex DTO mapping embedded in the business service?
- Has the class accumulated too many unrelated dependencies?
- Are unrelated business operations being added to the same service?
- Would changing the email provider require changing core business logic?
- Would changing a database implementation affect unrelated calculations?
- Would changing an external API affect core domain rules?
- Are extracted components named according to real responsibilities?
- Has SRP been applied without unnecessary class fragmentation?
- Are transaction boundaries still correct after extraction?
- Are exception semantics preserved?
- Have circular dependencies been introduced?
- Can individual responsibilities be unit tested independently?
- Does the orchestration service clearly communicate the business workflow?
- Are closely related business rules still kept together?
- Is the design simpler after applying SRP?
24. Common Pull Request Review Comments
- *This service currently handles pricing, persistence, email generation, and external API synchronization. These responsibilities change independently; could we extract the notification and integration concerns into focused collaborators?*
- *The class now has nine dependencies covering unrelated concerns. That is a signal that the service may be taking on too many responsibilities. Please review whether some of these belong in dedicated components.*
- *PDF generation is separate from invoice business calculation. Consider moving document generation into an InvoicePdfGenerator so layout changes do not affect pricing code.*
- *This mapper logic has become substantial and is obscuring the service workflow. A dedicated mapper may make the responsibility boundary clearer.*
- *Please avoid extracting this three-line condition into a separate service only for SRP. The validation is specific and cohesive with this use case, so keeping it here may be simpler.*
- *The new component introduces a circular dependency between OrderService and PaymentService. We should revisit the ownership of this workflow rather than resolving the cycle with lazy injection.*
- *The external API call was moved to another bean, but please verify that this did not change the transaction and rollback behavior.*
- *CustomerService appears to be becoming a general-purpose class for all customer-related operations. Could we separate registration, account lifecycle, and notification responsibilities according to their use cases?*
- *The extracted class has no clear domain or technical responsibility beyond wrapping one repository call. I would keep this logic in the existing cohesive service unless we have a stronger boundary.*
- *The audit implementation is infrastructure-oriented and changes independently from payment processing. Consider moving it behind an audit component so payment logic remains focused.*
25. Code Review Exercise
Review the following Spring Boot service.
Identify:
- Problems
- Code smells
- Risks
- Improvements
@Service
public class LoanService {
private final LoanRepository loanRepository;
private final CustomerRepository customerRepository;
private final CreditScoreClient creditScoreClient;
private final EmailClient emailClient;
private final DocumentGenerator documentGenerator;
private final AuditRepository auditRepository;
public LoanService(
LoanRepository loanRepository,
CustomerRepository customerRepository,
CreditScoreClient creditScoreClient,
EmailClient emailClient,
DocumentGenerator documentGenerator,
AuditRepository auditRepository) {
this.loanRepository = loanRepository;
this.customerRepository = customerRepository;
this.creditScoreClient = creditScoreClient;
this.emailClient = emailClient;
this.documentGenerator = documentGenerator;
this.auditRepository = auditRepository;
}
@Transactional
public LoanResponse approveLoan(
Long customerId,
BigDecimal requestedAmount) {
Customer customer =
customerRepository.findById(customerId)
.orElseThrow(
() -> new CustomerNotFoundException(
customerId
)
);
if (requestedAmount == null
|| requestedAmount.compareTo(
BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException(
"Invalid loan amount"
);
}
int creditScore =
creditScoreClient.getCreditScore(
customer.getNationalId()
);
if (creditScore < 700) {
throw new LoanRejectedException(
"Credit score is too low"
);
}
Loan loan = new Loan();
loan.setCustomer(customer);
loan.setAmount(requestedAmount);
loan.setStatus(LoanStatus.APPROVED);
loan.setApprovedAt(LocalDateTime.now());
Loan savedLoan =
loanRepository.save(loan);
byte[] approvalDocument =
documentGenerator.generateLoanApproval(
savedLoan
);
emailClient.sendWithAttachment(
customer.getEmail(),
"Loan Approved",
"Your loan has been approved.",
approvalDocument
);
AuditLog auditLog = new AuditLog();
auditLog.setAction("LOAN_APPROVED");
auditLog.setEntityId(
savedLoan.getId().toString()
);
auditRepository.save(auditLog);
LoanResponse response =
new LoanResponse();
response.setLoanId(savedLoan.getId());
response.setAmount(savedLoan.getAmount());
response.setStatus(savedLoan.getStatus());
return response;
}
}26. Exercise Solution
Review Findings
LoanService Has Multiple Responsibilities
The class performs:
- Customer retrieval
- Request validation
- Credit assessment
- Loan approval
- Loan persistence
- Document generation
- Email delivery
- Audit recording
- DTO mapping
Several concerns can change independently.
Credit Integration Is Embedded
Changes to the external credit-score provider require modification to the loan service.
Document Generation Is Embedded
Changes to PDF layout or document technology should not affect loan approval logic.
Notification Is Embedded
Email provider and template changes are independent of loan business rules.
Audit Persistence Is Embedded
Audit infrastructure is unrelated to loan approval decision logic.
External Call Inside Transaction
The credit-score client, document generation, and email call occur around a transactional workflow.
Remote communication and transaction behavior should be reviewed carefully.
Improved Design
Loan Approval Service
@Service
public class LoanApprovalService {
private final CustomerRepository customerRepository;
private final LoanRepository loanRepository;
private final LoanEligibilityService eligibilityService;
private final LoanNotificationService notificationService;
private final LoanAuditService auditService;
private final LoanMapper loanMapper;
public LoanApprovalService(
CustomerRepository customerRepository,
LoanRepository loanRepository,
LoanEligibilityService eligibilityService,
LoanNotificationService notificationService,
LoanAuditService auditService,
LoanMapper loanMapper) {
this.customerRepository = customerRepository;
this.loanRepository = loanRepository;
this.eligibilityService = eligibilityService;
this.notificationService = notificationService;
this.auditService = auditService;
this.loanMapper = loanMapper;
}
@Transactional
public LoanResponse approveLoan(
Long customerId,
BigDecimal requestedAmount) {
Customer customer =
findCustomer(customerId);
eligibilityService.validateEligibility(
customer,
requestedAmount
);
Loan loan = createApprovedLoan(
customer,
requestedAmount
);
Loan savedLoan =
loanRepository.save(loan);
auditService.recordLoanApproved(
savedLoan
);
notificationService.sendApproval(
savedLoan
);
return loanMapper.toResponse(
savedLoan
);
}
private Customer findCustomer(
Long customerId) {
return customerRepository
.findById(customerId)
.orElseThrow(
() -> new CustomerNotFoundException(
customerId
)
);
}
private Loan createApprovedLoan(
Customer customer,
BigDecimal requestedAmount) {
Loan loan = new Loan();
loan.setCustomer(customer);
loan.setAmount(requestedAmount);
loan.setStatus(
LoanStatus.APPROVED
);
loan.setApprovedAt(
LocalDateTime.now()
);
return loan;
}
}Eligibility Service
@Service
public class LoanEligibilityService {
private static final int MINIMUM_CREDIT_SCORE = 700;
private final CreditScoreClient creditScoreClient;
public LoanEligibilityService(
CreditScoreClient creditScoreClient) {
this.creditScoreClient = creditScoreClient;
}
public void validateEligibility(
Customer customer,
BigDecimal requestedAmount) {
validateRequestedAmount(
requestedAmount
);
int creditScore =
creditScoreClient.getCreditScore(
customer.getNationalId()
);
if (creditScore
< MINIMUM_CREDIT_SCORE) {
throw new LoanRejectedException(
"Credit score is below the approval threshold"
);
}
}
private void validateRequestedAmount(
BigDecimal requestedAmount) {
if (requestedAmount == null
|| requestedAmount.compareTo(
BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException(
"Loan amount must be greater than zero"
);
}
}
}Notification Service
@Service
public class LoanNotificationService {
private final LoanDocumentGenerator documentGenerator;
private final EmailClient emailClient;
public LoanNotificationService(
LoanDocumentGenerator documentGenerator,
EmailClient emailClient) {
this.documentGenerator = documentGenerator;
this.emailClient = emailClient;
}
public void sendApproval(Loan loan) {
byte[] approvalDocument =
documentGenerator.generateApproval(
loan
);
emailClient.sendWithAttachment(
loan.getCustomer().getEmail(),
"Loan Approved",
"Your loan has been approved.",
approvalDocument
);
}
}Audit Service
@Service
public class LoanAuditService {
private final AuditRepository auditRepository;
public LoanAuditService(
AuditRepository auditRepository) {
this.auditRepository = auditRepository;
}
public void recordLoanApproved(
Loan loan) {
AuditLog auditLog = new AuditLog();
auditLog.setAction(
"LOAN_APPROVED"
);
auditLog.setEntityId(
loan.getId().toString()
);
auditRepository.save(
auditLog
);
}
}Mapper
@Component
public class LoanMapper {
public LoanResponse toResponse(
Loan loan) {
LoanResponse response =
new LoanResponse();
response.setLoanId(
loan.getId()
);
response.setAmount(
loan.getAmount()
);
response.setStatus(
loan.getStatus()
);
return response;
}
}Why These Changes Are Useful
LoanApprovalService now owns the loan approval use case.
Eligibility rules are isolated from notification concerns.
Credit-score integration belongs to the eligibility responsibility.
Document generation and email delivery belong to loan notification.
Audit persistence belongs to an audit-oriented component.
Mapping has an explicit location.
Each area can evolve without forcing unrelated changes into the loan approval service.
The design also makes tests more focused.
However, the transaction boundary and notification behavior still require an architectural decision.
In a production system, it may be preferable to trigger notification after the database transaction commits rather than performing email delivery directly inside the transaction.
SRP improves responsibility boundaries, but transaction consistency must still be designed deliberately.
27. Interview Perspective
SRP is one of the most common SOLID topics in Java interviews.
However, experienced interviewers usually do not stop at:
What is the definition of SRP?
They may ask:
- How do you identify an SRP violation in an existing service?
- Does a class with ten methods violate SRP?
- Does a class with one method automatically follow SRP?
- How would you refactor a large Spring Boot service?
- How do you avoid over-engineering while applying SRP?
- Should validation always be a separate class?
- Should every external integration have its own service?
- How does SRP improve testing?
- What happens to
@Transactionalduring refactoring? - What is the difference between method extraction and responsibility extraction?
- When should a responsibility move to another class?
Strong answers should focus on:
- Reasons to change
- Cohesion
- Business boundaries
- Infrastructure separation
- Dependency management
- Testing
- Transaction behavior
- Practical tradeoffs
28. Interview Questions and Answers
Basic Question
Question: What is the Single Responsibility Principle?
Answer:
The Single Responsibility Principle means a class or component should have one primary responsibility and one primary reason to change.
It does not mean one method per class.
For example, an invoice calculation component may contain several methods related to pricing and tax calculations while still having one responsibility.
A problem appears when the same class also handles unrelated concerns such as email delivery and PDF generation.
Intermediate Question
Question: How do you identify an SRP violation in a Spring Boot service?
Answer:
I look at the reasons the class may need to change.
If a service changes because of:
- Business-rule changes
- Email-provider changes
- Database changes
- External API changes
- Report-format changes
then several responsibilities may be combined.
I also inspect:
- Number and type of dependencies
- Unrelated methods
- Test complexity
- Large mixed workflows
- Repeated infrastructure logic
A large dependency list is not proof of an SRP violation, but it is a useful review signal.
Advanced Question
Question: Does an orchestration service violate SRP because it calls several other services?
Answer:
Not necessarily.
An orchestration service can have one responsibility:
Coordinate a specific business use case.
For example, an OrderPlacementService may call:
- InventoryService
- PricingService
- PaymentService
- OrderRepository
- NotificationService
The service can still follow SRP if its responsibility is coordinating order placement and specialized logic remains inside appropriate collaborators.
SRP concerns responsibility boundaries, not simply the number of dependencies or method calls.
Scenario-Based Question
Question: A 600-line CustomerService handles registration, profile updates, password reset, notification, reporting, and CRM synchronization. How would you refactor it?
Answer:
First, I would avoid splitting it purely based on line count.
I would identify separate reasons to change.
Possible responsibilities might include:
CustomerRegistrationService
CustomerProfileService
PasswordResetService
CustomerNotificationService
CustomerCrmServiceI would protect existing behavior with tests and extract one responsibility at a time.
I would also verify:
- Transaction boundaries
- Exception behavior
- Spring bean dependencies
- Circular dependency risks
- External API failure semantics
The goal is clear ownership, not creating as many classes as possible.
Code-Review Question
Question: What would you comment if a PR adds PDF generation directly into an existing payment service?
Answer:
A useful comment would be:
PDF generation changes for reasons unrelated to payment processing. Could we move this into a dedicated receipt/document component and keep PaymentService focused on payment workflow?
This identifies both the design problem and the reason for the suggested change.
Real-Project Question
Question: How does SRP help large development teams?
Answer:
When responsibilities are separated, different teams can modify different components with fewer conflicts.
For example:
- Payment engineers can change payment processing.
- Notification engineers can change email delivery.
- Integration engineers can change external clients.
This improves:
- Code ownership
- Pull Request review
- Testing
- Merge conflict frequency
- Change isolation
- Production debugging
It also reduces dependency on developers having detailed knowledge of unrelated concerns.
29. Quick Rule to Remember
If one class changes because several unrelated business or technical concerns change, it probably owns too many responsibilities.
Do not ask only:
How many methods does this class have?
Ask:
Why does this class need to change?
30. Final Takeaway
The Single Responsibility Principle is not about making every Java class tiny.
It is about giving each component clear ownership.
What the Developer Should Remember
- Identify the real responsibility of the class.
- Group cohesive behavior together.
- Separate concerns that change independently.
- Keep business logic separate from unrelated infrastructure details.
- Use focused collaborators where they provide meaningful boundaries.
- Preserve transaction and exception behavior during refactoring.
- Avoid circular dependencies.
- Do not over-engineer small, cohesive code.
What the Reviewer Should Check
A reviewer should determine:
- Can the class's responsibility be explained clearly?
- Does the class have several unrelated reasons to change?
- Are notifications mixed with business calculations?
- Are external integrations embedded directly in unrelated services?
- Is substantial mapping or document generation cluttering business workflow?
- Has the class accumulated too many unrelated dependencies?
- Are extracted components genuinely cohesive?
- Has the refactoring preserved transaction behavior?
- Are responsibilities easier to test independently?
- Is the resulting design simpler and easier to maintain?
What Should Be Avoided in Production Code
Avoid:
- God classes
- Multipurpose service classes
- Utility dumping grounds
- Business logic mixed with unrelated infrastructure code
- Notification logic scattered across services
- External API DTO mapping inside core domain logic
- Artificial one-method classes
- Circular service dependencies
- Unnecessary interfaces
- Excessive abstraction
- Applying SRP mechanically based only on class size
A well-designed Spring Boot service should make its purpose obvious.
When a developer opens:
PaymentServicethey should primarily find payment behavior.
When they open:
PaymentNotificationServicethey should find payment notification behavior.
When they open:
PaymentGatewayClientthey should find external payment-provider communication.
Clear responsibility boundaries make Java applications easier to review, test, debug, modify, and scale as both the codebase and development team grow.