Nanoservices – Part 3

Nanoservices – Part 3

Last time I presented a quick overview of the UI portion of a nanoservice for converting from file extensions to mime types. This time I’ll focus on the business logic for that service. Specifically, I’ll be looking into the CG.Obsidian.Abstractions and CG.Obsidian libraries, which are part of the overall solution.

Let’s start by looking at the shared abstractions for the service:

We have a manager type: IMimeTypeManager, which is tasked with exposing the various stores and, of course, using those stores to implement any required logic. In this case, the mime type manager has two properties: MimeTypes, which is of type IMimeTypeStore, and FileExtensions, which is of type IFileExtensionStore. The manager also has a method named FindByExtensionAsync, which is responsible for producing a list of mime types, given nothing more than a file extension as an input parameter.

Both stores have an AsQueryable method, which returns an IQueryable<T> object. Now, I know some will grumble about leaky abstractions, but, in my opinion, LINQ itself is super leaky so when I write code, in C#, that uses LINQ, I no longer jump through hoops to avoid otherwise unavoidable abstraction leaks.

Both stores also contain the typical CRUD methods we’ve all come to expect. The only difference, of course, is that the IMimeTypeStore uses the MimeType model type, and the IFileExtensionStore uses the FileExtension model type.

In addition to the stores types, we also have matching repository types. In this case, we have the IMimeTypeRepository and IFileExtensionRepository types. That’s not surprising since stores typically wrap a repository in order to separate the business logic from the storage logic.

Finally, we have the MimeType and FileExtension classes, which are simply models that represent the notion of a mime type, and an associated file extension.

The implementation of the interfaces looks something like this:

The store implementations are named MimeTypeStore, and FileExtensionStore. Both stores contain a repository reference that is injected via the ASP.NET Dependency Injection (DI) library, at runtime. We’ll talk about the implementation of the repository interfaces, and the overall data layer, in the next article.

Now that we have a good overall picture of what’s going on with this library, let’s go look at some code. Since the implementation of the stores are so similar, I’ll only detail the MimeTypeStore in this article. But, the source code is available HERE, if you’re curious about the FileExtensionStore.

Here is a listing of the MimeTypeStore:

public class MimeTypeStore : StoreBase, IMimeTypeStore
{
    protected IMimeTypeRepository Repository { get; }
    protected ILogger<MimeTypeStore> Logger { get; }

    public MimeTypeStore(
        IMimeTypeRepository repository,
        ILogger<MimeTypeStore> logger
        )
    {
        Guard.Instance().ThrowIfNull(repository, nameof(repository))
            .ThrowIfNull(logger, nameof(logger));

        Repository = repository;
        Logger = logger;
    }

    public virtual IQueryable<MimeType> AsQueryable()
    {
        try
        {
            var query = Repository.AsQueryable();
            return query;
        }
        catch (Exception ex)
        {
            Logger.LogError(
                ex,
                "Failed to query for mime types!"
                );

            throw new StoreException(
                message: $"Failed to query for mime types!",
                innerException: ex
                ).SetCallerInfo()
                    .SetOriginator(nameof(MimeTypeStore))
                    .SetDateTime();
        }
    }

    public virtual async Task<MimeType> AddAsync(
        MimeType model,
        CancellationToken cancellationToken = default
        )
    {
        try
        {
            Guard.Instance().ThrowIfNull(model, nameof(model));

            for (var x = 0; x < model.Extensions.Count; x++)
            {
                model.Extensions[x].Extension = 
                    model.Extensions[x].Extension.StartsWith(".")
                    ? model.Extensions[x].Extension 
                    : $".{model.Extensions[x].Extension}";
            }

            var addedModel = await Repository.AddAsync(
                model,
                cancellationToken
                ).ConfigureAwait(false);

            return addedModel;
        }
        catch (Exception ex)
        {
            Logger.LogError(
                ex,
                "Failed to add a new mime type!",
                (model != null ? JsonSerializer.Serialize(model) : "null")
                );
                
            throw new StoreException(
                message: $"Failed to add a new mime type!",
                innerException: ex
                ).SetCallerInfo()
                    .SetOriginator(nameof(MimeTypeStore))
                    .SetMethodArguments(("model", model))
                    .SetDateTime();
        }
    }

    public virtual async Task<MimeType> UpdateAsync(
        MimeType model,
        CancellationToken cancellationToken = default
        )
    {
        try
        {
            Guard.Instance().ThrowIfNull(model, nameof(model));

            for (var x = 0; x < model.Extensions.Count; x++)
            {
                model.Extensions[x].Extension =
                    model.Extensions[x].Extension.StartsWith(".")
                    ? model.Extensions[x].Extension
                    : $".{model.Extensions[x].Extension}";
            }

            var updatedModel = await Repository.UpdateAsync(
                model,
                cancellationToken
                ).ConfigureAwait(false);

            return updatedModel;
        }
        catch (Exception ex)
        {
            Logger.LogError(
                ex,
                "Failed to update an existing mime type!",
                (model != null ? JsonSerializer.Serialize(model) : "null")
                );

            throw new StoreException(
                message: $"Failed to update an existing mime type!",
                innerException: ex
                ).SetCallerInfo()
                    .SetOriginator(nameof(MimeTypeStore))
                    .SetMethodArguments(("model", model))
                    .SetDateTime();
        }
    }

    public virtual async Task DeleteAsync(
        int id,
        CancellationToken cancellationToken = default
        )
    {
        try
        {
            Guard.Instance().ThrowIfZero(id, nameof(id));

            var model = Repository.AsQueryable().FirstOrDefault(x => 
                x.Id == id 
                );

            if (null == model)
            {
                throw new KeyNotFoundException(
                    message: $"Key: {id}"
                    );
            }

            await Repository.DeleteAsync(
                model,
                cancellationToken
                ).ConfigureAwait(false);
        }
        catch (Exception ex)
        {
            Logger.LogError(
                ex,
                $"Failed to delete an existing mime type!",
                id
                );

            throw new StoreException(
                message: $"Failed to delete an existing mime type!",
                innerException: ex
                ).SetCallerInfo()
                    .SetOriginator(nameof(MimeTypeStore))
                    .SetMethodArguments(("id", id))
                    .SetDateTime();
        }
    }
}

Starting from the top, we see that the class derives from the StoreBase class, and implements the IMimeTypeStore interface (as shown earlier, in the diagram). The StoreBase class is part of the CG.Business NUGET package, available HERE.

The class contains two properties: The Repository property, which contains a reference to our repository object, and the Logger property, which contains a reference to a logger object.

The constructor simply takes the repository and logger references from the DI container, and passes them to our Repository and Logger properties.

The AsQueryable method simply defers to the repository’s matching AsQueryable method.

The AddAsync method loops through and ensures that each file extension associated with the MimeType object, starts with a ‘.’ character. By doing that here, and again in the UpdateAsync method, we never have to worry about consistently formatting file extensions again. This store method defers to the matching repository method and returns the results.

The UpdateAsync method also loops through and ensures that each associated file extension is properly formatted. Afterwards, it defers to the matching UpdateAsync method ion the repository, then returns the results.

The DeleteAsync method first looks for a matching MimeType model, using the identifier passed in from the caller. Assuming a match is found, the model is then passed to the matching DeleteAsync method in the repository.

If it seems like all a store does is defer to an inner repository, it’s because it does! After all, a store is just a concrete abstraction that contains, and wraps, one or more inner repository objects. The point of that isn’t to duplicate code between the store and the repository types – even though that’s what it seems like, at times – it’s to allow callers to deal with the store type and never have to realize that the repository types even exist. The DI container takes care of wiring everything up, at runtime, and our store callers need be none the wiser. What’s more, if we change concrete repository types, say from Oracle to SQL-Server repositories, then any business logic in the stores themselves can be, and should be, oblivious to the change. These stores are obviously simple, because our nanoservice is simple, but that doesn’t change the underlying concepts.

Before we move on from stores. I didn’t specifically talk about error handling, for the various store methods, so I’ll do that now. All the store methods live inside a try/catch block. The catch portion of those blocks all do two things: (1) they log the error with the logger, and (2) they rethrow the exception, as a StoreException, with (hopefully) better context than whatever came with the original exception. Rethrowing the exception allows callers to catch and process any errors within their own scope. Adding context through the SetCallInfo, SetOriginator, SetMethodArguments, etc, allow us to present additional data without requiring the rest of the world to know how to parse that extra information out of our error messages.

I’ve mentioned several time that the DI container is expected to wire all of these classes together, at runtime. That doesn’t happen by itself, of course. I typically use extension methods for that purpose, hung off the IServiceCollection type. I typically call those methods from the server’s Startup class. For the CG.Obsidian library types, I use two extension methods called AddObsidianStores and AddObsidianManagers. Here is the listing for those:

public static partial class ServiceCollectionExtensions
{
    public static IServiceCollection AddObsidianStores(
        this IServiceCollection serviceCollection,
        IConfiguration configuration,
        ServiceLifetime serviceLifetime = ServiceLifetime.Scoped
        )
    {
        Guard.Instance().ThrowIfNull(serviceCollection, nameof(serviceCollection))
            .ThrowIfNull(configuration, nameof(configuration));

        serviceCollection.Add<IMimeTypeStore, MimeTypeStore>(serviceLifetime);
        serviceCollection.Add<IFileExtensionStore, FileExtensionStore>(serviceLifetime);

        return serviceCollection;
    }

    public static IServiceCollection AddObsidianManagers(
        this IServiceCollection serviceCollection,
        IConfiguration configuration,
        ServiceLifetime serviceLifetime = ServiceLifetime.Scoped
        )
    {
        Guard.Instance().ThrowIfNull(serviceCollection, nameof(serviceCollection))
            .ThrowIfNull(configuration, nameof(configuration));

        serviceCollection.Add<IMimeTypeManager, MimeTypeManager>(serviceLifetime);

        return serviceCollection;
    }
}

The AddObsidianStores method registers the IMimeTypeStore and IFileExtensionStore types with the ASP.NET DI container. I’m using my own Add extension method, hung off the IServiceCollection type, to perform the operation, since my overload allows me to pass in the service lifetime as a parameter. I find that approach to be more flexible. That extension method is part of my CG.DependencyInjection NUGET package, which is available HERE.

The AddObsidianManagers method registers the IMimeTypeManager type with the ASP.NET DI container. Once again, I’m passing in the service lifetime, for better felxibility.

That’s really about it for the business logic and abstractions. It goes fast because the nanoservice itself it so simple.

Next time, I’ll cover the the database and the data layer, including the repositories.

Photo by Bench Accounting on Unsplash