Loading content...
Open the package structure of any long-lived project and you'll often find a jungle. The utils package has 47 classes. The services package imports from every other package. The common module is a dependency of everything, making it practically impossible to modify without potentially breaking the entire system.
This chaos emerges when package design happens accidentally—classes grouped by type (controllers, services, repositories) rather than by responsibility, or thrown into catch-all packages when their home wasn't obvious.
Module-level SRP is the antidote. It ensures that packages and modules—the organizational units above classes—each have a single, cohesive reason to exist, making the overall codebase navigable, maintainable, and evolvable.
By the end of this page, you will understand how to design packages with clear responsibilities, how to evaluate package cohesion, how to structure projects for team autonomy, and how package design directly impacts build times, deployment strategies, and system maintainability.
Before applying SRP at this level, we must define what we mean by 'module'. The term is overloaded in software:
Regardless of terminology, a module at this level is:
A cohesive collection of classes that encapsulates a specific capability and exposes a deliberate public interface.
SRP at the module level means each such collection should have a single, focused reason to exist—a single capability it provides to the rest of the system.
In some languages (like Java before modules), packages are purely organizational—they don't enforce encapsulation. Classes in any package can access public members of any other package. True modules provide stronger boundaries with explicit exports. The principles apply to both, but the enforcement mechanisms differ.
Most codebases suffer from one or more anti-patterns that undermine module-level SRP. Recognizing these patterns is the first step toward healthier organization.
user, order, product become too broad. What aspect of users? Authentication? Profiles? Preferences?1234567891011121314151617181920212223242526272829303132
# ANTI-PATTERN: Layer-based structurecom.company.app/├── controllers/ # Every feature has controllers here│ ├── UserController│ ├── OrderController│ ├── ProductController│ └── ... (50 more controllers)├── services/ # Every feature has services here│ ├── UserService│ ├── OrderService│ └── ... (80 more services)├── repositories/│ ├── UserRepository│ └── ... (40 more repositories)├── dtos/│ ├── UserDTO│ └── ... (120 more DTOs)└── utils/ # The dreaded dumping ground ├── StringUtils ├── DateUtils ├── ValidationHelper ├── CryptoHelper └── ... (200 random utilities) # Problem: Adding "password reset" feature requires:# - Modifying controllers/# - Modifying services/# - Modifying repositories/# - Adding to dtos/# - Maybe something in utils/# # 5 packages touched for one cohesive feature!When a feature's code is scattered across many packages, every change requires coordinating modifications in multiple locations. This increases the chance of inconsistencies, makes code reviews harder (reviewing 10 files in 10 packages vs 10 files in 1 package), and prevents teams from owning complete features.
The most impactful shift toward module-level SRP is organizing by feature (or vertical slice) rather than by layer. This means grouping all classes related to a single capability together:
All in one package, instead of scattered across the codebase.
123456789101112131415161718192021222324252627282930313233343536373839
# BETTER: Feature-based structurecom.company.app/├── authentication/ # Single responsibility: Authentication│ ├── AuthenticationController│ ├── AuthenticationService│ ├── TokenRepository│ ├── LoginRequest│ ├── AuthToken│ └── internal/ # Package-private implementation│ ├── JwtTokenGenerator│ └── PasswordHasher├── user.profile/ # Single responsibility: User profiles│ ├── ProfileController│ ├── ProfileService│ ├── ProfileRepository│ └── ProfileDTO├── user.preferences/ # Single responsibility: User preferences│ ├── PreferencesController│ ├── PreferencesService│ └── UserPreferences├── ordering/ # Single responsibility: Order management│ ├── OrderController│ ├── OrderService│ ├── OrderRepository│ ├── Order│ └── OrderLineItem├── payment/ # Single responsibility: Payment processing│ ├── PaymentController│ ├── PaymentService│ ├── PaymentGatewayClient│ └── PaymentResult└── shared.kernel/ # Truly shared concepts only ├── Money ├── EntityId └── AuditInfo # Adding "password reset" now:# - Add to authentication/ only# - Single package, single responsibility, single teamRobert Martin suggests that your project structure should 'scream' its purpose. Looking at a healthcare app's package structure, you should see 'patient-records', 'appointments', 'prescriptions'—not 'controllers', 'services', 'repositories'. The domain should be evident at first glance.
Robert Martin articulated three principles that guide module cohesion—specifically, what classes belong together in a module. These principles help operationalize SRP at the package level.
1. The Reuse/Release Equivalence Principle (REP):
The granule of reuse is the granule of release.
Classes that are reused together should be released together. If module A contains classes X, Y, and Z, and a client only needs X, they're forced to depend on Y and Z as well. Modules should be cohesive enough that clients need all their contents.
Implication: Don't mix unrelated utilities in a 'commons' package. If email utilities are released with string utilities, changes to string handling force clients using only email to take updates.
2. The Common Closure Principle (CCP):
Classes that change together belong together.
This is SRP restated for modules. If a change in requirements affects multiple classes, those classes should be in the same module to localize the change.
Implication: Group classes by what causes them to change. If payment regulation changes affect PaymentValidator, PaymentProcessor, and PaymentReporter, they belong in the same payment module—not scattered across validation, processing, and reporting packages.
3. The Common Reuse Principle (CRP):
Classes that aren't reused together shouldn't be together.
The contrapositive of cohesion. If two classes are in the same module but clients typically use only one, they shouldn't be packaged together.
Implication: Avoid the 'commons' package. If StringUtils and DateUtils are in the same module but used by completely different clients, separate them. Forcing DateUtils dependency on clients who only need StringUtils couples them unnecessarily.
| Principle | Rule | Violation Smell |
|---|---|---|
| REP | Reuse unit = Release unit | Clients forced to take unused dependencies |
| CCP | Together if change together | One feature change touches many modules |
| CRP | Apart if used apart | 'Utils' packages; grab-bag modules |
These principles are in tension. REP and CCP group classes together (for reuse and change locality). CRP splits them apart (to avoid unused dependencies). Architecture is about finding the right balance for your context. Early in a project, favor CCP (localize changes). As the system matures and is reused more widely, CRP gains importance.
A module's responsibility is only as clear as its boundaries. When modules depend on each other freely, responsibilities blur and changes propagate unexpectedly. Healthy module dependencies follow patterns:
1234567891011121314151617181920212223242526272829303132333435
// MODULE: payment (higher-level, depends on abstractions)package com.company.payment; // Depends on interface, not implementationpublic class PaymentService { private final PaymentGateway gateway; // Interface private final AuditLog auditLog; // Interface public PaymentResult process(Payment payment) { // Business logic using abstractions }} // Public interface - this module's contractpublic interface PaymentGateway { ChargeResult charge(PaymentMethod method, Money amount); RefundResult refund(TransactionId id, Money amount);} // MODULE: payment-stripe (lower-level, implements abstraction)package com.company.payment.stripe; // Implements the interface, depends on payment modulepublic class StripePaymentGateway implements PaymentGateway { private final StripeClient stripeClient; // Stripe SDK @Override public ChargeResult charge(PaymentMethod method, Money amount) { // Stripe-specific implementation }} // Dependency direction: concrete → abstract// payment-stripe → payment// payment module doesn't know Stripe existsIf you find circular dependencies (A → B → A), the usual solutions are: 1) Extract the shared concept into a third module that both depend on, 2) Invert one dependency using an interface, 3) Merge the modules if they're really one responsibility split artificially.
Conway's Law states that organizations produce systems mirroring their communication structures. This manifests powerfully at the module level:
The ideal module is owned by exactly one team and contains everything that team needs to deliver their capability.
When module boundaries align with team boundaries, each team can:
When boundaries misalign, you get delays, meetings, and distributed responsibility (which means no responsibility).
| Pattern | Description | Result |
|---|---|---|
| 1 Module : 1 Team | Each module owned by one team | Optimal autonomy and accountability |
| N Modules : 1 Team | One team owns multiple modules | Workable if modules are related |
| 1 Module : N Teams | Module shared across teams | Coordination overhead, unclear ownership |
| Scattered Ownership | No clear ownership model | Technical debt accumulates rapidly |
Designing for Team Autonomy:
When structuring modules, consider:
12345678910111213141516171819202122232425
# Team-aligned module structure Team: Payments├── payment-core/ # Payment domain logic├── payment-gateway-stripe/ # Stripe integration├── payment-gateway-paypal/ # PayPal integration└── payment-reporting/ # Payment analytics Team: Ordering├── order-management/ # Order lifecycle├── order-fulfillment/ # Shipping/delivery└── order-analytics/ # Order metrics Team: Customers├── customer-profile/ # Customer data├── customer-auth/ # Authentication└── customer-preferences/ # Settings/preferences Team: Platform├── shared-kernel/ # Truly shared domain concepts├── infrastructure/ # Cross-cutting concerns└── observability/ # Monitoring/logging # Each team owns their modules completely# Collaboration happens at module boundaries via APIsAmazon's 'two-pizza team' rule (teams small enough to be fed by two pizzas) extends to modules. If a module is too large to be understood and maintained by a two-pizza team, it may be doing too much. Consider decomposition.
A module's responsibility is expressed through its public API—the set of classes, interfaces, and methods it exposes to consumers. A well-designed module API:
1234567891011121314151617181920212223242526272829303132333435363738394041
// MODULE: notification// Good: Clean, minimal public APIpackage com.company.notification; // The public contract - what consumers seepublic interface NotificationService { void send(Notification notification); List<Notification> getPending(UserId userId); void markRead(NotificationId id);} public record Notification( NotificationId id, UserId recipient, String title, String body, Instant createdAt) {} // That's it. Three methods, one data type.// The entire capability is accessible through this surface. // Internal structure - hidden from consumers// package-private or in 'internal' subpackageclass NotificationServiceImpl implements NotificationService { private final NotificationRepository repo; private final ChannelRouter router; private final RateLimiter rateLimiter; private final TemplateEngine templates; // Rich internal implementation, completely hidden} class EmailChannel implements NotificationChannel { ... }class PushChannel implements NotificationChannel { ... }class SMSChannel implements NotificationChannel { ... }class ChannelRouter { ... }class NotificationTemplate { ... } // Consumers depend only on the 3-method interface// Internal refactoring is invisible to themMany projects use an 'internal' subpackage convention to signal 'use at your own risk.' Java 9+ modules can enforce this with 'exports' declarations. Even without enforcement, the convention communicates intent: these classes may change without notice.
Module-level SRP is about organizing the codebase for clarity, team autonomy, and sustainable evolution. Get this right, and your system can grow for years without becoming unmanageable.
What's Next:
We've covered SRP from methods through classes to modules—the scales within a single codebase. In the next page, we'll zoom out further to architectural-level SRP: how entire subsystems, services, and bounded contexts should each embody a single responsibility, and how this shapes microservices design and system decomposition.
You now understand SRP at the module level—how to structure packages for cohesion, manage dependencies cleanly, align with team ownership, and design minimal public APIs. Next, we'll explore SRP at the architectural scale.