Saturday, December 17, 2022

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.


/// <inheritdoc/>

public async Task EnqueueCommunicationRequestsAsync(int idUser, string idCommunicationType, int idCommunication) {

   

    var queryParams = new {

      idUser,

      idCommunicationType,

      idCommunication

       };

  

       await _dapperWrapper.QueryAsyncWithRetry(this.Connection,

TEMPLATED_COMMUNICATION_QUEUE_INSERT, this.retryAttempts,

this.retryInterval, queryParams, commandType: CommandType.StoredProcedure);

      

    

But a finished method usually can’t remain quite that simple. Resilience, auditability, supportability, and efficiency all require additional code:

/// <inheritdoc/>

public async Task EnqueueCommunicationRequestsAsync(int idUser, string idCommunicationType, int idCommunication) {

 

if (Connection.State != ConnectionState.Open) {

Connection.Open();

}

try {

   

     var queryParams = new {

idUser,

idCommunicationType,

idCommunication

     };

 

     _logger.LogInformation("Enqueueing record into the Queue table for User

{idUser} with Communication Type {idCommunicationType} and Communication Id

{idCommunication} started", idUser, idCommunicationType, idCommunication);

 

     await _dapperWrapper.QueryAsyncWithRetry(Connection,

TEMPLATED_COMMUNICATION_QUEUE_INSERT, retryAttempts, retryInterval,

queryParams, commandType: CommandType.StoredProcedure);

           

} catch (Exception ex) {

     _logger.LogError(ex, "Error occurred while inserting queue

record into table");

     throw;

} finally {

     if (Connection.State == ConnectionState.Open) {

Connection.Close();

}

}

}



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:

public async Task EmptyTaskWrapperAsync() {

 

if (Connection.State != ConnectionState.Open) {

Connection.Open();

}

try {

   

// Here’s where we’d have the core functionality

           

} catch (Exception ex) {

     _logger.LogError(ex, "Error occurred [doing something]");

     throw;


} finally {

     if (Connection.State == ConnectionState.Open) {

Connection.Close();

}

}

}

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:

private async Task CoreFunctionalityWrapperAsync<Task>(string errorMessage,

Func<Task> coreFunction) {

if (Connection.State != ConnectionState.Open) {

Connection.Open();

}

try {

 

// Do the core thing that the calling method has to do.

await coreFunction();

 

} catch (Exception ex) {

_logger.LogError(ex, errorMessage);

throw;


} finally {

if (Connection.State == ConnectionState.Open) {

Connection.Close();

}

}

}

(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:


// Define the core functionality of the method as a Func<Task>:

Func<Task> coreFunction = () => {

 

  var queryParams = new {

idUser,

idCommunicationType,

idCommunication

};

 

return _dapperWrapper.QueryAsyncWithRetry(Connection,

TEMPLATED_COMMUNICATION_QUEUE_INSERT, retryAttempts, retryInterval,

queryParams, commandType: CommandType.StoredProcedure);


};


Invoking the method wrapper is easy:

await CoreFunctionalityWrapper("Error occurred while enqueueing

a record", coreFunction);

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:


/// <inheritdoc/>

public async Task EnqueueCommunicationRequestsAsync(int idUser, string

idCommunicationType, int idCommunication) {

 

Func<Task> coreFunction = () => {

 

  var queryParams = new {

idUser,

idCommunicationType,

idCommunication

};

 

_logger.LogInformation("Starting enqueueing record into the Queue table

for User {idUser} with Communication Type {idCommunicationType} and

Communication Id {idCommunication}", idUser, idCommunicationType,

idCommunication);

 

return _dapperWrapper.QueryAsyncWithRetry(Connection,

TEMPLATED_COMMUNICATION_QUEUE_INSERT, retryAttempts, retryInterval,

queryParams, commandType: CommandType.StoredProcedure);


};

 

await CoreFunctionalityWrapper("Error occurred while enqueueing a

record", coreFunction);

}

 



/// <summary>

/// Wrapper for core functionality of methods. Handles Connection State and

/// try ... catch setup and teardown for methods that call it.

/// </summary>

/// <typeparam name="TResult">The return value from the core function.

Could be a <see cref="Task"/>,

/// an <see cref="IEnumerable{T}"/>, or just a scalar value or object.

</typeparam>

/// <param name="errorMessage">The general error message to log if an

error occurs within the

/// core method and isn't handled by it.</param>

/// <param name="coreFunction">The core functionality of the calling method,

represented as

/// a <see cref="Func{TResult}"/>.</param>

/// <returns>A value of type <typeparamref name="TResult"/>.</returns>

private TResult CoreFunctionalityWrapper<TResult>(string errorMessage,

Func<TResult> coreFunction) {

if (coreFunction == null) 

throw new ArgumentNullException(nameof(coreFunction));


if (Connection.State != ConnectionState.Open) {

Connection.Open();

}

try {

 

// Do the core thing that the calling method has to do.

return coreFunction();

 

} catch (Exception ex) {

_logger.LogError(ex, errorMessage);

throw;


} finally {

if (Connection.State == ConnectionState.Open) {

Connection.Close();

}

}

}

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


0 Comments:

Post a Comment

<< Home