Loading learning content...
Throughout this module, we've debunked misconceptions and emphasized pragmatic balance. Now we take an even more nuanced view: some apparent SRP violations aren't violations at all.
There are legitimate design patterns, architectural necessities, and practical considerations that lead to classes handling what looks like multiple responsibilities. In many cases, these structures are not just acceptable—they're preferable to rigidly SRP-compliant alternatives.
This page develops your ability to distinguish between genuine SRP violations (which harm maintainability) and apparent violations (which serve design goals). The difference lies in understanding the deeper purpose behind the principle.
Junior developers learn rules. Senior developers learn when rules apply. Expert developers understand why rules exist—and therefore when 'violations' actually serve the rule's underlying purpose better than rigid compliance.
By the end of this page, you will recognize categories of acceptable SRP 'violations,' understand why they serve design goals, and confidently defend these decisions in code reviews and architectural discussions.
In Domain-Driven Design, an aggregate is a cluster of domain objects treated as a single unit. Aggregates often appear to have multiple responsibilities, but they actually represent one cohesive domain concept with multiple facets.
Why aggregates seem problematic:
An Order aggregate might handle:
To a surface-level SRP analysis, this looks like 5 responsibilities. But these aren't independent responsibilities—they're interdependent aspects of what an order fundamentally is.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374
/** * Order Aggregate - Acceptable "multi-responsibility" design * * This appears to have multiple responsibilities, but all methods * serve one conceptual responsibility: "being a complete, valid Order." * * The aggregate root ensures: * - Invariants are always maintained * - State transitions are valid * - Business rules are enforced * - The Order concept is coherent */public class Order { private final OrderId id; private final List<LineItem> items; private OrderStatus status; private PaymentDetails payment; private Money totalAmount; // ========== Line Item "Responsibility" ========== public void addItem(Product product, int quantity) { ensureEditable(); validateProduct(product); items.add(new LineItem(product, quantity)); recalculateTotal(); // Interdependency: items affect total } public void removeItem(LineItemId itemId) { ensureEditable(); items.removeIf(item -> item.getId().equals(itemId)); recalculateTotal(); // Interdependency } // ========== Pricing "Responsibility" ========== public Money getTotal() { return totalAmount; } private void recalculateTotal() { Money subtotal = items.stream() .map(LineItem::getAmount) .reduce(Money.ZERO, Money::add); this.totalAmount = applyTax(subtotal); // Interdependency: tax rules } // ========== State Management "Responsibility" ========== public void submit() { ensureNotEmpty(); // Invariant: can't submit empty order validateAllItemsInStock(); // Invariant: items must be available this.status = OrderStatus.SUBMITTED; } public void confirm(PaymentConfirmation paymentConfirmation) { ensureStatus(OrderStatus.SUBMITTED); this.payment = paymentConfirmation.toPaymentDetails(); this.status = OrderStatus.CONFIRMED; // Interdependency: payment + status } // ========== Validation "Responsibility" ========== private void ensureEditable() { if (status != OrderStatus.DRAFT) { throw new OrderNotEditableException(id, status); } } private void ensureNotEmpty() { if (items.isEmpty()) { throw new EmptyOrderException(id); } } // All these "responsibilities" are interdependent facets of ORDER // Splitting them would break invariant enforcement}Why splitting would be harmful:
If we extracted OrderLineItemManager, OrderPricingCalculator, OrderStateManager, and OrderValidator:
The aggregate design looks like multiple responsibilities but functions as one: maintaining a valid, consistent Order.
Domain-Driven Design explicitly endorses rich aggregates with many methods. The aggregate's responsibility is 'maintaining the integrity of this domain concept.' All methods serve that single, crucial responsibility—even if they touch different data aspects.
Some concerns inherently cross-cut multiple responsibilities. Extracting them can actually make code worse by scattering related logic or introducing awkward abstractions.
The logging dilemma:
Consider a payment processor that logs each step:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465
// Option 1: SRP-pure approach with extracted logging concernpublic class PaymentProcessor { private final PaymentGateway gateway; private final PaymentLogger logger; // Injected dependency public PaymentResult process(Payment payment) { logger.logPaymentStarted(payment); ValidationResult validation = validate(payment); logger.logValidationComplete(validation); if (!validation.isValid()) { logger.logPaymentRejected(payment, validation); return PaymentResult.rejected(validation); } GatewayResponse response = gateway.charge(payment); logger.logGatewayResponse(response); PaymentResult result = toResult(response); logger.logPaymentComplete(result); return result; }} // Problems:// - 8 lines of business logic, 5 lines of logging calls// - Logger needs to understand PaymentProcessor's internal flow// - PaymentLogger interface couples to PaymentProcessor internals// - Changing logging requires touching both classes // Option 2: Pragmatic approach with inline loggingpublic class PaymentProcessor { private final PaymentGateway gateway; private final Logger log = LoggerFactory.getLogger(PaymentProcessor.class); public PaymentResult process(Payment payment) { log.info("Processing payment: customerId={}, amount={}", payment.getCustomerId(), payment.getAmount()); ValidationResult validation = validate(payment); if (!validation.isValid()) { log.warn("Payment validation failed: {}", validation.getErrors()); return PaymentResult.rejected(validation); } log.debug("Validation passed, charging gateway"); GatewayResponse response = gateway.charge(payment); if (!response.isSuccessful()) { log.error("Gateway charge failed: {}", response.getError()); return PaymentResult.failed(response.getError()); } log.info("Payment successful: transactionId={}", response.getTransactionId()); return PaymentResult.success(response.getTransactionId()); }} // Benefits:// - Logging is contextual and immediately understood// - Business logic and its observability are co-located// - No artificial abstraction layer// - Changes to flow naturally include logging changesCross-cutting concerns that often stay inline:
| Concern | Why Inline Is Often Better |
|---|---|
| Logging | Log statements are contextual; extracting loses context |
| Metrics | Method-level metrics belong with the method |
| Simple validation | Validation of immediate inputs belongs at entry point |
| Exception translation | Catch-and-rethrow is clearest inline |
| Resource cleanup | try-with-resources is preferable to extracted cleanup |
When extraction IS warranted:
Cross-cutting concerns should be extracted when:
Ask: 'Does this cross-cutting concern need to understand the specific context of this class?' If yes (like contextual logging), keep it inline. If no (like generic authentication), extract it. Context-dependent concerns belong with their context.
Some classes exist specifically to coordinate multiple responsibilities. The Façade pattern and orchestration services appear to violate SRP by touching many concerns, but their actual responsibility is coordination itself.
The façade pattern:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455
/** * OrderFacade - Acceptable multi-concern coordination * * This class coordinates inventory, payment, shipping, and notification. * It APPEARS to have 4 responsibilities, but its ACTUAL responsibility * is "providing a simple interface for order processing." * * Each concern is implemented elsewhere; this facade orchestrates them. */public class OrderFacade { private final InventoryService inventoryService; private final PaymentService paymentService; private final ShippingService shippingService; private final NotificationService notificationService; /** * The facade's responsibility: Coordinate order processing workflow. * It doesn't IMPLEMENT any of these concerns; it ORCHESTRATES them. */ public OrderResult processOrder(Order order) { // Step 1: Reserve inventory InventoryReservation reservation = inventoryService.reserve(order.getItems()); if (!reservation.isSuccessful()) { return OrderResult.inventoryFailed(reservation); } try { // Step 2: Process payment PaymentResult payment = paymentService.charge(order.getPaymentMethod(), order.getTotal()); if (!payment.isSuccessful()) { inventoryService.release(reservation); // Rollback return OrderResult.paymentFailed(payment); } // Step 3: Arrange shipping ShippingLabel label = shippingService.createLabel(order.getShippingAddress(), order.getItems()); // Step 4: Notify customer notificationService.sendOrderConfirmation(order.getCustomerEmail(), order, label); return OrderResult.success(order, payment, label); } catch (Exception e) { inventoryService.release(reservation); // Rollback on any failure throw new OrderProcessingException(order.getId(), e); } }} // The facade's ONE responsibility: "Coordinate the order processing workflow"// It delegates each concern to specialists// Splitting the facade would scatter the workflow, making it harder to understandWhy coordination is a valid single responsibility:
Application services as orchestrators:
In layered architecture, application services often orchestrate multiple domain services:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
/** * UserRegistrationService - Application layer orchestrator * * Coordinates: validation, user creation, email verification, welcome flow. * Responsibility: "Orchestrating the user registration use case." */@Service@Transactionalpublic class UserRegistrationService { private final UserValidator validator; private final UserRepository userRepository; private final EmailVerificationService emailService; private final WelcomeFlowService welcomeFlow; private final EventPublisher eventPublisher; public RegistrationResult register(RegistrationRequest request) { // Validate ValidationResult validation = validator.validate(request); if (!validation.isValid()) { return RegistrationResult.invalid(validation); } // Check uniqueness if (userRepository.existsByEmail(request.getEmail())) { return RegistrationResult.emailTaken(); } // Create user User user = User.create(request.getEmail(), request.getName()); userRepository.save(user); // Initiate email verification emailService.sendVerification(user); // Start welcome flow welcomeFlow.initiate(user); // Publish event for other systems eventPublisher.publish(new UserRegisteredEvent(user)); return RegistrationResult.success(user); }} // This touches validation, persistence, email, welcome flow, and events.// But its single responsibility is: "Execute the registration use case."// Each concern is delegated; this class only coordinates.The key distinction: Façades and orchestrators DELEGATE concerns, they don't IMPLEMENT them. An orchestrator that calls paymentService.charge() is not responsible for payment logic—it's responsible for knowing WHEN to call it. This delegation preserves SRP at both levels.
Web controllers, message handlers, and CLI commands serve as infrastructure glue—they connect external interfaces to application logic. These classes often appear to mix concerns (HTTP handling, validation, business logic), but their role is fundamentally about translation and routing.
The controller dilemma:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879
/** * OrderController - Thin infrastructure glue * * Does this violate SRP? It handles: * - HTTP request mapping * - Request body parsing * - Validation triggering * - Service delegation * - Response formatting * - Error translation * * But its actual responsibility is: "Translate HTTP to application layer." */@RestController@RequestMapping("/api/orders")public class OrderController { private final OrderApplicationService orderService; private final OrderResponseMapper mapper; @PostMapping public ResponseEntity<OrderResponse> createOrder( @Valid @RequestBody CreateOrderRequest request, @AuthenticationPrincipal User user) { // Translate HTTP request to application command CreateOrderCommand command = new CreateOrderCommand( user.getId(), request.getItems(), request.getShippingAddressId() ); // Delegate to application layer OrderResult result = orderService.createOrder(command); // Translate application result to HTTP response if (result.isSuccessful()) { OrderResponse response = mapper.toResponse(result.getOrder()); return ResponseEntity.status(HttpStatus.CREATED).body(response); } return handleFailure(result); } @GetMapping("/{orderId}") public ResponseEntity<OrderResponse> getOrder( @PathVariable OrderId orderId, @AuthenticationPrincipal User user) { return orderService.findOrder(orderId, user.getId()) .map(mapper::toResponse) .map(ResponseEntity::ok) .orElse(ResponseEntity.notFound().build()); } @PutMapping("/{orderId}/cancel") public ResponseEntity<Void> cancelOrder( @PathVariable OrderId orderId, @AuthenticationPrincipal User user) { CancelResult result = orderService.cancelOrder(orderId, user.getId()); return result.isSuccessful() ? ResponseEntity.noContent().build() : ResponseEntity.status(HttpStatus.CONFLICT).build(); } private ResponseEntity<OrderResponse> handleFailure(OrderResult result) { return switch (result.getFailureType()) { case VALIDATION -> ResponseEntity.badRequest().body(errorResponse(result)); case INVENTORY -> ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse(result)); case PAYMENT -> ResponseEntity.status(HttpStatus.PAYMENT_REQUIRED).body(errorResponse(result)); default -> ResponseEntity.internalServerError().build(); }; }} // This controller touches HTTP, validation, authorization, services, and mapping.// But its ONE responsibility is: "Expose order operations via HTTP."// Splitting into CreateOrderController, GetOrderController, CancelOrderController// would scatter related endpoints with no benefit.Why thin controllers are acceptable:
| Factor | Controller Design |
|---|---|
| Business logic | Zero—completely delegated to services |
| Domain knowledge | Minimal—translates external to internal |
| Reason to change | HTTP interface changes (new endpoints, response formats) |
| Testing focus | Integration tests for HTTP behavior; unit tests on services |
| Natural grouping | Endpoints for one resource belong together |
When controllers DO violate SRP:
Controllers become problematic when they:
The test: 'If I change the HTTP framework, what in this controller changes?' If the answer is 'everything,' the controller is appropriately thin.
Infrastructure components (controllers, handlers, adapters) are responsible for TRANSLATION—converting between external protocols and internal abstractions. Judging them by domain SRP rules is a category error. Their SRP is: 'Correctly translate protocol X to application layer.'
Every application has composition root code—places where dependencies are wired together. These classes inherently touch many concerns because their purpose is to configure and connect the system.
Configuration classes:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556
/** * ApplicationConfiguration - Composition root * * This class configures: database, caching, messaging, security, and more. * It MUST touch all these concerns—that's its entire purpose. * * Responsibility: "Wire up application dependencies correctly." */@Configurationpublic class ApplicationConfiguration { @Bean public DataSource dataSource(DatabaseProperties props) { HikariConfig config = new HikariConfig(); config.setJdbcUrl(props.getUrl()); config.setUsername(props.getUsername()); config.setPassword(props.getPassword()); config.setMaximumPoolSize(props.getPoolSize()); return new HikariDataSource(config); } @Bean public CacheManager cacheManager(CacheProperties props) { CaffeineCacheManager manager = new CaffeineCacheManager(); manager.setCaffeine(Caffeine.newBuilder() .expireAfterWrite(props.getTtl()) .maximumSize(props.getMaxSize())); return manager; } @Bean public MessageQueue messageQueue(MessagingProperties props) { return new RabbitMQQueue(props.getHost(), props.getPort()); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(12); } @Bean public ObjectMapper objectMapper() { return new ObjectMapper() .registerModule(new JavaTimeModule()) .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); } @Bean public Clock clock() { return Clock.systemUTC(); // Enables testing with fake clocks }} // This touches database, caching, messaging, security, serialization...// But its ONE responsibility is: "Provide correctly configured dependencies."// Asking this class to follow domain-level SRP is nonsensical.Bootstrap and initialization code:
Similarly, application bootstrap code—main() methods, startup listeners, migration runners—necessarily coordinates multiple concerns:
1234567891011121314151617181920212223242526272829303132333435363738394041424344
/** * ApplicationBootstrap - Startup coordination * * Ensures correct initialization order: * 1. Validate configuration * 2. Run database migrations * 3. Warm caches * 4. Register with service discovery * 5. Start accepting traffic */@Componentpublic class ApplicationBootstrap implements ApplicationListener<ApplicationReadyEvent> { private final ConfigurationValidator configValidator; private final DatabaseMigrator migrator; private final CacheWarmer cacheWarmer; private final ServiceRegistry serviceRegistry; private final HealthIndicator healthIndicator; @Override public void onApplicationEvent(ApplicationReadyEvent event) { log.info("Application starting up..."); // Phase 1: Configuration configValidator.validate(); log.info("Configuration validated"); // Phase 2: Database migrator.migrate(); log.info("Database migrations complete"); // Phase 3: Caching cacheWarmer.warmCriticalCaches(); log.info("Caches warmed"); // Phase 4: Service discovery serviceRegistry.register(healthIndicator); log.info("Registered with service discovery"); log.info("Application ready to serve traffic"); }} // Responsibility: "Ensure correct application startup."// This MUST touch all startup concerns by definition.Configuration and bootstrap code operates at a different abstraction level than domain code. These classes are architectural glue—their purpose is to know about many things and connect them. Applying domain-level SRP to infrastructure-level composition is a category error.
Value objects often contain what looks like multiple responsibilities: parsing, validation, formatting, comparison, and arithmetic. But these operations are intrinsic to what the value represents.
Example: A Money value object:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109
/** * Money - Value object with multiple operations * * Operations include: * - Factory methods (parsing) * - Validation (invariant enforcement) * - Arithmetic (add, subtract, multiply) * - Comparison (equals, compareTo) * - Formatting (toString, for display) * * These aren't separate responsibilities—they're facets of "being Money." */public final class Money implements Comparable<Money> { public static final Money ZERO = new Money(BigDecimal.ZERO, Currency.USD); private final BigDecimal amount; private final Currency currency; // === Factory/Parsing "Responsibility" === public static Money of(BigDecimal amount, Currency currency) { requireNonNull(amount, "amount"); requireNonNull(currency, "currency"); if (amount.scale() > currency.getDecimalPlaces()) { throw new InvalidMoneyException("Too many decimal places"); } return new Money(amount, currency); } public static Money parse(String text) { // Parse "$100.00 USD" format } public static Money dollars(double amount) { return of(BigDecimal.valueOf(amount), Currency.USD); } // === Arithmetic "Responsibility" === public Money add(Money other) { requireSameCurrency(other); return new Money(amount.add(other.amount), currency); } public Money subtract(Money other) { requireSameCurrency(other); return new Money(amount.subtract(other.amount), currency); } public Money multiply(BigDecimal factor) { return new Money(amount.multiply(factor), currency); } public Money percentage(BigDecimal percent) { return multiply(percent.divide(BigDecimal.valueOf(100))); } // === Comparison "Responsibility" === public boolean isPositive() { return amount.signum() > 0; } public boolean isNegative() { return amount.signum() < 0; } public boolean isZero() { return amount.signum() == 0; } public boolean isGreaterThan(Money other) { requireSameCurrency(other); return amount.compareTo(other.amount) > 0; } @Override public int compareTo(Money other) { requireSameCurrency(other); return amount.compareTo(other.amount); } // === Formatting "Responsibility" === public String format() { return currency.format(amount); } public String formatWithSymbol() { return currency.getSymbol() + format(); } @Override public String toString() { return String.format("%s %s", amount, currency.getCode()); } // === Validation (enforced in constructor and operations) === private void requireSameCurrency(Money other) { if (!currency.equals(other.currency)) { throw new CurrencyMismatchException(currency, other.currency); } }} // All these operations answer ONE question: "How does Money behave?"// Extracting MoneyParser, MoneyArithmetic, MoneyFormatter would destroy// the coherence of the Money concept.Why value objects can be rich:
| Aspect of Value Object | Why It Belongs |
|---|---|
| Parsing | Creating valid instances is intrinsic to the type |
| Validation | Invariant enforcement is the type's core purpose |
| Arithmetic | Operations on the value are the value's behavior |
| Comparison | Equality and ordering are fundamental to values |
| Formatting | Representing the value for display is natural |
The test: 'Would these operations make sense on a different type?' If no—if they only make sense for Money, EmailAddress, or DateRange—they belong on that type.
Value objects encapsulate data AND the operations intrinsic to that data. Asking 'What can I do with Money?' should be answered by Money itself, not by external helper classes. This is cohesion, not SRP violation.
We've examined several categories of acceptable SRP 'violations.' Here's a practical checklist to determine if an apparent violation is actually fine:
The 7-Question Acceptance Test:
Interpretation:
Putting it into practice:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859
// Applying the checklist to OrderAggregate from earlier: /** * 1. ONE conceptual purpose? * YES - "Represent and manage a valid Order" * * 2. Would splitting break cohesion? * YES - Items, pricing, and status are interdependent invariants * * 3. Delegation not implementation? * PARTIAL - Some logic is internal, but domain entities should own behavior * * 4. Infrastructure glue? * NO - This is domain logic, not infrastructure * * 5. One actor/stakeholder? * YES - The Ordering team owns all Order behavior * * 6. Changes affect everything together? * YES - Order logic changes affect multiple methods * * 7. Does the name make sense? * YES - "Order" perfectly captures the concept * * RESULT: 6/7 YES - Acceptable, not an SRP violation */public class Order { // ... rich aggregate with many methods} // Applying to a problematic class: /** * 1. ONE conceptual purpose? * NO - Imports data AND sends notifications AND generates reports * * 2. Would splitting break cohesion? * NO - These are independent operations with no shared state * * 3. Delegation not implementation? * NO - Each operation fully implemented here * * 4. Infrastructure glue? * NO - This is application logic * * 5. One actor/stakeholder? * NO - Data team, marketing, and analytics all request changes * * 6. Changes affect everything together? * NO - Import changes don't affect notification logic * * 7. Does the name make sense? * NO - Named "DataHandler" with vague scope * * RESULT: 0/7 YES - Genuine SRP violation, needs refactoring */public class DataHandler { // ... should be split into DataImporter, NotificationService, ReportGenerator}We've explored the nuanced territory between genuine SRP violations and acceptable design decisions. Here are the key insights:
The Expert's Approach:
Mature engineers don't ask 'Does this violate SRP?' in isolation. They ask:
Principles exist to serve good design, not to be served by compliance. When a principle's mechanical application would produce worse design, the principle has reached its limit—and good judgment should take over.
You now possess a sophisticated understanding of SRP—not just what it says, but what it means, when it applies, and when its apparent violations are actually sound design. You can confidently navigate code reviews, design discussions, and architectural decisions with nuanced judgment rather than mechanical rule-following.