Avoiding the pitfalls of abstract and common services

In the ever-evolving world of software engineering, creating robust and maintainable applications is a constant challenge. One common mistake that engineers often make is creating overly common or abstract services. While the idea of reusability and abstraction is appealing, it can lead to a host of problems, including violations of the single responsibility principle and the open/closed principle. In this article, we will delve into the issues that engineers may encounter when creating generic services and highlight the importance of embracing specificity.

What are abstract or common services?

An abstract or common service refers to a service that aims to provide generic functionality or handle multiple related tasks in a general manner. It is designed to be reused across different parts of an application or even across multiple applications. The intention behind creating abstract or common services is to promote code reuse and increase efficiency by consolidating similar functionalities. However, while the idea of abstraction and reusability is beneficial, it is important to strike a balance and avoid going too far in the direction of abstracting services to the point where they become too generic or common.

What problems can such services lead to?

Here are a couple of common problems that a lot of engineers face.

Violating the Single Responsibility principle

The single responsibility principle (SRP) states that a class or module should have only one reason to change. When we create overly common or abstract services, we run the risk of violating this principle. These services tend to accumulate multiple responsibilities, making them harder to understand, test, and maintain. Let’s consider an example:

class NotificationService
{
    public function sendNotification($user, $message)
    {
        // Code to send a notification to the user
    }

    public function logNotification($user, $message)
    {
        // Code to log the notification
    }

    public function updateAnalytics($user, $message)
    {
        // Code to update analytics related to the notification
    }
}

In the above example, the NotificationService class handles three distinct responsibilities: sending notifications, logging notifications, and updating analytics. This violates the SRP and makes the class tightly coupled to multiple concerns. A better approach would be to create separate, more specific services for each responsibility.

The better approach is to separate responsibilities by creating the smallest specific service.

class NotificationSender
{
    public function sendNotification($user, $message)
    {
        // Code to send a notification to the user
    }
}

class NotificationLogger
{
    public function logNotification($user, $message)
    {
        // Code to log the notification
    }
}

class AnalyticsUpdater
{
    public function updateAnalytics($user, $message)
    {
        // Code to update analytics related to the notification
    }
}

By separating the responsibilities into distinct classes, we achieve a cleaner and more maintainable codebase. Each class has a clear and single responsibility, allowing for easier testing, debugging, and modification.

The Open/Closed principle and the cost of change

The open/closed principle (OCP) states that software entities (classes, modules, functions) should be open for extension but closed for modification. When we create generic or abstract services, we often find ourselves modifying existing code to accommodate new requirements. This violates the OCP and can lead to a cascade of unintended consequences.

class UserService
{
    public function create($user)
    {
        // Code to create a user
    }

    public function update($user)
    {
        // Code to update a user
    }

    public function delete($user)
    {
        // Code to delete a user
    }

    public function activate($user)
    {
        // Code to activate a user
    }
}

In the above example, we have an common UserService class that provides various methods such as createupdatedelete, and activate. However, imagine a scenario where the requirement changes, and we need to modify the activation process specifically. Instead of modifying the existing code within the UserService class, we create a separate UserActiveService class that is responsible only for the user activation functionality.

class UserActiveService
{
    public function activateUser($user)
    {
        // Code to activate a user
    }
}

In this example, we take a different approach to address the issue of a common UserService with multiple methods. Instead of creating a UserService with various responsibilities, we eliminate the need for it altogether and create a more specific service called UserActiveService.

The UserActiveService class is responsible solely for user activation. It provides a single method, activateUser, which handles the activation process for a given user. By creating this specific service, we eliminate the need for a broader and more generic UserService that encompasses multiple functionalities.

This approach aligns with the Open/Closed Principle, as we are closing the UserService for modification and extending the system by introducing a new, more specific service. By separating concerns and responsibilities into individual services, we achieve a more modular and maintainable codebase. Each service has a clear purpose, making the code easier to understand, test, and modify without impacting other parts of the system.

God class problem

In addition to the challenges of violating the single responsibility and open/closed principles, another problem that arises with overly common or abstract services is the risk of turning them into “God classes.” When services encompass multiple functionalities and become too generic, they become magnets for new engineers to add new logic and features to them. This can quickly spiral out of control, leading to bloated, monolithic classes that are difficult to understand, maintain, and test.

As new engineers join a project and encounter these common services, they may find it tempting to extend the existing functionality by adding their specific logic within the same class. This can result in the accumulation of unrelated responsibilities, leading to poor separation of concerns and increased code complexity. The codebase becomes entangled, making it harder to comprehend and increasing the chances of introducing bugs or unintended consequences.

Moreover, the reliance on a single, all-encompassing class for multiple functionalities inhibits code reuse and modularity. It hampers the ability to make isolated changes or swap out components without affecting the entire system. As a result, the development process becomes slower, and the system becomes more fragile.

To mitigate this problem, it is essential to follow the principle of creating specific services with well-defined responsibilities. By adhering to the single responsibility principle and breaking down complex services into smaller, more focused units, we can prevent the accumulation of unrelated logic and avoid the pitfalls of a “God class.” This encourages better code organization, promotes code reusability, and allows for easier collaboration among team members.

Conclusion

In the realm of software engineering, it is crucial to strike a balance between creating common or abstract services and embracing specificity. While the idea of reusability and abstraction is appealing, it can lead to a multitude of challenges, including violations of the single responsibility principle and the open/closed principle.

By understanding the single responsibility principle, engineers can avoid the pitfalls of creating services that encompass multiple responsibilities. Instead, they should strive to create more specific services, each with a clear and singular purpose. This approach enhances code readability, testability, and maintainability.

The open/closed principle emphasizes the importance of keeping software entities closed for modification and open for extension. When faced with new requirements or changes, engineers should resist modifying existing code directly. Instead, they can create new specific services that extend the functionality of the system without altering the original codebase. This promotes modularity and minimizes the risk of introducing bugs or affecting other parts of the application.

In our code examples, we demonstrated the implications of violating these principles and the benefits of adhering to them. We showcased the refactoring of common or abstract services into more specific services, such as the creation of a UserActiveService responsible only for user activation. By doing so, we achieved code that is easier to understand, test, and maintain, while also improving the flexibility and extensibility of the system.

In conclusion, software engineers must carefully consider the design and implementation of services in order to avoid creating overly common or abstract services. By embracing specificity and adhering to principles like the single responsibility principle and the open/closed principle, engineers can build more robust, maintainable, and adaptable software systems.