Pretend you’re constructing a house; you could put up some walls and a roof and say it’s all good. It may stand for a while, but what about when a storm comes? In codebase terms, this is where SOLID principles come into play.
SOLID principles are the architectural blueprint of a code. SOLID is an acronym that encapsulates these principles: single responsibility, open-closed, Liskov substitution, interface segregation, and dependency inversion.
They ensure that the code is not only strong but also robust. Think about it. Would you prefer a house with a fragile foundation that could collapse at the slightest breeze? Or a house with a strong foundation that could survive any storm?
The Single Responsibility Principle, or SRP, is the first letter of the SOLID design principles. I’d say it’s one of the most important principles you must understand to write clean and well-understandable code.
Let’s dive a little deeper.
In short, the SRP says that a class should have one reason to change. This definition is easy to comprehend, but let’s explore the key concepts of SRP further.
SRP clarifies “responsibility,” which is the role of a class in the system. The principle states that this responsibility must be well defined.
To comprehend what a responsibility within a system entails, below are some concrete aspects of a system:
These two are separate responsibilities of a system, they are different so you can have a class for each of these responsibilities.
Imagine a single employee of a company is responsible for product management, IT support, and human resource management.
If the company is planning to increase its product management capabilities, it indicates that the employee will be given more work. If IT support is lacking, the employee has to work harder. This means the job will be extremely overwhelming and daunting.
Now imagine a situation where the organization decides to have a focused team for each of the following:
One can expect focus, efficiency, and great results because each team is specialized in specific tasks and goals.
Now that we’ve discussed the theory behind single responsibility (SRP), let’s examine it in practice. We’ll start with an example of an SRP violation in which a class holds multiple responsibilities, and then refactor it to follow SRP correctly.
Let’s take the following UserService
class in TypeScript. It balances several different roles, including:
This is a violation of SRP because that class has more than one responsibility. These responsibilities should ideally be separated:
class UserService { constructor(private database: any) {} // Register a user registerUser(username: string, password: string): void { const hashedPassword = this.hashPassword(password); this.database.save({ username, password: hashedPassword }); this.sendWelcomeEmail(username); } // Hashes password private hashPassword(password: string): string { return `hashed_${password}`; // Simulating hashing } // Sends a welcome email private sendWelcomeEmail(username: string): void { console.log(`Sending welcome email to ${username}`); } }
When we finally apply SRP, we should separate concerns into different classes:
UserRepository
– This is used to perform database operationsAuthService
– Handles authenticationEmailService
– Sends emails:
// Database operations class UserRepository { save(user: { username: string; password: string }) { console.log("User saved to database:", user); } } // Authentication class AuthService { hashPassword(password: string): string { return `hashed_${password}`; // Simulating hashing } } // Email sending class EmailService { sendWelcomeEmail(username: string): void { console.log(`Sending welcome email to ${username}`); } } // UserService delegating responsibilities class UserService { constructor( private userRepository: UserRepository, private authService: AuthService, private emailService: EmailService ) {} registerUser(username: string, password: string): void { const hashedPassword = this.authService.hashPassword(password); this.userRepository.save({ username, password: hashedPassword }); this.emailService.sendWelcomeEmail(username); } } // Usage const userService = new UserService( new UserRepository(), new AuthService(), new EmailService() ); userService.registerUser("JohnDoe", "securePassword");
SRP seems quite simple to comprehend and use, but identifying SRP violations can be intimidating. In this section, we will provide some signs to look out for to know if a class is doing more, and thus violating the single responsibility principle.
When a class has more than one reason to change, it breaks the single responsibility design principle.
The class, in other words, is doing too much and wearing too many hats. For example, you have a class responsible for sending email alerts, user authentication, user authorization, and database transactions. You could already tell that it’s far too much responsibility for a single class, which goes against everything SRP stands for.
If a class has too many dependencies, this can be an issue for maintainability. Using multiple third-party services or libraries within a class can lead to the class growing and becoming complex. Now, these services are very tightly coupled with the class, and it becomes quite difficult to change the class without affecting the other classes in the system.
All Object-Oriented Programming (OOP) languages follow the SOLID design principles to their core. However, implementation differs across languages. In this section, we will see a code implementation of the SRP in Python, Java, TypeScript, and C#
SRP states that a workable class has only one responsibility, and thanks to Python’s dynamic and flexible nature, we can easily implement it.
Here’s an example of Python as an SRP-compliant design approach:
class UserRepository: def save(self, user): print(f"Saving user: {user}") class AuthService: def hash_password(self, password): return f"hashed_{password}" class EmailService: def send_welcome_email(self, username): print(f"Sending email to {username}") class UserService: def __init__(self, user_repo, auth_service, email_service): self.user_repo = user_repo self.auth_service = auth_service self.email_service = email_service def register_user(self, username, password): hashed_password = self.auth_service.hash_password(password) self.user_repo.save({"username": username, "password": hashed_password}) self.email_service.send_welcome_email(username) # Usage user_service = UserService(UserRepository(), AuthService(), EmailService()) user_service.register_user("JohnDoe", "secure123")
Java’s strict type system and interface-driven design help structure code to follow SRP.
Here’s an SRP-compliant approach in Java:
interface UserRepository { void save(User user); } class DatabaseUserRepository implements UserRepository { public void save(User user) { System.out.println("Saving user to database: " + user.getUsername()); } } class AuthService { public String hashPassword(String password) { return "hashed_" + password; } } class EmailService { public void sendWelcomeEmail(String username) { System.out.println("Sending welcome email to " + username); } } class UserService { private UserRepository userRepository; private AuthService authService; private EmailService emailService; public UserService(UserRepository userRepository, AuthService authService, EmailService emailService) { this.userRepository = userRepository; this.authService = authService; this.emailService = emailService; } public void registerUser(String username, String password) { String hashedPassword = authService.hashPassword(password); userRepository.save(new User(username, hashedPassword)); emailService.sendWelcomeEmail(username); } }
SRP helps to keep services and modules independent in TypeScript, which makes frontend code maintainable.
Here’s the SRP-compliant approach in TypeScript:
class UserRepository { save(user: { username: string; password: string }): void { console.log(`User saved: ${user.username}`); } } class AuthService { hashPassword(password: string): string { return `hashed_${password}`; } } class EmailService { sendWelcomeEmail(username: string): void { console.log(`Sending email to ${username}`); } } class UserService { constructor( private userRepository: UserRepository, private authService: AuthService, private emailService: EmailService ) {} registerUser(username: string, password: string): void { const hashedPassword = this.authService.hashPassword(password); this.userRepository.save({ username, password: hashedPassword }); this.emailService.sendWelcomeEmail(username); } } // Usage const userService = new UserService( new UserRepository(), new AuthService(), new EmailService() ); userService.registerUser("JohnDoe", "securePass");
C# encourages clean architecture with interfaces and dependency injection, enforcing SRP naturally.
Here’s the SRP-compliant approach in C#:
public interface IUserRepository { void Save(User user); } public class UserRepository : IUserRepository { public void Save(User user) { Console.WriteLine($"User saved: {user.Username}"); } } public class AuthService { public string HashPassword(string password) { return "hashed_" + password; } } public class EmailService { public void SendWelcomeEmail(string username) { Console.WriteLine($"Sending welcome email to {username}"); } } public class UserService { private readonly IUserRepository _userRepository; private readonly AuthService _authService; private readonly EmailService _emailService; public UserService(IUserRepository userRepository, AuthService authService, EmailService emailService) { _userRepository = userRepository; _authService = authService; _emailService = emailService; } public void RegisterUser(string username, string password) { string hashedPassword = _authService.HashPassword(password); _userRepository.Save(new User(username, hashedPassword)); _emailService.SendWelcomeEmail(username); } } // Usage UserService userService = new UserService(new UserRepository(), new AuthService(), new EmailService()); userService.RegisterUser("JohnDoe", "securePass");
Like every software design pattern, the single responsibility principle and other SOLID principles ensure that developers start writing high-quality code.
This principle is important because it allows you to:
Breaking code into smaller, more focused units makes it easier to understand and manage your code.
When classes are focused on a single responsibility, they can be reused across your application and different projects, making utility classes highly possible.
Smaller classes with a single responsibility are much easier to test because there are fewer cases to consider.
When bugs appear (and they will), you can debug the issue much quicker when your code structure adheres to SRP. SRP ensures that developers can pinpoint the source of a bug faster, as it provides a well-organized codebase that is easy to maintain.
The single responsibility principle provides a solid blueprint for writing high-quality code, but many developers misinterpret or misuse it. In this section, we will review some of the common misconceptions of SRP that developers should be mindful of.
This is a common misconception among developers, especially those who are new to the SRP. SRP talks about a class having only one responsibility, which means that everything about a single class should be about only one responsibility. A class can have as many methods as needed, as long as they all work together to ensure that the only responsibility is done well and effectively.
Many developers create needless classes in the name of using the single responsibility principle (SRP). Over-abstraction is the wrong way of applying SRP. Too much abstraction makes it difficult to understand the code.
SRP at the wrong abstraction level occurs when a developer applies the principle of separation at a level that doesn’t align with the structure of the system. The code may technically follow SRP by having only one “reason to change,” but that reason may be so trivial, or detached from the business logic that it adds unnecessary complexity rather than clarity. The wrong abstraction level can pose some serious problems, as maintenance becomes harder and components may lack flexibility.
The SRP provides an awesome experience in small projects. However, the real usefulness comes when the project’s complexity starts to grow.
SRP applies to system architecture as well as individual classes in large programs, ensuring that various components address various issues.
Below, we will examine how SRP is effectively used in modular monolithic architecture, which is a common pattern in small to medium projects, and microservices architecture, which is most common in industry-level applications.
At the service level, each service is in charge of a specific business capability. Microservices inherently enforce SRP.
Here’s how SRP works in microservices:
Let’s consider an example. A PaymentService
executes transactions, whereas a UserService
solely manages operations pertaining to users. If the payment logic needs to be changed, the UserService
will not be impacted.
The application is still a single unit in modular monoliths, but it is separated into SRP-compliant, well-structured modules.
Here’s how SRP works in modular monoliths:
UserModule
, BillingModule
)Let’s think of an example. An e-commerce platform can contain distinct modules for users, products, and orders, each focusing on a particular responsibility rather than a single monolithic service managing everything.
Effective implementation of the Single Responsibility Principle (SRP) requires careful planning and sensible decision-making, in addition to class separation.
Make sure the responsibilities are separate before dividing a class into many parts. Related functions may belong together, so not all multi-method classes violate SRP. Divide a class into distinct classes if it covers several business issues, but refrain from needless fragmentation.
Enforcing SRP at the architectural level helps avoid bloated classes in large applications. A layered architecture, which keeps the user interface, business logic, and data access distinct, is best practice. Additionally, modularizing your code organizes related functionality into well-structured modules or services.
SRP works best in combination with other SOLID principles, such as:
It is best to use dependency injection to pass services into classes instead of hard-coding dependencies.
Overapplying SRP too early can lead to excessive abstraction, making the system harder to manage. It is best practice to start simple, then refactor when you notice clear SRP violations. Use code smells (e.g., a class with too many dependencies) as signals for refactoring.
If a class has too many responsibilities, it may be checked with a good test suite. SRP may be broken if a single class needs to be tested for several independent functionalities. If a test becomes complicated because it covers several issues, you should probably rewrite the class.
A key idea in software design is the Single Responsibility Principle (SRP), which guarantees that any class, module, or component has just one reason to change. By using SRP, developers can produce code that is easier to debug, test, and extend, making it clearer, more maintainable, and scalable.
To provide flexibility and long-term maintainability, SRP carefully separates concerns rather than merely dividing classes.
There’s no doubt that frontends are getting more complex. As you add new JavaScript libraries and other dependencies to your app, you’ll need more visibility to ensure your users don’t run into unknown issues.
LogRocket is a frontend application monitoring solution that lets you replay JavaScript errors as if they happened in your own browser so you can react to bugs more effectively.
LogRocket works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app’s performance, reporting metrics like client CPU load, client memory usage, and more.
Build confidently — start monitoring for free.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowToday, we’ll be exploring the Open-Closed Principle: from the criticisms around it, its best use cases, and common misapplication.
Explore how AI-driven testing tools like Shortest, Testim, Mabl, and Functionize are changing how we do end-to-end testing.
Examine the difference between profit vs. cost center organizations, and the pros and cons these bring for the engineering team.
Explore how to pass functions and structured objects as parameters in TypeScript, including use cases, syntax, and practical scenarios.