C# — Extension methods with practical use cases

Extend external dependencies or your own modules by using inversion of control and dependency injection

Luis Costa Brochado
9 min readApr 25, 2023
Source: Huzaifa Ginwala from Unsplash

Table of Contents

  1. Background
  2. Extension methods and their purpose
  3. Producing decoupled modules by extending IServiceCollection
    Following recommended good practices
    Working the extension
    Inversion of control
    Dependency injection
  4. Extending external dependencies
  5. Critical points

1. Background

A mesmerizing explanation of extension methods had made me wonder:

‘Why extend a class I control, when I can simply add the functionality to it whenever needed?’

As I didn’t usually manage code out of my control, that wasn’t quite a thorough first explanation. Extension methods remained a last resort for me for a while, as I refused to use them in almost every use case they made sense.

I needed another point of view. Since ChatGPT wasn’t available at that time, I relied mostly on experienced developers and Google to help me get answers to the questions I was asking. Their top tips pointed to the usual sale points: ‘it helps a lot to be able to extend basic types’. With that, I was given the usual example of extending the .ToString() method, to format a string, and that was about it.

I was aware that updating packages could involve a lot of work, but I didn’t instantly see how could extension methods reduce development time.

With time came experience and, with a solid amount of research, alongside periods of reflection, I started seeing how important this concept was. In fact, I became amazed by its brilliance, and its simplicity:

‘By extending functionality of an existing codebase externally, I won’t be required to recompile, package and push a new version of a bunch of binaries from another codebase each time I need something added to it.’

Of course things are not so linear, and this is not a jack of all trades. Sometimes, using an extension will only deepen a problem. For instance, if a feature is too large to be extended, it should be added to the library itself.

2. Extension methods and their purpose

Probably, the most obvious use case is when you’re producing decoupled modules and there’s the need to prepare the module to be ‘injectable’. To be able to do that you’ll ideally enable the IoC container to be extended beforehand.

Imagine this other scenario:
- You’re working with micro services, where there’s no real control of the entire codebase used, and domain or service logic should reside within the service’s contextual boundaries. Ideally, under these circumstances, you’d be extending a third party library to be able to perform a specific task that the package maintainer didn’t include. If it’s a relatively small addition to the original package, than that makes a lot of sense.

These are, from my perspective, the most common cases in which creating an extension is a good practice. In fact, in similar contexts, it’s the de facto practice.

3. Producing decoupled modules by extending IServiceCollection

In a previous blog, where I wrote a bit about remote and private package management, I accidentally laid the foundations for this post. That was a merely illustrative code example, for educational purposes, so I tried my best to abstract from the functionality, and focus on the main objective, which was to produce a NuGet package, package and push it to a private package source.

That was the Calculator class. A class library MySimpleCalculator with the following functionalities:

namespace MySimpleCalculator
{
public class Calculator
{
public int Add(int? N1, int? N2)
{
CheckForNull(N1, N2);
return N1.Value + N2.Value;
}

public int Subtract(int? N1, int? N2)
{
CheckForNull(N1, N2);
return N1.Value - N2.Value;
}

public int Multiply(int? N1, int? N2)
{
CheckForNull(N1, N2);
return N1.Value * N2.Value;
}

public double Divide(double? N1, double? N2)
{
CheckForNull(N1, N2);
CheckDivisionZero(N2);
return N1.Value / N2.Value;
}

private static void CheckForNull(int? N1, int? N2)
{
if (!N1.HasValue || !N2.HasValue)
throw new ArgumentNullException();
}

private static void CheckForNull(double? N1, double? N2)
{
if (!N1.HasValue || !N2.HasValue)
throw new ArgumentNullException();
}

private static void CheckDivisionZero(double? N2)
{
if (N2.Value == 0)
throw new DivideByZeroException();
}
}
}

In that situation I was making use of this Calculator, restoring it as an external dependency, and using it like a helper class. This meant the Calculator class had to be instantiated in each and every single one of the controller’s methods for an operation to be performed. But that’s not written on the books.

Fortunately, that circumstance has provided me with this opportunity, which is to explain extension methods in detail.

Following recommended good practices

In order to use methods from the Calculator module in other projects, register them with a dependency injection container, the IServiceCollection, from the Microsoft.Extensions.DependencyInjection library.

By adding a scoped service with the interface and the declaration, the Calculator is being registered as a service that can be injected into other components, this is dependency injection. It means that when an instance of the class that depends on the Calculator class is created, the dependency injection container will automatically create an instance of the Calculator.

Working the extension

Extract the interface from the Calculator class. This can be done with ease on Visual Studio, by pressing 'ctrl + .' and clicking the “extract interface” option. It should end up being similar to the following:

namespace MySimpleCalculator
{
public interface ICalculator
{
int Add(int? N1, int? N2);
double Divide(double? N1, double? N2);
int Multiply(int? N1, int? N2);
int Subtract(int? N1, int? N2);
}
}

After that, create a static class, namely CalculatorEx, where the static AddCalculatorService() method will return the extended IServiceCollection, within which the Calculator service has been injected:

namespace MySimpleCalculator.Extensions
{
public static class CalculatorEx
{
public static IServiceCollection AddCalculatorService(this IServiceCollection services)
{
services.AddSingleton<ICalculator, Calculator>();

return services;
}
}
}

By this stage, in the real world, one would either compile, package and push this set of binaries to a private repository of some sort, or have a CI framework in place that’d automate those steps.

Interested in doing those steps manually? Here’s an insight on how to do the packaging.

Inversion of control

With that done, create a new Web API project, in the same solution. Be sure to add the NuGet package which was created previously. Inject the Calculator in the Startup.cs using services.AddCalculatorService() on the ConfigureServices method, as can be seen below — between the references to MVC and Swagger:

public void ConfigureServices(IServiceCollection services)
{
services.AddMvc()
.ConfigureApiBehaviorOptions(options => options.SuppressMapClientErrors = true)
.AddMvcOptions(options => options.EnableEndpointRouting = false);

services.AddCalculatorService();

services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1",
new Microsoft.OpenApi.Models.OpenApiInfo
{
Title = "Calculator Web App, " + hosting.EnvironmentName,
Version = "v1",
Description = "Web API example, created with .NET 6"
});
});
}

Dependency Injection

With this done, the module will be automatically injected into the Web API whenever it’s booted up. The only thing that’s missing is adding it to a controller.

Create a CalcController, and paste in the following code:

namespace MyCalculatorWebApp.Controllers
{
[Produces("application/json")]
[Route("api/[controller]")]
[ApiController]
public class CalcController : ControllerBase
{
private readonly ICalculator calculator;

public CalcController(ICalculator calculator)
{
this.calculator = calculator;
}

[HttpPost("add/{n1}/{n2}")]
public ActionResult<string> PostNumbersAdd(int n1, int n2)
{
try
{
return new ObjectResult(calculator.Add(n1, n2).ToString());
}
catch (ArgumentNullException ex)
{
return ex.Message;
}
}

[HttpPost("sub/{n1}/{n2}")]
public ActionResult<string> PostNumbersSub(int n1, int n2)
{
try
{
return new ObjectResult(calculator.Subtract(n1, n2).ToString());
}
catch (ArgumentNullException ex)
{
return ex.Message;
}

}

[HttpPost("mul/{n1}/{n2}")]
public ActionResult<string> PostNumbersMul(int n1, int n2)
{
try
{
return new ObjectResult(calculator.Multiply(n1, n2).ToString());
}
catch (ArgumentNullException ex)
{
return ex.Message;
}
}

[HttpPost("div/{n1}/{n2}")]
public ActionResult<string> PostNumbersDiv(int n1, int n2)
{
try
{
return new ObjectResult(calculator.Divide(n1, n2).ToString());
}
catch (DivideByZeroException ex)
{
return ex.Message;
}
catch (ArgumentNullException ex)
{
return ex.Message;
}
}
}
}

Notice how the private readonly ICalculator calculator is declared before the constructor. Inside the constructor, the dependency injection framework instantiates the ICalculator interface that was previously worked up on the Startup.cs class. Build and run it:

OpenAPI’s view of Calc controller

With the API tested, the next step is creating an extension method by adding another mathematical operation, modulo.

4. Extending external dependencies

The MySimpleCalculator package will be extended past its boundaries. For representative purposes, I want to know the remainder of a specific division. This means a new method will be added to the Calculator, without touching the original codebase.

The extension method must follow a similar approach to what was made on the extension sub-section. The first parameter of the method should be the type being extended, and it should be preceded by the “this” keyword. The other two parameters will be two integers n1 and n2. A couple validations will also be made, just before returning the result of the modulo operation, the remainder.

namespace MySimpleCalculatorWebAPI.Extensions
{
public static class MySimpleCalculatorEx
{
public static int Mod(this ICalculator calculator, int? n1, int? n2)
{
if (!n1.HasValue || !n2.HasValue)
throw new ArgumentNullException();

if (n2.Value == 0)
throw new DivideByZeroException(nameof(n2));

return n1.Value % n2.Value;
}
}
}

On the CalcController class, create another method. Here intellisense might trip a little. MySimpleCalculatorWebAPI.Extensions namespace should be included manually in the controller. Call the recently extended calculator’s .Mod() method on the controller:

[HttpPost("mod/{n1}/{n2}")]
public ActionResult<string> PostNumbersMod(int n1, int n2)
{
try
{
return new ObjectResult(calculator.Mod(n1, n2).ToString());
}
catch (DivideByZeroException ex)
{
return ex.Message;
}
catch (ArgumentNullException ex)
{
return ex.Message;
}
}

The Web API should present the new recently added method:

Recently added mod method

There are other, more refined, examples that can illustrate how this works. But this is a fairly simple one, which does well displaying the practical usage of this pattern.

5. Critical points

Personally, extension methods felt much like having a clunky third arm in those days.

  • Balance
    — At first, I didn’t quite know what to do with that information but, deep down, I was aware that it’d be useful.
    — Then, up to a point I found myself using extension methods abusively. This sort of approach didn’t have an immediate drawback but, in some projects, the code wasn’t gradually becoming modular. In fact, its complexity grew, and it was getting harder to maintain, because these external modules and extensions were tightly coupling the codebase.
    — Finally, I started balancing my decision more towards compliance with the SOLID principles, ease of maintenance, and testability.
  • Flexibility
    — This is in my opinion the strongest sell point behind extension methods.
    — An extension method is an enabler in essence. It’s also ergonomic if used appropriately.
    — No extension methods in the codebase means you’re most like doing something wrong.
    — Too many extension methods can reveal either a hidden problem in the external dependency being extended, or a problem in the codebase. Have too many of them, and replace the module being extended by another one, just to see how deep of a hole you’ve dug for yourself over time.
  • Time consumption
    — Developing an extension method saves both time and resources. It can be used as future proof, for instance, when addressing technical debt. If you follow good practices, and common sense, your extension method’s code will serve as documentation when you address that debt.
  • Limitations
    — From my perspective, extension methods should, for the most part, be temporary if they match these two conditions:
    1. The dependency being extended is simultaneously outside of the current codebase
    2. That same third party being extended is under your (or your team’s) control.
    In other words, this means that the extension of MySimpleCalculator given as an example before, where the modulo was created, should ideally be temporary, since I control both the original package and the project where the extension was created.
    — Extension methods can’t be used to override existing methods. In an edge case, you can mimic an existing method by mirroring most of its functionality, and adding the extra logic you need in order to solve a problem. All and all, this will duplicate the existing code. Plus, the method name can’t be similar to the original one.
    — Extension methods cannot access private properties of the concrete type they implement. Fundamentally, they can’t access anything that’s private on that specific type.
    You can only extend methods. Not properties, not constructors, not operators, not indexers, anything other than methods. If an extension is too large of an addition, it has to be implemented inside the dependency.

Here’s the code for the program above:

If you read this up until this point, and liked the content, don’t forget to give it a thumbs up and follow me for more content like this!

--

--

Luis Costa Brochado

Software Engineer. I write mainly for future reference (and to share with the tech community). A DevOps advocate, working in the .NET cross-platform ecosystem.