Table of contents
- 1. Introduction
- 2. Requirements
- 3.Development
- 4 Identifying separate responsibilities
- 5. Refactor
- 6. Exercise
- 7. Conclusion
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.
Next we will look at the project requirements that led to our initial approach.
2. Requirements
We are 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
3.1. Initial Approach
We opt 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. 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.
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);
}
}
- 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);
}
}
- 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
}
}
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;
}
}
- Update
InvoiceMatchOrchestrator
to useInvoiceProcessor
:
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
}
}
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
}
}
- Updated
InvoiceMatchOrchestrator
to useInvoiceService
:
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;
}
}
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
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 bothHttpClient
andMyApplicationNameOpenSearchClient
to save the matched invoices in their respective data stores. 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.
7. Conclusion
So, refactoring the orchestrator class by applying the SRP resulted in cleaner and readable code. 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.
The 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.
NOTE: Please fully verify all code snippets before using them, as they may or may not function as shown.
Top comments (0)