S.O.L.I.D. Principles: Applying Single Responsibility Principle to Real-World Code

Table of contents

1. Introduction

2. Requirements

2.1. Expected workflow logic
2.2. Technical details

3.Development

3.1. Initial approach
3.2. Initial Orchestrator code
3.3. Challenges with initial Orchestrator

4. Refacto…


This content originally appeared on DEV Community and was authored by Vedant Phougat

Table of contents

  • 1. Introduction
  • 2. Requirements
    • 2.1. Expected workflow logic
    • 2.2. Technical details
  • 3.Development
    • 3.1. Initial approach
    • 3.2. Initial Orchestrator code
    • 3.3. Challenges with initial Orchestrator
  • 4. Refactor
    • 4.1. Identifying separate responsibilities
    • 4.2. Applying SRP
    • 4.3. Refactored Orchestrator
  • 5. Exercise
  • 6. Conclusion
  • 7. Feedback

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;
    }
}

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. 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.

4.1. 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.

4.2. Applying SRP

With the separate responsibilities identified, we'll apply SRP by delegating each task to its respective class:

  • Create HasuraClient to handle communication between the application and Hasura server.
  //-------- Hasura Client --------
  public interface IHasuraClient
  {
      Task<HasuraResponse> SendAsync(HasuraRequest request);
  }

  public class HasuraClient : IHasuraClient
  {
      private readonly HttpClient _client;

      //See: https://learn.microsoft.com/en-us/dotnet/core/extensions/httpclient-factory#typed-clients
      public HasuraClient(HttpClient client)
      {
          _client = client;
      }

      public async Task<HasuraResponse> SendAsync(HasuraRequest request)
      {
          //create HttpRequestMessage object from HasuraRequest
          var responseMessage = await _client.SendAsync(requestMessage)
          //check and parse to HasuraResponse and return
          return response;
      }
  }
  • Create a PaInvoiceService to deal with invoices from PA using an injected HasuraClient.
  //-------- Pa Invoice Service --------
  public interface IPaInvoiceService
  {
      Task<ICollection<PaInvoice>> GetInvoicesForBatchAsync(Int32 batchId);
  }

  public class PaInvoiceService : IPaInvoiceService
  {
      private const String GetInvoicesForBatchQuery = "hasura query";
      private readonly IHasuraClient _client;

      public PaInvoiceService(IHasuraClient client)
      {
          _client = client;
      }

      public async Task<ICollection<PaInvoice>> GetInvoicesForBatchAsync(Int32 batchId)
      {
          //create HasuraRequest object using GetInvoicesForBatchQuery+batchId
          var hasuraResponse = await _client.SendAsync(hasuraRequest);
          //validate and return invoices
          return hasuraResponse.Value;
      }
  }
  • Create an IaInvoiceService to deal with invoices from IA using an injected OpenSearchClient
  //-------- Ia Invoice Service --------
  public interface IIaInvoiceService
  {
      Task<ICollection<IaInvoice>> GetInvoicesForBatchAsync(Int32 batchId);
  }

  public class IaInvoiceService : IIaInvoiceService
  {
      private readonly OpenSearchClient _client;

      public IaInvoiceService(OpenSearchClient client)
      {
          _client = client;
      }

      public async Task<ICollection<IaInvoice>> GetInvoicesForBatchAsync(Int32 batchId)
      {
          //create an object of OpenSearchRequest using batchId
          var openSearchResponse = await _client.SearchAsync(openSearchRequest);
          //check response, extract the invoices fetched, and return
          return openSearchResponse.Value;
      }
  }
  • Develop an InvoiceMatchProcessor class responsible for the invoice matching logic. This allows the matching rules to be isolated, making it easy to modify as the business logic evolves.
  //-------- 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;
      }
  }
  • Introduce an InvoiceService to handle saving the matched invoices to PA. This class will encapsulate the saving logic such as saving to additional data sources or adjusting to changing persistence requirements.
  //-------- Matched Invoice Service --------
  public interface IInvoiceService
  {
      Task<ICollection<Int32>> SaveMatchesAsync(ICollection<Invoice> matches);
  }

  public class InvoiceService : IInvoiceService
  {
      private const String SaveMatchedInvoiceMutation = "hasura-mutation";
      private readonly IHasuraClient _client;

      public InvoiceService(HasuraClient client)
      {
          _client = client;
      }

      public async Task<ICollection<Int32>> SaveMatchesAsync(ICollection<Invoice> matches)
      {
          //Use SaveMatchMutation + matches to create an object of HasuraRequest
          var response = _client.SendAsync(request);
          //extract collection of Ids of newly created matches from HasuraResponse
          return response.Value;
      }
  }

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

4.3. Refactored Orchestrator

After refactoring, the InvoiceMatchOrchestrator class now looks like this:

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;
    }
}

5. 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 HasuraClient and MyApplicationNameOpenSearchClient to save the matched invoices. 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.

6. 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 withing the application, like PaInvoiceService, IaInvoiceService, etc, will be refactored as necessary, depending upon the frequency of changes and needs of the application.

7. 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.


This content originally appeared on DEV Community and was authored by Vedant Phougat


Print Share Comment Cite Upload Translate Updates
APA

Vedant Phougat | Sciencx (2024-10-08T17:06:38+00:00) S.O.L.I.D. Principles: Applying Single Responsibility Principle to Real-World Code. Retrieved from https://www.scien.cx/2024/10/08/s-o-l-i-d-principles-applying-single-responsibility-principle-to-real-world-code/

MLA
" » S.O.L.I.D. Principles: Applying Single Responsibility Principle to Real-World Code." Vedant Phougat | Sciencx - Tuesday October 8, 2024, https://www.scien.cx/2024/10/08/s-o-l-i-d-principles-applying-single-responsibility-principle-to-real-world-code/
HARVARD
Vedant Phougat | Sciencx Tuesday October 8, 2024 » S.O.L.I.D. Principles: Applying Single Responsibility Principle to Real-World Code., viewed ,<https://www.scien.cx/2024/10/08/s-o-l-i-d-principles-applying-single-responsibility-principle-to-real-world-code/>
VANCOUVER
Vedant Phougat | Sciencx - » S.O.L.I.D. Principles: Applying Single Responsibility Principle to Real-World Code. [Internet]. [Accessed ]. Available from: https://www.scien.cx/2024/10/08/s-o-l-i-d-principles-applying-single-responsibility-principle-to-real-world-code/
CHICAGO
" » S.O.L.I.D. Principles: Applying Single Responsibility Principle to Real-World Code." Vedant Phougat | Sciencx - Accessed . https://www.scien.cx/2024/10/08/s-o-l-i-d-principles-applying-single-responsibility-principle-to-real-world-code/
IEEE
" » S.O.L.I.D. Principles: Applying Single Responsibility Principle to Real-World Code." Vedant Phougat | Sciencx [Online]. Available: https://www.scien.cx/2024/10/08/s-o-l-i-d-principles-applying-single-responsibility-principle-to-real-world-code/. [Accessed: ]
rf:citation
» S.O.L.I.D. Principles: Applying Single Responsibility Principle to Real-World Code | Vedant Phougat | Sciencx | https://www.scien.cx/2024/10/08/s-o-l-i-d-principles-applying-single-responsibility-principle-to-real-world-code/ |

Please log in to upload a file.




There are no updates yet.
Click the Upload button above to add an update.

You must be logged in to translate posts. Please log in or register.