Safetica > Resources > Azure Functions: SOLID-compliant service factory

Azure Functions: SOLID-compliant service factory

The Problem

During the implementation of a feature in my work, I came across a problem:

  • I had multiple implementations of one service interface, and I needed to inject one of them based on the data type it should process.
  • These implementations had other dependencies, so I needed a factory that would build a whole dependency tree for me using .NET dependency injection.
  • The factory also needed to be easy to extend, without having to change the code.

With these criteria in mind, I searched the internet for inspiration. Unfortunately, I could not find anything helpful. So, I decided to solve the problem myself and share the idea with you.

For the sake of demonstration, I created an example project you can find on GitHub. In this article, I will guide you through the code and explain the idea so you can apply it in your own project.

The Function

The example here is the HTTP-triggered AreaCalculator function, which can compute the area of common geometrical shapes. It is triggered via the POST method with the route calculateArea/{shape}. The {shape} route parameter represents the shape for which the area will be calculated. The shape data is specified as JSON in the request body.

As you can see, we are injecting the IAreaCalculatorFactory here. The factory provides the required implementation of the calculator based on the shape type parsed from the route parameter. The calculator then computes the area of the specific shape, and the result is returned as JSON in the OK response.

public class AreaCalculator
{
    private readonly IAreaCalculatorFactory _calculatorFactory;
    private readonly ILogger<AreaCalculator> _logger;

    public AreaCalculator(
        IAreaCalculatorFactory calculatorFactory,
        ILogger<AreaCalculator> log)
    {
        _calculatorFactory = calculatorFactory;
        _logger = log;
    }

    [FunctionName(nameof(AreaCalculator))]
    [OpenApiOperation(operationId: "Run", tags: new[] { "name" })]
    [OpenApiSecurity("function_key", SecuritySchemeType.ApiKey, Name = "code", In = OpenApiSecurityLocationType.Query)]
    [OpenApiParameter(
        name: "shape",
        In = ParameterLocation.Path,
        Required = true,
        Type = typeof(string),
        Description = "The shape (circle, square, rectangle, or triangle)"
    )]
    [OpenApiResponseWithBody(
        statusCode: HttpStatusCode.OK,
        contentType: "application/json",
        bodyType: typeof(AreaResult),
        Description = "The area of the specified shape, rounded to 2 decimal places"
    )]
    public async Task<IActionResult> RunAsync(
        [HttpTrigger(AuthorizationLevel.Function, "post", Route = "calculateArea/{shape}")]
        HttpRequest req,
        string shape,
        CancellationToken ct)
    {
        _logger.LogInformation("Executing area calculator for shape: {shape}", shape);

        if (!Enum.TryParse(shape, true, out Shape shapeType))
            return new BadRequestObjectResult($"{shape} shape not supported");

        var calculator = _calculatorFactory.GetAreaCalculatorForShape(shapeType);
        var result = await calculator.CalculateAreaAsync(req.Body, ct);

        return new OkObjectResult(new AreaResult(result));
    }
}

The Service Factory

The area calculator factory is straightforward. It holds a dictionary of registered calculator services for each available shape type and implements the GetAreaCalculatorForShape method. This method retrieves registered calculator services directly from the IServiceProvider based on the provided shape type.

public class AreaCalculatorFactory : IAreaCalculatorFactory
{
    private readonly Dictionary<Shape, Type> _registeredCalculators;
    private readonly IServiceProvider _serviceProvider;

    public AreaCalculatorFactory(
        IServiceProvider serviceProvider,
        Dictionary<Shape, Type> registeredCalculators)
    {
        _registeredCalculators = registeredCalculators;
        _serviceProvider = serviceProvider;
    }

    public IAreaCalculatorService GetAreaCalculatorForShape(Shape shape)
    {
        if (!_registeredCalculators.TryGetValue(shape, out var calculatorType) || calculatorType == null)
            throw new InvalidOperationException($"No calculator registered for shape {shape}");

        return (IAreaCalculatorService)_serviceProvider.GetRequiredService(calculatorType);
    }
}

The Service Factory Builder

Now you might wonder how this can be easily initialized and registered so it can later be injected where needed. The service provider injected into the factory is immutable and must already contain all calculator services and their dependency tree.

Where and how do you register calculators then? The answer is AreaCalculatorFactoryBuilder. It takes the service collection in its constructor, which means you can register specific calculator services for specific shape types and save the Shape → IAreaCalculatorService mapping at the same time using the RegisterCalculatorForType method.

public class AreaCalculatorFactoryBuilder
{
    private readonly Dictionary<Shape, Type> _registeredCalculators;
    private readonly IServiceCollection _services;

    public AreaCalculatorFactoryBuilder(IServiceCollection services)
    {
        _registeredCalculators = new Dictionary<Shape, Type>();
        _services = services;
    }

    public AreaCalculatorFactoryBuilder RegisterCalculatorForType<TCalculator>(Shape shape)
        where TCalculator : IAreaCalculatorService
    {
        _services.AddScoped(typeof(TCalculator));
        _registeredCalculators.Add(shape, typeof(TCalculator));

        return this;
    }

    public IAreaCalculatorFactory Build(IServiceProvider serviceProvider)
    {
        return new AreaCalculatorFactory(serviceProvider, _registeredCalculators);
    }
}

Service Registration

The factory and its services are then registered as shown below. The AreaCalculatorFactoryBuilder is initialized beforehand, and the Build method is called inside the service provider delegate when the IAreaCalculatorFactory implementation is needed.

You can also see the registration of the custom JSON parser used within the calculator services to deserialize the request body stream into the data needed for area calculation. This demonstrates that the services provided by the factory can have other dependencies injected using .NET dependency injection.

public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddServices(this IServiceCollection services)
    {
        // Parsers
        services.AddScoped<ICustomParser, ShapeParser>();

        // Area calculators
        var areaCalculatorFactoryBuilder = new AreaCalculatorFactoryBuilder(services)
            .RegisterCalculatorForType<TriangleAreaCalculator>(Shape.Triangle)
            .RegisterCalculatorForType<SquareAreaCalculator>(Shape.Square)
            .RegisterCalculatorForType<RectangleAreaCalculator>(Shape.Rectangle)
            .RegisterCalculatorForType<CircleAreaCalculator>(Shape.Circle);

        services.AddScoped(x => areaCalculatorFactoryBuilder.Build(x));

        return services;
    }
}

Explanation of the Remaining Code

Lastly, here is a short explanation of the CircleAreaCalculator and ShapeParser implementations. As you can see, CircleAreaCalculator is an implementation of the abstract class AreaCalculatorBase. AreaCalculatorBase handles parsing and provides data to specific implementations so they can perform the required calculations. The parser uses the built-in .NET JSON serializer with custom options to deserialize the stream into the required type.

public class CircleAreaCalculator : AreaCalculatorBase<Circle>
{
    public CircleAreaCalculator(ICustomParser shapeParser) : base(shapeParser) { }

    protected override double CalculateArea(Circle circle)
        => Math.Pow(circle.R, 2) * Math.PI;
}

public abstract class AreaCalculatorBase<TShape> : IAreaCalculatorService
{
    private readonly ICustomParser _shapeParser;

    public AreaCalculatorBase(ICustomParser shapeParser)
    {
        _shapeParser = shapeParser;
    }

    private async Task<TShape> GetShapeAsync(Stream stream, CancellationToken ct)
        => await _shapeParser.ParseJsonBodyAsync<TShape>(stream, ct);

    public async Task<double> CalculateAreaAsync(Stream stream, CancellationToken ct)
        => CalculateArea(await GetShapeAsync(stream, ct));

    protected abstract double CalculateArea(TShape shape);
}

public class ShapeParser : ICustomParser
{
    private static readonly JsonSerializerOptions SerializerOptions = new JsonSerializerOptions
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    };

    public async Task<T> ParseJsonBodyAsync<T>(Stream stream, CancellationToken ct = default)
    {
        T? val = await JsonSerializer.DeserializeAsync<T>(stream, SerializerOptions, ct);

        if (val == null)
        {
            throw new JsonException("Null payload");
        }

        return val;
    }
}

Summary

The resulting solution is:

  • Easy to use — it uses built-in .NET dependency injection.
  • Open for extension, closed for modification — support for additional shapes can be added without changing existing code.
  • Simple to implement — it can be done in three steps:
  1. Implement a service factory using IServiceProvider to utilize .NET dependency injection.
  2. Implement a service factory builder to simplify service registration and factory creation.
  3. Provide a service factory delegate that builds the required factory when needed.

The example project can be run locally. Just run the function app and open http://localhost:{function-app-port}/api/swagger/ui in your browser. There you can use Swagger UI to make API calls.

Similar posts