Applying Single Responsibility Principle to Real-World Code

Vedant Phougat - Oct 8 - - Dev Community

Table of contents


1. Introduction

Ever wondered why you found yourself modifying the same class to accommodate unrelated features? Imagine having a class responsible for processing invoices, but over time, it has become the go to class to fix issues related to database queries, business rules, and external API calls. This is a common issue when classes take on multiple responsibilities, leading to code that's difficult to maintain and extend.

This post will provide you with practical insights into identifying, breaking down and refactoring a class – InvoiceMatchOrchestrator, that is performing multiple tasks, by applying Single Responsibility Principle (SRP from here) to a real-world example. First, let's start with the formal definition:

A class should have only one reason to change.

Now let's start by looking at the project requirements that led to our initial approach.


2. Requirements

A developer is tasked with developing a service to match invoices from two distinct sources, PA and IA, based on specific business logic. This involves fetching invoices from both sources, comparing them according to the matching criteria, and saving the matched invoices back to PA. The goal is to streamline this workflow while keeping the code readable, maintainable and adaptable for future changes.

2.1. Expected Workflow Logic

A class named InvoiceMatchOrchestrator is responsible for managing the invoice matching workflow logic.

What is invoice matching workflow logic?

It is the order of steps – fetching invoices from both data sources, applying business logic to find matches, and saving matched invoices back to PA.

2.2. Technical Details

  • PA's data source: PostgreSQL, accessed via a Hasura GraphQL layer, which provides a GraphQL API for database interaction.
  • IA's data source: OpenSearch, used as an external reference source for comparison
  • Programming language: C#.

3. Development

Although the developer is proficient in C# and .NET, he is using some of the project's other technologies for the first time. With a tight deadline for an upcoming demo, he needs to develop the application quickly despite his unfamiliarity with these technologies.

3.1. Initial Approach

Since the developer is facing a tight deadline, he opts for a quick, all-in-one approach to get the application ready for the demo. This initial approach involves creating a single class called InvoiceMatchOrchestrator that handles the following tasks on its own:

  • Fetching invoices from PA: Creates and configures an HttpClient instance to connect to PA's data source.
  • Fetching invoices from IA: Creates and configures an OpenSearchClient instance to connect to IA’s data source.
  • Matching invoices: Implements business logic to match the invoices from PA and IA.
  • Saving matched invoices: Saves the matched invoices back to PA using HttpClient.

3.2. Initial Orchestrator Code

After the initial developement phase, the code for InvoiceMatchOrchestrator looks as follows:

public class InvoiceMatchOrchestrator
{
    // This method's job is to orchestrate the invoice matching process
    public ICollection<Int32> ExecuteMatching(Int32 batchId)
    {
        // ------ Fetch invoices from PA ------
        var query_FetchInvoices = "it contains hasura query to fetch invoices";
        var httpClient = new HttpClient();
        // configure httpClient and create the message request using batchId
        var response = httpClient.SendAsync(request);
        // check and parse the response
        var paInvoices = JsonSerializer.Deserialize<List<PaInvoice>>(stringifyJson);

        // ------ Fetch invoices from IA ------
        var openSearchClient = new OpenSearchClient();
        // configure the openSearchClient and create search request using batchId
        var iaInvoices = openSearchClient.Search<List<IaInvoice>>(searchRequest);

        // ------ Find matching invoices ------
        // logic to find the matching invoices

        // ------ Save matching invoices ------
        var mutation_SaveMatches = "it contains hasura query to save invoices";
        // create the save request using matched invoices
        var saveResponse = httpClient.SendAsync(saveRequest);
        // check and parse the response to generate the IDs of the saved result
        return ids;
    }
}
Enter fullscreen mode Exit fullscreen mode

3.3. Challenges with Initial Orchestrator

As development progressed, this initial approach quickly became complex and hard to adapt. With each new requirement, such as:

  • Saving matched invoices to IA (OpenSearch) in addition to PA.
  • Adjusting the invoice matching criteria to use a different field, x (abstracted for confidentiality) instead of y.

the InvoiceMatchOrchestrator class required significant code changes, making it increasingly error-prone. This Initial Approach bundled multiple responsibilities within one class causing any new requirement to impact unrelated parts of the code, thus introducing risks and reducing reliability.


4. Identifying separate responsibilities

With the challenges identified in the Development section, we’ll now focus on refactoring the InvoiceMatchOrchestrator class by applying the SRP. The goal is to make this class perform task that aligns with its name and delegate everything else to specialized classes by clearly separating responsibilities. To determine if a class is handling multiple responsibilities, ask these questions:

  • What is the primary purpose of the class? In the case of InvoiceMatchOrchestrator, its main job is to manage workflow logic related to invoice matching.
  • Should this class change when requirements unrelated to the workflow logic are introduced? If the answer is "yes", the class likely has multiple responsibilities or named incorrectly. For InvoiceMatchOrchestrator, any change to data-fetching or saving steps would require updates, even though these tasks aren't part of its main workflow management role.

Using the above-mentioned questions, let’s consider when the InvoiceMatchOrchestrator class should be modified if the following new requirements arise:

  1. How invoices should be fetched from PA? No ❌
  2. How invoices should be fetched from IA? No ❌
  3. How invoices should be matched? No ❌
  4. How matched invoices should be saved to PA? No ❌

These answers confirm that InvoiceMatchOrchestrator should delegate each of these tasks to specialized classes to ensure it adheres to SRP by focusing only on coordinating the invoice matching workflow.


5. Refactor

After the demo, the developer should pause to evaluate how new requirements might impact the InvoiceMatchOrchestrator class. While the initial approach was suitable for meeting the deadline, it's now important to consider whether this design will continue to support future changes efficiently. And if he finds potential issues, like increased complexity or the need for frequent modifications, refactoring is necessary.

It is also part of the developer's role to communicate and explain this to stakeholders, highlighting why time for refactoring is needed, the long-term benefits it offers, and how it will make future changes quicker and easier.

5.1. Invoice fetching logic

Move the invoice fetching logic for each invoice source into separate classes, isolating data retrieval.

  • Create PaInvoiceService
//-------- Interface --------
public interface IPaInvoiceService
{
    Task<ICollection<PaInvoice>> GetInvoicesForBatchAsync(Int32 batchId);
}

//-------- Implementation --------
public class PaInvoiceService : IPaInvoiceService
{
    public async Task<ICollection<PaInvoice>> GetInvoicesForBatchAsync(Int32 batchId)
    {
        var query = "query to fetch invoices from PA";
        var httpClient = new HttpClient();
        // configure and send the request using batchId
        // parse the response
        return JsonSerializer.Deserialize<List<PaInvoice>>(responseString);
    }
}
Enter fullscreen mode Exit fullscreen mode
  • Create IaInvoiceService
//-------- Interface --------
public interface IIaInvoiceService
{
    Task<ICollection<IaInvoice>> GetInvoicesForBatchAsync(Int32 batchId);
}

//-------- Implementation --------
public class IaInvoiceService : IIaInvoiceService
{
    public async Task<ICollection<IaInvoice>> GetInvoicesForBatchAsync(Int32 batchId)
    {
        var openSearchClient = new OpenSearchClient();
        // configure and create search request using batchId
        return openSearchClient.Search<List<IaInvoice>>(searchRequest);
    }
}
Enter fullscreen mode Exit fullscreen mode
  • Create InvoiceMatchOrchestrator
public class InvoiceMatchOrchestrator
{
    private readonly PaInvoiceService _paInvoiceService;
    private readonly IaInvoiceService _iaInvoiceService;

    public InvoiceMatchOrchestrator(
        PaInvoiceService paInvoiceService,
        IaInvoiceService iaInvoiceService
    )
    {
        _paInvoiceService = paInvoiceService;
        _iaInvoiceService = iaInvoiceService;
    }

    public async Task<ICollection<Int32>> ExecuteMatching(Int32 batchId)
    {
        var paInvoices = await _paInvoiceService.GetInvoicesForBatchAsync(batchId);
        var iaInvoices = await _iaInvoiceService.GetInvoicesForBatchAsync(batchId);
        //Matching and saving logic remains here for now
    }
}
Enter fullscreen mode Exit fullscreen mode

5.2. Invoice matching logic

Move the matching logic into a dedicated InvoiceProcessor class.

  • Create InvoiceProcessor class:
  //-------- Invoice Match Processor --------
  public interface IMatchProcessor
  {
      ICollection<Match> MatchInvoices(
          ICollection<PaInvoice> paInvoices,
          ICollection<IaInvoice> iaInvoices);
  }

  //------ Implementation ------
  public class InvoiceMatchProcessor : IMatchProcessor
  {
      public ICollection<Invoice> MatchInvoices(
          ICollection<PaInvoice> paInvoices,
          ICollection<IaInvoice> iaInvoices)
      {
          //the logic to match the invoices
          return matchedInvoices;
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • Update InvoiceMatchOrchestrator to use InvoiceProcessor:
public class InvoiceMatchOrchestrator
{
    private readonly PaInvoiceService _paInvoiceService;
    private readonly IaInvoiceService _iaInvoiceService;
    private readonly IMatchProcessor _invoiceMatchProcessor;

    public InvoiceMatchOrchestrator(
        PaInvoiceService paInvoiceService,
        IaInvoiceService iaInvoiceService,
        IMatchProcessor invoiceMatchProcessor
    )
    {
        _paInvoiceService = paInvoiceService;
        _iaInvoiceService = iaInvoiceService;
        _processor = processor;
        _invoiceMatchProcessor = invoiceMatchProcessor;
    }

    public async Task<ICollection<Int32>> ExecuteMatching(Int32 batchId)
    {
        var paInvoices = await _paInvoiceService.GetInvoicesForBatchAsync(batchId);
        var iaInvoices = await _iaInvoiceService.GetInvoicesForBatchAsync(batchId);
        var matchedInvoices = _processor.MatchInvoices(paInvoices, iaInvoices);
        //Saving logic remains here for now
    }
}
Enter fullscreen mode Exit fullscreen mode

5.3. Matched invoice saving logic

Move the saving logic into a InvoiceService class, isolating the save operation.

  • Create InvoiceService:
  //-------- Matched Invoice Service --------
  public interface IInvoiceService
  {
      Task<ICollection<Int32>> SaveMatchesAsync(ICollection<Invoice> matches);
  }

  public class InvoiceService : IInvoiceService
  {
      public async Task<ICollection<Int32>> SaveMatchesAsync(ICollection<Invoice> matches)
      {
          var query = "mutation query to save matches";
        var httpClient = new HttpClient();
        // configure and send the save request
        return parsedIds; // Parsed from response
      }
  }
Enter fullscreen mode Exit fullscreen mode
  • Updated InvoiceMatchOrchestrator to use InvoiceService:
public class InvoiceMatchOrchestrator
{
    private readonly IPaInvoiceService _paInvoiceService;
    private readonly IIaInvoiceService _iaInvoiceService;
    private readonly IMatchProcessor _invoiceMatchProcessor;
    private readonly IInvoiceService _invoiceService;

    public InvoiceMatchOrchestrator(
        IPaInvoiceService paInvoiceService,
        IIaInvoiceService iaInvoiceService,
        IMatchProcessor invoiceMatchProcessor,
        IInvoiceService invoiceService)
    {
        _paInvoiceService = paInvoiceService;
        _iaInvoiceService = iaInvoiceService;
        _invoiceMatchProcessor = invoiceMatchProcessor;
        _invoiceService = invoiceService;
    }

    public async Task<ICollection<int>> ExecuteMatching(int batchId)
    {
        // 1. Fetch invoices from PA
        var paInvoices = await _paInvoiceService.GetInvoicesForBatchAsync(batchId);

        // 2. Fetch invoices from IA
        var iaInvoices = await _iaInvoiceService.GetInvoicesForBatchAsync(batchId);

        // 3. Perform invoice matching
        var matchedInvoices = _invoiceMatchProcessor.MatchInvoices(paInvoices, iaInvoices);

        // 4. Save the matched invoices
        var matchedInvoiceIds = await _invoiceService.SaveMatchesAsync(matchedInvoices);
        return matchedInvoiceIds;
    }
}
Enter fullscreen mode Exit fullscreen mode

By isolating each responsibility in dedicated classes, we ensure that changes to one responsibility will only impact the relevant class, not the orchestrator or unrelated functionality.


6. Exercise

Let's say a new requirement is introduced and as per it – the matched invoices must also be saved in IA (OpenSearch) as well. The way this change can be incorporated:

  • Before refactoring – The InvoiceMatchOrchestrator class will be modified, which will lead to more complexity, making the code harder to maintain, test, and understand.
  • After refactoring – First, these matched invoices are a collection of type Invoice and InvoiceService is responsible for handling Invoice operations. So, to implement new requirement, we will first create a dedicated client/service – let's call it MyApplicationNameOpenSearchClient – responsible for actually saving data to OpenSearch. This client will then be injected into the InvoiceService, where the SaveMatchAsync method will call both HttpClient and MyApplicationNameOpenSearchClient to save the matched invoices in their respective data stores. Thus leaving the InvoiceMatchOrchestrator untouched.

The key point here is that only the class related to saving the matched invoices is modified and ensuring that the changes are isolated to their specific classes while the overall workflow remains unchanged.


7. Conclusion

So, refactoring the orchestrator class by applying the SRP resulted in cleaner, readable, and testable design. By separating concerns – such as fetching invoices from PA and IA, performing the matching logic, and saving the matched invoices – each of these tasks are now handled by their respective classes. This approach allows the orchestrator to manage the workflow logic, while making future modifications easier and less risky.

Since refactoring by applying SRP is an ongoing process, other classes within the application, like PaInvoiceService, IaInvoiceService, etc, will be refactored as necessary, depending upon the frequency of changes and needs of the application.


8. Feedback

Thank you for reading this post! Please leave any feedback in the comments about what could make this post better or topics you’d like to see next. Your suggestions help improve future posts and bring more helpful content.

NOTE: Please fully verify all code snippets before using them, as they may or may not function as shown.


. . .
Terabox Video Player