Single Responsibility Principle

23 min read

Object-Oriented Design and SOLID Review — review Java classes for a single clear responsibility instead of several unrelated reasons to change.

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:

JAVA
InvoiceService
InvoiceCalculator
InvoiceRepository
InvoicePdfGenerator
InvoiceNotificationService
AccountingClient

InvoiceService 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:

JAVA
PaymentService

should 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:

JAVA
InvoiceNotificationService

instead 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:

JAVA
@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.

JAVA
@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:

CustomerService currently 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

JAVA
@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

JAVA
@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

JAVA
@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

JAVA
@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

JAVA
@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

JAVA
@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

AreaBad CodeImproved Code
ResponsibilityOne class performs many unrelated operationsResponsibilities are separated
ReadabilityBusiness flow mixed with technical detailsRegistration flow is visible
MaintainabilityMany independent changes affect one classChanges remain localized
TestabilityMany unrelated dependencies requiredComponents can be tested independently
Notification changesModify registration serviceModify notification service
CRM changesModify registration serviceModify CRM integration
Audit changesModify registration serviceModify audit service
Code reviewLarge mixed-responsibility PRsResponsibility-specific review is easier
ReliabilityHigher regression riskSmaller change surface

13. Real Project Scenario

Consider a healthcare appointment microservice.

Initially, one class called:

JAVA
AppointmentService

handles:

  • 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:

JAVA
AppointmentService
AppointmentValidator
DoctorAvailabilityService
InsuranceEligibilityService
AppointmentNotificationService
AppointmentBillingService
AppointmentAuditService

The 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:

JAVA
ApplicationManager
CommonHelper
UtilityService

often 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:

JAVA
OrderService -> PaymentService
PaymentService -> OrderService

This indicates that responsibilities need reconsideration.

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:

JAVA
InventoryService

may 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:

JAVA
AuthorizationService
TokenValidator
PasswordService
DataMaskingService

This 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:

JAVA
ApplicationService

containing customer, order, payment, reporting, and notification logic.

Utility Dumping Grounds

Avoid putting unrelated operations inside:

JAVA
CommonUtil
ProjectUtils
HelperService

One 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

  1. *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?*
  1. *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.*
  1. *PDF generation is separate from invoice business calculation. Consider moving document generation into an InvoicePdfGenerator so layout changes do not affect pricing code.*
  1. *This mapper logic has become substantial and is obscuring the service workflow. A dedicated mapper may make the responsibility boundary clearer.*
  1. *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.*
  1. *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.*
  1. *The external API call was moved to another bean, but please verify that this did not change the transaction and rollback behavior.*
  1. *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?*
  1. *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.*
  1. *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
JAVA
@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

JAVA
@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

JAVA
@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

JAVA
@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

JAVA
@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

JAVA
@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 @Transactional during 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:

JAVA
CustomerRegistrationService
CustomerProfileService
PasswordResetService
CustomerNotificationService
CustomerCrmService

I 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:

JAVA
PaymentService

they should primarily find payment behavior.

When they open:

JAVA
PaymentNotificationService

they should find payment notification behavior.

When they open:

JAVA
PaymentGatewayClient

they 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.