Single Responsibility Principle: Writing Clean and Maintainable Code

Reza Erami
3 min readSep 25, 2023

--

SRP suggests that classes, modules, and functions should each have a singular focus. The approach involves breaking down these entities into smaller components, each handling a distinct task. This strategy accelerates development and testing, enhancing understanding of each component’s individual role. By adhering to this principle, you can evaluate class scope to ensure it doesn’t breach SRP.

Let’s say we want to create a user class in which we obtain an image for the user and upload it to the S3 bucket. After obtaining the path to the uploaded file, we will update the database, and set the file path as the profile path for the user. So, let’s start with an empty class containing an empty method called updateAvatar

class UserService{
public async updateAvatar(id: number, file: any) {
try {
// connect to S3
// upload to S3
// get path
// find user and update it with given path
} catch (error) {
console.error('Avatar update failed:', error);
}
}
}

As you can see, we have an empty method for updating the avatar, which receives the path and updates the database. Now, let’s proceed to actually push it to S3.

class UserService{
public async updateAvatar(id: number, file: any) {
try {
const s3 = new AWS.S3();
const params = {
Bucket: 'my-bucket',
Key: `avatars/${this.id}-${Date.now()}.jpg`,
Body: file,
};

const uploadResult = await s3.upload(params).promise();
const avatarPath = uploadResult.Location;
} catch (error) {
console.error('Avatar update failed:', error);
}
}
}

Now that we have the upload result, we can update the database.

const user = await this.usersRepository.findById(id);
user.avatarPath = avatarPath;
await user.save();

So, we’re going to end up with a class that looks like this:

class UserService {
private usersRepository: UserRepository;

constructor(usersRepository: UserRepository) {
this.usersRepository = usersRepository;
}

public async updateAvatar(id: number, file: any) {
try {
const s3 = new AWS.S3();
const params = {
Bucket: 'my-bucket',
Key: `avatars/${this.id}-${Date.now()}.jpg`,
Body: file,
};

const uploadResult = await s3.upload(params).promise();
const avatarPath = uploadResult.Location;

const user = await this.usersRepository.findById(id);
user.avatarPath = avatarPath;
await user.save();
} catch (error) {
console.error('Avatar update failed:', error);
}
}
}

As you can see in the example above, both uploading a file to S3 and interacting with the database, specifically writing to it, have been combined within a single method. This means that our updateAvatar method has two responsibilities:

  1. Uploading a file.
  2. Writing to the database.

This practice contradicts the first principle of SOLID, which is the Single Responsibility Principle. Now, let’s refactor the code:

  1. First, we create a “File” class that is responsible for uploading files.
  2. Then, we create a “UserUpdater” class, which is responsible for updating user-related information.
  3. Finally, we can include the file upload method inside our updateAvatar method to orchestrate these two methods together.

By separating these responsibilities into distinct classes, we adhere to SOLID principles and maintain a more modular and maintainable codebase.

class FileService {
public async upload(file: any): Promise<string> {
const s3 = new AWS.S3();
const extension = this.getExtension(file);
const params = {
Bucket: 'my-bucket',
Key: `avatars/${Date.now()}.${extension}`,
Body: file,
};

const uploadResult = await s3.upload(params).promise();
return uploadResult.Location;
}

public getFileExtension(file: any): string {
const fileName = file.name || '';
const parts = fileName.split('.');
if (parts.length > 1)
return parts[parts.length - 1];
return '';
}
}

As you can see, our FileService class now has the sole responsibility of handling files and includes a method to upload a file. Now, let’s refactor the “User” class as well to adhere to the Single Responsibility Principle (SRP).

class UserService {
private usersRepository: UserRepository;
private fileService: FileService;

constructor(usersRepository: UserRepository, fileService: FileService) {
this.usersRepository = usersRepository;
this.fileService = fileService;
}

public async update(id: number, fields: Record<string, any>) {
let user = await this.usersRepository.findById(id);
user = {...user, ...fields};
await user.save();
return user;
}

public async updateAvatar(id: number, file: any) {
try {
const avatarPath = await this.fileService.upload(file);
await this.update(id, { avatar: avatarPath });
} catch (error) {
console.error('Avatar update failed:', error);
}
}
}

With this approach, we’ll have code that is more testable and reusable. The file uploader can be reused in other services, and the user update functionality can be used to update other fields of the user. However, when used together within the updateAvatar method, they will cooperate seamlessly. This design promotes modularity and maintainability in our codebase.

--

--