SOLID Principles: Applying Single Responsibility Principle to Real-World Code
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.
NOTE: For brevity I have omitted additional code such as
initializing
HttpClient
andOpenSearchClient
further refactoring of specialized classes (which may or may not be violating SRP).
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 comparisonProgramming 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 ofy
.
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:
How invoices should be fetched from PA? No ❌
How invoices should be fetched from IA? No ❌
How invoices should be matched? No ❌
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 injectedHasuraClient
.
//-------- 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 injectedOpenSearchClient
//-------- 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
andInvoiceService
is responsible for handlingInvoice
operations. So, to implement new requirement, we will first create a dedicated client/service – let's call itMyApplicationNameOpenSearchClient
– responsible for actually saving data toOpenSearch
. This client will then be injected into theInvoiceService
, where theSaveMatchAsync
method will call bothHasuraClient
andMyApplicationNameOpenSearchClient
to save the matched invoices. Thus leaving theInvoiceMatchOrchestrator
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.
Subscribe to my newsletter
Read articles from Vedant Phougat directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Vedant Phougat
Vedant Phougat
Senior Software Engineer who likes writing clean, high performing and fully tested code in C# using .NET and ASP.NET Core.