Continuing my series of reflections, like most people who start delving into SOLID, I would like to start with the letter "S". I will work with TypeScript in the examples.
In this article
Single Responsibility Principle
Abstract example
Technical example (Front-End)
Technical example (Back-End)
Personal example
Functional example
Applicabilities
Final thoughts
Single Responsibility Principle
The Single Responsibility Principle (or SRP) dictates that a class (or entity, if you prefer) should have only one single responsibility, and one single reason to change. This last part is the key to understanding the principle deeply.
In other words, the main purpose behind this idea is to ensure that we don't have classes with mixed purposes. Having a class A that was developed for purpose X, if a new functionality Y arises, it's necessary to analyze whether it makes sense for Y to be implemented in class A, or if it would make more sense in a new class B.
And how do we make this analysis? What are the criteria? Well, let's go through some examples.
Abstract example
Let's use a library as an example. Suppose I want to manage the books that exist in a library, as well as search for them. Our functionalities could be described as follows:
- Register books. Allow adding books with title and author.
- Remove books. Allow removing books by identification number.
- List all books.
- Search books by author.
- Search books by title.
🔴 Incorrect Implementation
// Only the interface of our books.
// Simple interface for the book, using constructor shorthand.
class Book {
constructor(public id: string, public title: string, public author: string) {}
}
class Library {
public books: Book[];
constructor() {
this.books = [];
}
// Method to add a book to the library.
public addBook(book: Book): void {
this.books.push(book);
}
// Method to remove a book from the library.
// Ignore the lack of validation and pure use of the id - it's for educational purposes.
public removeBook(id: string): void {
const index = this.books.findIndex((book) => (book.id = id));
this.books.splice(index, 1);
}
// Method to list the existing books in the library.
public listBooks(): void {
this.books.forEach((book) => {
console.log(Book "${book.title}", author "${book.author}"
);
});
}
// Method to search for a book by title.
// PRINCIPLE VIOLATION: The class has the responsibility to manage book registration.
// With this method, it also controls searches and algorithms linked to its manipulation.
// Therefore, it now has more than one reason to be modified.
public findBookByTitle(titleToSearch: string): Book[] {
// Assuming that the search algorithm below is modified or evolved, it would cause a change in a class
// that originally should only be responsible for managing book registration.
return this.books.filter((book) => book.title.includes(titleToSearch));
}
// Method to search for a book by author.
// PRINCIPLE VIOLATION: Same as above.
public findBookByAuthor(authorToSearch: string): Book[] {
return this.books.filter((book) => book.author.includes(authorToSearch));
}
}
// Creating object of the first book.
const journeyToTheCenterOfTheEarth = new Book(
"123",
"Journey to the Center of the Earth",
"Jules Verne"
);
// Creating object of the second book.
const theTimeMachine = new Book(
"456",
"The Time Machine",
"Herbert George Wells"
);
// Creating our library.
const library = new Library();
// Adding the books.
library.addBook(journeyToTheCenterOfTheEarth);
library.addBook(theTimeMachine);
// Listing the books.
library.listBooks();
// OUTPUT:
// Book "Journey to the Center of the Earth", author "Jules Verne"
// Book "The Time Machine", author "Herbert George Wells"
// Searching by author.
const search = library.findBookByAuthor('Julio');
console.log(search);
// OUTPUT:
// [ Book { id: '123', title: 'Journey to the Center of the Earth', author: 'Jules Verne' } ]
**🟢 Correct Implementation**
typescript
class Book {
constructor(public id: string, public title: string, public author: string) {}
}
// We removed the search logic from this class.
class Library {
public books: Book[];
constructor() {
this.books = [];
}
public addBook(book: Book) {
this.books.push(book);
}
public removeBook(id: string): void {
const index = this.books.findIndex((book) => (book.id = id));
this.books.splice(index, 1);
}
public listBooks(): void {
this.books.forEach((book) => {
console.log(Book "${book.title}", author "${book.author}"
);
});
}
}
// And we create a new one, which is responsible for searches.
class LibrarySearcher {
// We instantiate with the library object.
constructor(private library: Library) {}
// And finally, we do our searches.
public findBookByTitle(titleToSearch: string): Book[] {
return this.library.books.filter((book) => book.title.includes(titleToSearch));
}
// This even opens up the opportunity to optimize repeated code between searches.
public findBookByAuthor(authorToSearch: string): Book[] {
return this.library.books.filter((book) => book.author.includes(authorToSearch));
}
}
const journeyToTheCenterOfTheEarth = new Book(
"123",
"Journey to the Center of the Earth",
"Jules Verne"
);
const theTimeMachine = new Book(
"456",
"The Time Machine",
"Herbert George Wells"
);
const library = new Library();
library.addBook(journeyToTheCenterOfTheEarth);
library.addBook(theTimeMachine);
// Instantiating the search object.
const searcher = new LibrarySearcher(library);
// Searching by author.
const search = searcher.findBookByAuthor("Julio");
console.log(search);
// OUTPUT:
// [ Book { id: '123', title: 'Journey to the Center of the Earth', author: 'Jules Verne' } ]
---
## Technical example (Front-End)
Let's imagine we have a component class, and we want it to have the following behavior:
- **Display the component.** This component will display some text.
- **Wrap the component with another parent component.** Just a simple container.
**🔴 Incorrect Implementation**
typescript
// We imagine, in a somewhat abstract way, that we have a component class.
class Component {
// In its constructor, we pass a text and a wrapper indicator.
constructor(private text: string, private wrapper?: boolean) {}
// We have a method to render.
public render() {
// VIOLATION OF THE PRINCIPLE: Now, the component is not only responsible for displaying
// the text, but also for the wrapper. If it changes, the class also needs to be modified.
if (this.wrapper) {
return
{this.text}
}
return <p>{this.text}</p>
}
}
**🟢 Correct Implementation**
typescript
class Component {
constructor(private text: string) {}
// Now, the rendering of the Component only concerns the text component itself.
public render() {
return
{this.text}
}
}
// And we create a Wrapper class that will be responsible for wrapping the component.
class Wrapper {
constructor(private component: Component) {}
public wrap() {
return
}
}
// We no longer need the boolean "wrapper" because new Wrapper(...)
is optional.
const myText = new Component('Hello!');
const myWrapper = new Wrapper(myText);
---
## Technical example (Back-End)
Consider that we have a class to control access to the **Database**, and we want to have the following functionalities:
- **Connect to the database.**
- **Retrieve user data.** Specific search within a domain.
**🔴 Incorrect Implementation**
typescript
// Let's simulate a class that controls the usage of the database.
class Database {
constructor() {}
// We have a method that connects to the database.
public connect() {
console.log('Connected to the database!');
}
// PRINCIPLE VIOLATION: The class should only concern itself with the usage of the database,
// not with retrieving data from such a specific domain.
// If the database provider changes, the class would be altered. If the way of getting the user changes, too.
public getUser(id: string) {
this.connect();
console.log(Got user ${id}
);
}
}
**🟢 Correct Implementation**
typescript
// The Database class now concerns itself only with what is related to the database itself.
// This can be the connection, encapsulations, transactions, and everything else.
class Database {
constructor() {}
public connect() {
console.log("Connected to the database!");
}
}
// Meanwhile, the UserDatabase class has a bias more focused on a specific business domain.
// Besides being agnostic to the Database provider, it has a single reason to be altered - what is related to the user.
class UserDatabase {
constructor(private database: Database) {}
public getUser(id: string) {
this.database.connect();
console.log(Got user ${id}
);
}
}
---
## Personal example
Let's play around with a more personal example. As I mentioned in the first part, I like to think of some hobbies to interpret certain principles. Let's imagine a game like **Super Mario 64**, where I want to:
- **Collect coins.** Save the amount obtained.
- **Collect stars.** Save the amount obtained.
- **Move the player.** Update as needed.
**🔴 Incorrect Implementation**
typescript
// Let's create the class for the game as a whole.
class SuperMario64 {
// In this game, we control the quantities of coins, stars, and player position (x, y, z).
private coinCount: number = 0;
private starCount: number = 0;
private playerPosition = "0,0,0";
constructor() {}
// And then, we can play the game!
public playGame() {
console.log(
Coins: ${this.coinCount}, Stars: ${this.starCount}, Position: ${this.playerPosition}
);
}
// PRINCIPLE VIOLATION: This class should only concern itself with consuming the game state, and not
// updating something specific.
public getCoin() {
this.coinCount++;
}
// PRINCIPLE VIOLATION: Same as above.
public getStar() {
this.starCount++;
}
// PRINCIPLE VIOLATION: Same as above.
public updatePlayerPosition(newPosition: string) {
this.playerPosition = newPosition;
}
}
**🟢 Correct Implementation**
typescript
// We separated the player into its own class.
class Player {
constructor(public playerPosition: string) {}
updatePlayerPosition(newPlayerPosition: string) {
this.playerPosition = newPlayerPosition;
}
}
// We identified that stars and coins work in the same way, and created their own class.
class Collectible {
private count: number;
constructor() {}
public add() {
this.count++;
}
public getCurrent() {
return this.count;
}
}
// Now, the game consumes what it needs, instead of being responsible for it.
class SuperMario64 {
constructor(
public player: Player,
public coins: Collectible,
public stars: Collectible
) {}
public playGame() {
const playerPosition = this.player.playerPosition;
const coinCount = this.coins.getCurrent();
const starCount = this.stars.getCurrent();
console.log(
`Coins: ${coinCount}, Stars: ${starCount}, Position: ${playerPosition}`
);
}
}
---
## Functional example
Também na parte inicial desta série de artigos, mencionei que os paradigmas de SOLID costumam se aplicar ao padrão de onde surgiram - _Object-Oriented Programming_. Apenas para provar o conceito, gostaria de apresentar uma ideia utilizando a perspectiva da _Functional Programming_.
**🔴 Incorrect Implementation**
typescript
function readFileAndMap(fileName: string) {
// Suppose we are reading a file synchronously here.
const file = fileSystem.read(fileName);
// PRINCIPLE VIOLATION: We are processing the read data instead of just reading it.
const mappedContent = file.replace(/alguma-coisa/gm, 'outra-coisa');
return mappedContent;
}
const content = readFileAndMap('file-name');
**🟢 Correct Implementation**
typescript
function readFile(fileName: string) {
const file = fileSystem.read(fileName);
return file;
}
function mapContent(fileContent: string) {
const mappedContent = fileContent.replace(/alguma-coisa/gm, "outra-coisa");
return mappedContent;
}
// Here we separate the file reading moment from the mapping moment.
// Thus, to modify the mapping logic, we won't alter the function
// responsible solely for reading files, and we can reuse it for other
// scenarios with different mappings.
const fileContent = readFile("file-name");
const content = mapContent(fileContent);
---
## Applicabilities
With the examples described, the question arises: **"How can I apply this knowledge in my daily life?"**
The main point is **to analyze the entities involved and seek the responsibilities of the process**. Does the class you are handling already have a great deal of complexity? Or does it already have a well-defined responsibility? Then, perhaps it will be necessary to delegate its logic to a separate class.
Start paying closer attention to the entities you interact with, and try to identify the purpose of each one. You can even diagram, describe, or study the implementations to have a more concrete view of the class and its relationships.
---
## Final thoughts
It's quite likely that you'll encounter **several projects with mixed responsibilities** in your day-to-day, as not everyone is accustomed to observing development with this practical perspective. In such moments, **be cautious not to fall into the trap of excessive refactoring**. The desire to apply the principles may be strong, but you should adopt a **more analytical and careful stance**, as refactoring without planning can lead to unexpected outcomes (a topic for another article).
Remember that **experimentation** is highly valuable for understanding what works and what doesn't, **without overdoing it**. We must be very careful with what we call _over-engineering_, the application of concepts overly and extremely meticulously, especially in simple contexts.
The Single Responsibility Principle is an excellent starting point for understanding SOLID, and it's probably the one most people are familiar with. I hope these descriptions and examples have helped you to visualize it in a more practical way.
Top comments (0)