Refactoring Business Logic Into Services

Yo dawg, are you controllers getting plump?

Well I’ve got one technique you can use to drop pounds QUICK!

What is one of the most common ways things start to chub up?

Business Logic.

Damn is it easy to slather on that biz logic fat right within our controller actions.

So what can we do about it?

Business Logic Thinning Strategies

There are multiple strategies you could choose to tackle business logic in a controller. Naming just two of them, you could:

Move the logic into reusable ActionFilters

Good if the logic is common amongst other controller actions like authorizing access to an entity. However, it does still couple the business logic with controller-related libraries. In other words, you wouldn’t use an ActionFilter outside of an MVC or API route, which would mean you couldn’t use that business logic outside of that domain either. Perhaps you never would anyway, so in that case, an ActionFilter might be a fine, pragmatic choice.

Move the logic into a service that is injected into the controller

This way works well because your business logic can live happily in its own reusable class, without coupling itself to any particular client technology.

This is the strategy we’ll be looking at for the remainder of this article.

Refactoring business logic into services

First, let’s clarify terms.

Business Logic

For our purposes, business logic is any code that implements the goals of the business. For an e-commerce application, this would be processes or tasks like creating orders, processing payments, creating and sending receipts, and adding or updating customer data.

In comparison, controllers are only responsible for managing the incoming HTTP request, delegating the work of satisfying the request, and passing on a response.

If you have trouble identifying the boundary between business logic and controller logic (clarifying responsibilities), send me a message on Twitter and I’ll try to help.

Services

A service is really anything that… serves.

Whenever you call a method on one class from another, the calling class is the client and the class with the method is the service.

So there you have it, a service is a class with a method that can be called.

Sticking our business logic in its own class has a number of advantages. The ability to use the feature in areas beyond just the controller, the ability to test the business logic (and the controller) in isolation, and a big improvement in readability due to the fact that a blob or blobs of logic filling the controller can be reduced to clearly named method calls.

The code in question

Ok, I’m going to throw you for a bit of a loop.

The code we will be refactoring is actually an Azure Function, and not a controller.

Don’t freak out. Let me explain.

I’m choosing to use this code because it’s actual real world production code rather than an invented example.

On top of that, the code used to build an Azure Function is so gosh darn similar to a controller that you would barely know the difference if I didn’t call it out.

There are minor differences that I’ll point out, but the refactoring concepts in this article can still be applied 1-to-1.

For that reason I’ll be using the word controller and function interchangeably for the remainder of this article.

Let’s see the code…

public static class HandleNotification
{  
    public static async Task<IActionResult> Run(
        HttpRequest req, 
        ILogger log, 
        ExecutionContext context)
    {
        log.LogInformation("C# HTTP trigger function processed a request.");

        var config = new ConfigurationBuilder()
            .SetBasePath(context.FunctionAppDirectory)
            .AddJsonFile("local.settings.json", optional: true, reloadOnChange: true)
            .AddEnvironmentVariables()
            .Build();

        var googleSheetsCredentialsPath = Path.Combine(context.FunctionAppDirectory, "credentials.json");

        var spreadsheetDefinition = (ID: config["SpreadsheetId"], Name: config["SpreadsheetName"], Range: config["SpreadsheetRange"]);

        var requestBody = await new StreamReader(req.Body).ReadToEndAsync();

        var googleSheetsService = GetGoogleSheetsService(googleSheetsCredentialsPath);

        log.LogInformation(requestBody);

        try
        {
            var jsonSerializerSettings = new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore, ContractResolver = new DefaultContractResolver { NamingStrategy = new SnakeCaseNamingStrategy { } } };
            var samCartEvent = JsonConvert.DeserializeObject<SamCartEvent>(requestBody, jsonSerializerSettings);

            if (samCartEvent.Type == EventType.Order)
            {
                await AddOrderToSalesLogSpreadsheet(samCartEvent, spreadsheetDefinition, googleSheetsService);
            }
            else if (samCartEvent.Type == EventType.Refund)
            {
                await SetExistingOrderStatusToRefunded(samCartEvent, spreadsheetDefinition, googleSheetsService);
            }

            return new OkResult();
        }
        catch (Exception e)
        {
            log.LogError(e, e.Message);

            return new BadRequestObjectResult(e);
        }
        
    }

    private static async Task AddOrderToSalesLogSpreadsheet(SamCartEvent orderEvent, (string ID, string Name, string Range) spreadsheetDefinition, SheetsService sheetsService)
    {
        var customerName = $"{orderEvent.Customer.FirstName} {orderEvent.Customer?.LastName}";
        var programCity = orderEvent.Order.CustomFields?.SingleOrDefault(cf => cf.Name == "Program City")?.Value;

        var row = new List<object>() { orderEvent.Order.ID, customerName, orderEvent.Customer.Email, programCity, DateTime.Now.ToString("MM/dd/yyyy h:mm tt"), "Ordered" };

        var valueRange = new ValueRange();
        valueRange.Range = spreadsheetDefinition.Range;
        valueRange.Values = new List<IList<object>>();
        valueRange.Values.Add(row);

        var appendRequest = sheetsService.Spreadsheets.Values.Append(valueRange, spreadsheetDefinition.ID, spreadsheetDefinition.Range);
        appendRequest.ValueInputOption = SpreadsheetsResource.ValuesResource.AppendRequest.ValueInputOptionEnum.USERENTERED;

        var appendResponse = await appendRequest.ExecuteAsync();
    }

    private static async Task SetExistingOrderStatusToRefunded(SamCartEvent refundEvent, (string ID, string Name, string Range) spreadsheetDefinition, SheetsService sheetsService)
    {
        var refundedOrderID = refundEvent.Order.ID.ToString();

        var salesLogSpreadsheet = await sheetsService.Spreadsheets.Values.Get(spreadsheetDefinition.ID, spreadsheetDefinition.Range).ExecuteAsync();

        string statusCell = FindExistingOrderStatusCell(refundedOrderID, salesLogSpreadsheet, spreadsheetDefinition.Name);

        if (statusCell != null)
        {
            var row = new List<object>() { "Refunded" };

            var valueRange = new ValueRange();
            valueRange.Range = statusCell;
            valueRange.Values = new List<IList<object>>();
            valueRange.Values.Add(row);

            var updateRequest = sheetsService.Spreadsheets.Values.Update(valueRange, spreadsheetDefinition.ID, statusCell);
            updateRequest.ValueInputOption = SpreadsheetsResource.ValuesResource.UpdateRequest.ValueInputOptionEnum.USERENTERED;

            await updateRequest.ExecuteAsync();
        }

    }

    private static string FindExistingOrderStatusCell(string orderID, ValueRange salesLogSpreadsheet, string spreadsheetName)
    {
        string statusCell = null;

        for (int i = 0; i < salesLogSpreadsheet.Values?.Count; i++)
        {
            var row = salesLogSpreadsheet.Values[i];

            if ((string)row[0] == orderID)
            {
                statusCell = $"{spreadsheetName}!F{i+1}";
                break;
            }
        }

        return statusCell;
    }

    private static SheetsService GetGoogleSheetsService(string credentialsPath)
    {
        GoogleCredential credential;
        string[] _scopes = { SheetsService.Scope.Spreadsheets };

        using (var stream = new FileStream(credentialsPath, FileMode.Open, FileAccess.Read))
        {
            credential = GoogleCredential.FromStream(stream).CreateScoped(_scopes);
        }

        // Create Google Sheets API service.

        var sheetsService = new SheetsService(new BaseClientService.Initializer()
        {
            HttpClientInitializer = credential,
            ApplicationName = "SamCart Notification Listener"
        });

        return sheetsService;
    }
}

Comparing Azure Functions and ASP.NET Controllers

You’ll notice right away that this code comprises a single class with a single method that returns an IActionResult. Nearly identical to an action method endpoint of a Controller.

The main difference is that it is declared as static and passes a few arguments that you may not be used to (we’ll be changing that with our refactoring).

A brief explanation of behavior

This code is used as a listener for processing sales notifications that come from a shopping cart web app called SamCart.

You can configure an endpoint URL within SamCart’s admin panel that it will POST notifications to upon successful sales (or refunds). This code listens for those notifications and logs them to a Google Sheet spreadsheet using the Google Sheets API and it’s client library.

Tackling the task of refactoring

In an earlier article we looked at some strategies for code cleanup.

In this particular class, we have a fairly large Run method that is doing multiple logical blocks of work.

Can you name them?

Setting up configuration, reading and parsing the HTTP request body into a SamCartEvent POCO, and making use of the Google Sheets API to either add a new sales order or update the refund status of an existing order on a spreadsheet.

In order to simplify this code so that it’s easier to read and easier to change, let’s try to break it up into smaller bits and follow the Single Responsibility Principle more closely.

Parsing…

First, I found out the day of writing this article that Azure Functions allow you to benefit from the same automatic model binding that Controllers are provided. I was lead astray by the original template-generated code that you can see is manually parsing the HTTP request body.

So our first change is to completely eliminate that parsing logic and simply declare a SamCartEvent as the first argument to the Run method.

I’m also going to remove the logging calls and the logger argument for the clarity of this demonstration.

public static async Task<IActionResult> Run(
    SamCartEvent samCartEvent, 
    ExecutionContext context)
{
    var config = new ConfigurationBuilder()
        .SetBasePath(context.FunctionAppDirectory)
        .AddJsonFile("local.settings.json", optional: true, reloadOnChange: true)
        .AddEnvironmentVariables()
        .Build();

    var googleSheetsCredentialsPath = Path.Combine(context.FunctionAppDirectory, "credentials.json");

    var spreadsheetDefinition = (ID: config["SpreadsheetId"], Name: config["SpreadsheetName"], Range: config["SpreadsheetRange"]);

    var googleSheetsService = GetGoogleSheetsService(googleSheetsCredentialsPath);

    try
    {
        if (samCartEvent.Type == EventType.Order)
        {
            await AddOrderToSalesLogSpreadsheet(samCartEvent, spreadsheetDefinition, googleSheetsService);
        }
        else if (samCartEvent.Type == EventType.Refund)
        {
            await SetExistingOrderStatusToRefunded(samCartEvent, spreadsheetDefinition, googleSheetsService);
        }

        return new OkResult();
    }
    catch (Exception e)
    {
        return new BadRequestObjectResult(e);
    }       
}
    

With that cleaned up, we are left with only two primary tasks. Setting up configuration, and making use of the Google Sheets API.

Creating a service class

Looking a bit deeper, we see that the configuration is only used by the Google Sheets API, so we could say that the work involved in setting it up is really along the same logical block of work of calling the API’s various services.

For that reason, it makes sense to move all of that code into it’s own class to be used as a service.

To do that, I’ll create a new class and method, named after what this code is actually doing from a high level. Ultimately we are using Google Sheets to log our SamCart events to a spreadsheet, so we move all of that code behind a method called LogEvent for the class GoogleSheetsSamCartEventLogger.

One key difference here is that at the time of writing this article, there was no way to inject the ExecutionContext needed to derive the location of the configuration file, so the first line of code is an alternative way of doing so.

The rest is the same as before: getting the spreadsheet configuration and calling the appropriate API endpoint based on the type of SamCartEvent.

public class GoogleSheetsSamCartEventLogger
{
    public async Task LogEvent(SamCartEvent samCartEvent)
    {
        var functionAppDirectory = Environment.GetEnvironmentVariable("AzureWebJobsScriptRoot")
                ?? (Environment.GetEnvironmentVariable("HOME") == null
                    ? Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
                    : $"{Environment.GetEnvironmentVariable("HOME")}/site/wwwroot");

        var config = new ConfigurationBuilder()
            .SetBasePath(functionAppDirectory)
            .AddJsonFile("local.settings.json", optional: true, reloadOnChange: true)
            .AddEnvironmentVariables()
            .Build();

        var googleSheetsCredentialsPath = Path.Combine(functionAppDirectory, "credentials.json");
        var spreadsheetDefinition = (ID: config["SpreadsheetId"], Name: config["SpreadsheetName"], Range: config["SpreadsheetRange"]);
        var googleSheetsService = GetGoogleSheetsService(googleSheetsCredentialsPath);

        if (samCartEvent.Type == EventType.Order)
        {
            await AddOrderToSalesLogSpreadsheet(samCartEvent, spreadsheetDefinition, googleSheetsService);
        }
        else if (samCartEvent.Type == EventType.Refund)
        {
            await SetExistingOrderStatusToRefunded(samCartEvent, spreadsheetDefinition, googleSheetsService);
        }

    }

    private async Task AddOrderToSalesLogSpreadsheet(SamCartEvent orderEvent, (string ID, string Name, string Range) spreadsheetDefinition, SheetsService sheetsService)
    {
        //...

    }

    private async Task SetExistingOrderStatusToRefunded(SamCartEvent refundEvent, (string ID, string Name, string Range) spreadsheetDefinition, SheetsService sheetsService)
    {
        //...

    }

    private string FindExistingOrderStatusCell(string orderID, ValueRange salesLogSpreadsheet, string spreadsheetName)
    {
        //...

    }

    private SheetsService GetGoogleSheetsService(string credentialsPath)
    {
        //...

    }
}

With this service class created, we now just need to make use of it in our original Run method.

The fastest way to do that would be “new it up” directly in the method. Let’s go that route first, and then we will improve upon it further on in the article.

public async Task<IActionResult> Run(SamCartEvent samCartEvent)
{
    try
    {
        var eventLogger = new GoogleSheetsSamCartEventLogger();

        await eventLogger.LogEvent(samCartEvent);
        
        return new OkResult();
    }
    catch (Exception e)
    {
        return new BadRequestObjectResult(e);
    }
}

Now we are looking good.

We have a much thinner method with a lot less reasons to change (code stability). Our method simply needs to hand off the work to a service and respond with a relevant result.

It is now more likely that the event logger would change, rather than the function or controller itself.

Can we improve this code further?

Earlier we mentioned “newing up” the event logger. If we instead make use of dependency injection, we can move the responsibility of choice onto the application itself.

Responsibility of choice is a term I’m coining just now which entails the decision of how a certain behavior is implemented.

As it stands right now, we are hardcoding the use of Google Sheets by our listener. Meaning it would require an actual code change and redeployment to ever log our events differently. This could be perfectly fine if you had no plans or requirements to change your logging mechanism, but for the sake of demonstration, let’s say that our customer mentioned possibly wanting to move the logging to Microsoft’s Excel, or to a plain old text file.

With that being the case, it would be in our benefit to allow changing the implementation of event logging, without having to change the controller.

Less change = less risk.

This is where a combination of interfaces and dependency injection come in handy.

First, we can extract an interface from our Google Sheets event logger.

public interface ISamCartEventLogger
{
    Task LogEvent(SamCartEvent samCartEvent);
}

public class GoogleSheetsSamCartEventLogger : ISamCartEventLogger
{
    //...

}

With this in place, we are now free to create as many different logger implementations as we want.

As an example, we could have a very primitive console logger like this one.

public interface ISamCartEventLogger
{
    Task LogEvent(SamCartEvent samCartEvent);
}

public class ConsoleSamCartEventLogger : ISamCartEventLogger
{
    public async Task LogEvent(SamCartEvent samCartEvent) 
    {
        if (samCartEvent.Type == EventType.Order)
        {
            Console.WriteLine("An order was placed!");
        }
        else if (samCartEvent.Type == EventType.Refund)
        {
            Console.WriteLine("Aww... a refund.");
        }
    }
}

Interfaces are fun, but without dependency injection we would still have to change the code of the controller’s Run method to “new up” the alternative implementation.

Remember this?

public async Task<IActionResult> Run(SamCartEvent samCartEvent)
{
    try
    {
        var eventLogger = new ConsoleSamCartEventLogger();

        await eventLogger.LogEvent(samCartEvent);

        //...

    }
    //...

}

Let’s instead make use of the built in dependency injection container that is provided by ASP.NET to move the implementation decision to the application startup configuration.

To do that in an Azure Function is very similar to how you do that in an ASP.NET MVC or Web API project, you simply register your types in the Startup.cs class and then use them via constructor parameters.

public class Startup : FunctionsStartup
{
    public override void Configure(IFunctionsHostBuilder builder)
    {
        builder.Services.AddScoped<ISamCartEventLogger, GoogleSheetsSamCartEventLogger>();
    }
}
public class HandleNotification
{
    private readonly ISamCartEventLogger eventLogger;

    public HandleNotification(ISamCartEventLogger eventLogger)
    {
        this.eventLogger = eventLogger;
    }

    public async Task<IActionResult> Run(SamCartEvent samCartEvent)
    {
        try
        {
            await eventLogger.LogEvent(samCartEvent);
        }
        //...

    }
}

With it moved, we have improved the stability of the code by no longer needing to change the controller’s Run method.

We have also dramatically enhanced the readability of each individual component. Compare the above code snippet to the original code at the beginning of the article.

If you’d like to go deeper, I’ve put together a set of recipes with code samples and an exercise specifically for refactoring ASP.NET Controllers. It is not only the how, but provides a thorough exploration of the why.

You can get one of the recipes below (along with a discount code on the entire set).

Understand exactly how to keep mapping code out of your controllers with the Mapping DTOs Recipe.