Method-Core Injection: a C# Pattern for Reducing Boilerplate Code
This is a C# Advent 2022 post! Visit the website for more great C# posts by a wide-ranging group of authors!
Consider the humble service method.
In its most bare-bones form, it does one thing, and it does it straightforwardly: query a database, update a table, make a web request, or any of many other things.
Here’s a simple example. It’s pretty easy to tell what this does.
But a finished method usually can’t remain quite that simple. Resilience, auditability, supportability, and efficiency all require additional code:
Our service method is now approximately three times as long, without its essential purpose having changed. And this is still a fairly simple method by modern standards.
Now, there’s nothing wrong with this code as it was written. I just want to point some things out:
2/3 of this method’s code is now “boilerplate”: code that must be repeated in every service method in this class.
“Lewkowicz’s Law” states that any information -- be it data, text, or code -- that lives in more than one place, will diverge over time.
Think about it. Odds are, while this was being developed, there were changes suggested that applied to all the service methods.
Perhaps somebody said, reluctantly, “I know it’s going to impact everything, but we need to add some additional error information for when the database call throws an exception!”
So then someone had to make a series of very tedious manual changes. They might have sped it up by doing some cutting-and-pasting-and-changing, but what if they overlooked a method that was supposed to be updated? What if they introduced a “copypasta” error?
That’s how divergence (and defects) happen.
Wouldn’t it be nice if we could isolate our “boilerplate” to one place, such as a base class? So that we only had to make a change that affected it in one place, and would know it applied everywhere?
Can we? Let’s look at our method’s structure again:
do setup (open a connection)
open a try ... catch ... finally structure:
try: do the important stuff - the method’s reason for existing - inside the try ... catch
catch: handle any caught errors
finally: teardown (close the connection)
any final cleanup
The core functionality is right in the middle, within a try ... catch loop. We’ll have to wrap the boilerplate around the core functionality. How can we do that?
Let’s start by looking at what the method would look like if we didn’t include that core functionality:
You’ll note that this boilerplate-only method has no parameters. Let’s give it a parameter: pass the core functionality that is to be executed as a delegate -- a little encapsulated pellet of code:
(We’re also passing in a more useful task description as an error message.)
Where does that encapsulated Func<Task> parameter get defined? In the original service method, like this:
Invoking the method wrapper is easy:
And that’s all that needs to appear in the service method: the core functionality for that specific method (wrapped up as a Func<Task>) and the call to the wrapper method. All the error handling is within the wrapper method.
Note that, because it’s defined in the service method, the core functionality delegate can reference the service method’s input parameters (idUser, idCommunicationType, idCommunication) and declare its own variables (queryParams). Their values will be “captured” and will be accessible to it at run time, even though the delegate is being executed by a method (the wrapper method) that doesn’t know about or have access to those parameters.
Here are the finished methods, as they ultimately appeared:
(This service method doesn’t do any parameter validation, but if it needed to, it would do it at the beginning of the code, before defining the delegate or calling the wrapper code.)
Some additional advantages of this pattern:
- Boilerplate - code that is essential, but not part of the method’s “core mission” -- is now separated from the “core” method code, making both easier to understand
- Since the boilerplate code is isolated in one place (perhaps in a base class for several service classes?) the amount of code that must be reviewed to understand what each method does is drastically cut down.
- Consistency in error handling, resource handling, and logging is automatically enforced across methods.
- Unit testing is reduced to the essentials of both the service methods and the wrapper method.
- Changes to boilerplate dramatically reduce in scale and effort, since there is no need for replication across methods. Refinements (such as more specific error handling) can be introduced without requiring multiple methods to be changed and tested.