Applying template method design pattern

By Abhilash | October 9, 2019

Recently I worked on refactoring a piece of code which dealt with filtering DB entities based on certain attributes and there were different types of such filters each doing almost the same thing and the problem was, how to reuse the common logic while making sure that the specific details of filtering are the only thing each filter will have to worry about.

To better explain the problem and its solution, consider an example of a banking system, different entities in the system are the client (the account holder), account, deposit, withdrawal, investment, investment sale, etc.

A deposit or a withdrawal has a reference to an account which in turn has a client reference, since an account belongs to a client, and we can perform deposit/ withdrawal transactions on it, we can say, “get all deposits for a client and/or account”(a client can have more than one account), similarly withdrawals also can be filtered by a client and/or account.

An investment sale has a reference to an investment which in turn has an account reference since we typically invest a certain amount from an account on an asset (like stock) and later we can liquidate or sell the investment as well. To filter & retrieve investment sales, we can say “get a list of investment sales for a certain client and/or account and/or investment”.

To achieve the above filtering we have implemented two different filter managers here which are ClientOrAccountFilterManager and ClientOrAccountOrInvestmentFilterManager. Each of them intends to filter entities that implement the interface IHasAccountReference and IHasInvestmentReference respectively.

Entities to be filtered

    public class DbDeposit : IHasAccountReference
    {
        public DbAccount Account {get; set;} //this is from account reference

        // Other members follow ...
    }
    public class DbInvestmentSale : IHasInvestmentReference
    {
        public DbInvestment Investment {get; set;} //this is from investment reference

        // Other members follow ...
    }

Filter criteria

    public interface IClientOrAccountFilterCriteria
    {
        Guid AccountId {get; set;}

        string AccountNumber {get; set;}

        Guid ClientId {get; set;}

        string ClientNumber {get; set;}
    }
    public interface IClientOrAccountOrInvestmentFilterCriteria
    {
        Guid AccountId {get; set;}

        string AccountNumber {get; set;}

        Guid ClientId {get; set;}

        string ClientNumber {get; set;}

        Guid InvestmentId {get; set;}
    }

Current filter implementation

To keep things simple and to the point we’ll use pseudo code where necessary to outline the method that filters by client and/or account in ClientOrAccountFilterManager.

    public Task<List<TModelType>> FilterByClientOrAccountAsync<TModelType>(
            IClientOrAccountFilterCriteria filterCriteria,
            PagingOptions pagingOptions,
            Func<IQueryable<TDbType>, IQueryable<TDbType>> filter = null) where TDbType : IHasAccountReference
            {
                //1. Check if filterCriteria is null, if yes throw an argument null exception

                //2. Check if at least one of the filterCriteria are provided, if not then throw insufficient filters exception

                //3. Filter by client and/or account 
                //   for instance one of the predicates would be TDbType.Account.AccountId == filterCriteria.AccountId
                //   or TDbType.Account.Client.ClientId == filterCriteria.ClientId

                //4. Apply additional filters supplied
                if (filter != null)
                {
                    filterQuery = filter(filterQuery);
                }

                //5. Step no 3 & 4 return an IQueryable<TDbType> apply pagination logic on it using the pagingOptions supplied
                //   for instance getting a certain page of data by pageNumber

                //6. If no items were found after applying above filters, check if the filterCriteria supplied are valid 
                //   identifiers, such as check if filterCriteria.AccountId if supplied, is a valid id in the database, if not throw 
                //   an exception

                //7. If we are here then map the List<TDbType> to the List<TModelType> and return
            }

Similarly, the method that filters by client/account/investment in ClientOrAccountOrInvestmentFilterManager can be expressed as below.

    public Task<List<TModelType>> FilterByClientOrAccountOrInvestmentAsync<TModelType>(
            IClientOrAccountOrInvestmentFilterCriteria filterCriteria,
            PagingOptions pagingOptions,
            Func<IQueryable<TDbType>, IQueryable<TDbType>> filter = null) where TDbType : IHasInvestmentReference
            {
                //1. Check if filterCriteria is null, if yes throw an argument null exception

                //2. Check if at least one of the filterCriteria are provided, if not then throw insufficient filters exception

                //3. Filter by client and/or account and/or investment
                //   for instance one of the predicates would be TDbType.Investment.Account.AccountId == filterCriteria.AccountId
                //   or TDbType.Investment.Account.Client.ClientId == filterCriteria.ClientId
                //   or TDbType.InvestmentId == filterCriteria.InvestmentId

                //4. Apply additional filters supplied
                if (filter != null)
                {
                    filterQuery = filter(filterQuery);
                }

                //5. Step no 3 & 4 return an IQueryable<TDbType> apply pagination logic on it using the pagingOptions supplied
                //   for instance getting a certain page of data by pageNumber

                //6. If no items were found after applying above filters, check if the filterCriteria supplied are valid 
                //   identifiers, such as check if filterCriteria.AccountId if supplied, is a valid id in the database, if not throw 
                //   an exception

                //7. If we are here then map the List<TDbType> to the List<TModelType> and return
            }

Template method pattern

As you can see the above filter methods follow the same steps to arrive at a filtered result. The only different thing is the filter criteria and the TDbType constraint, the first filter method works on an IHasAccountReference TDbType and the second one works on TDbType of IHasInvestmentReference.

One of the reasons why the above methods cannot be combined into a single method is provided in this SO answer, in short, we cannot have

    public Task<List<TModelType>> FilterByClientOrAccountOrInvestmentAsync<TModelType>(
            IFilterCriteria filterCriteria,
            PagingOptions pagingOptions,
            Func<IQueryable<TDbType>, IQueryable<TDbType>> filter = null) where TDbType : IHasAccountReference, IHasInvestmentReference

Since the methods cannot be combined we’ll have to see if the process they follow can be generalized and then leave the specifics to be implemented by each filter manager, incidentally, this is exactly what the template method pattern suggests, quoting from the wiki article

In object-oriented programming, the template method is one of the behavioral design patterns identified by Gamma et al. in the book Design Patterns. The template method is a method in a superclass, usually an abstract superclass, and defines the skeleton of an operation in terms of a number of high-level steps. These steps are themselves implemented by additional helper methods in the same class as the template method.

The helper methods may be either abstract methods, for which case subclasses are required to provide concrete implementations, or hook methods, which have empty bodies in the superclass. Subclasses can (but are not required to) customize the operation by overriding the hook methods. The intent of the template method is to define the overall structure of the operation, while allowing subclasses to refine, or redefine, certain steps.

Applying the pattern in FilteringManagerBase

Based on this idea we can create an abstract base as below

    public abstract class FilteringManagerBase<TDbType, TFilterCriteria> 
        where TFilterCriteria : IFilterCriteria
    {
        public async Task<List<TModelType>> FilterAsync<TModelType>(
            TFilterCriteria filterCriteria,
            PagingOptions pagingOptions,
            Func<IQueryable<TDbType>, IQueryable<TDbType>> filter = null)
        {
            //1. Check if filterCriteria is null, if yes throw an argument null exception
            if (filterCriteria is null)
            {
                throw new InvalidDomainOperationException($"{nameof(filterCriteria)} cannot be null", ErrorCodes.FilterArgumentNull);
            }

            //2. Check if at least one of the filterCriteria are provided, if not then throw insufficient filters exception
            if (filterCriteria.IsEmpty())
            {
                throw new InvalidDomainOperationException("Insufficient filters specified", ErrorCodes.InsufficientFiltersSpecified);
            }

            //3. Filter by the right filter criteria which, is detailed by the sub class implementation
            var filterQuery = await GetFilterQuery(filterCriteria).ConfigureAwait(false);

            if (filter != null)
            {
                filterQuery = filter(filterQuery);
            }

            var dbTypesQuery = filterQuery.Paginate(filterCriteria, out var totalCount, ref pagingOptions, this.mapper).ToListAsync().ConfigureAwait(false);

            // If no items were found, check whether the filtering entity exists and if not return appropriate error code
            if (!dbTypes.Any())
            {
                await AssertEntityExists(filterCriteria).ConfigureAwait(false);
            }

            return this.mapper.Map<List<TModelType>>(dbTypes);
        }

        protected abstract Task AssertEntityExists(TFilterCriteria filterCriteria);

        protected abstract Task<IQueryable<TDbType>> GetFilterQuery(TFilterCriteria filterCriteria);
    }

    public interface IFilterCriteria
    {
        bool IsEmpty();
    }

As you can decipher from the above code, it follows the definition for the template method pattern. FilterAsync provides the overall structure of the operation while the helper methods AssertEntityExists, FilterParametersEmpty, GetFilterQuery are overridden by the specific filters which then completes the filter operation definition.

Overriding specifics in subclasses

So the subclasses now inherit the FilteringManagerBase abstract class and follow the structure provided by FilterAsync but define their filtering behavior by overriding the helper methods AssertEntityExists, FilterParametersEmpty, GetFilterQuery as shown below.

public sealed class ClientOrAccountFilteringManager<TDbType, TQuery, TFilterCriteria> : FilteringManagerBase<TDbType, TFilterCriteria>, IClientOrAccountFilteringManager<TDbType, TFilterCriteria>
        where TDbType : IHasAccountReference
        where TQuery : IQuery<TDbType, Guid>
        where TFilterCriteria : IFilterCriteria, , IClientOrAccountFilterCriteria
    {
        protected override async Task AssertEntityExists(TFilterCriteria clientOrAccountFilterCriteria)
        {
            // If any client details are passed in, check that the client actually exists
            if (clientOrAccountFilterCriteria.AreClientFiltersProvided())
            {
                var dbClient = await this.clientQuery.GetByReferenceAsync(
                    clientOrAccountFilterCriteria.ClientId,
                    clientOrAccountFilterCriteria.ClientNumber).ConfigureAwait(false);

                if (dbClient == null)
                {
                    throw new InvalidDomainOperationException("Client not found", ErrorCodes.ClientNotFound);
                }
            }   

            // If any account details are passed in, check that the account actually exists for the client (in the case that client details were also passed in)
            if (clientOrAccountFilterCriteria.AreAccountFiltersProvided())
            {
                var dbAccount = await this.accountQuery.GetByAnyReferenceAsync(
                    clientOrAccountFilterCriteria.AccountId,
                    clientOrAccountFilterCriteria.AccountNumber,
                    clientOrAccountFilterCriteria.ClientId,
                    clientOrAccountFilterCriteria.ClientNumber).ConfigureAwait(false);

                if (dbAccount == null)
                {
                    throw new InvalidDomainOperationException("Account not found", ErrorCodes.AccountNotFound);
                }
            }
        }

        protected override async Task<IQueryable<TDbType>> GetFilterQuery(TFilterCriteria clientOrAccountFilterCriteria)
        {           
            return (await this.query.GetAllAsync().ConfigureAwait(false))
                    .FilterByIdAndNumber(
                        x => x.Account.Client.Id,
                        x => x.Account.Client.ClientNumber,
                        clientOrAccountFilterCriteria.ClientId,
                        clientOrAccountFilterCriteria.ClientNumber,
                        ErrorCodes.ClientFilterNotSpecified
                    )
                    .FilterByIdAndNumber(
                        x => x.Account.Id,
                        x => x.Account.AccountNumber,
                        clientOrAccountFilterCriteria.AccountId,
                        clientOrAccountFilterCriteria.AccountNumber,
                        ErrorCodes.AccountFilterNotSpecified
                    );
        } 
    }
}
public class ClientOrAccountOrInvestmentFilteringManager<TDbType, TQuery, TFilterCriteria> : FilteringManagerBase<TDbType, TFilterCriteria>, IClientOrAccountOrInvestmentFilteringManager<TDbType, TFilterCriteria>
        where TDbType : IHasInvestmentReference
        where TQuery : IQuery<TDbType, Guid>
        where TFilterCriteria : IFilterCriteria, IClientOrAccountOrInvestmentFilterCriteria
    {
        protected override async Task AssertEntityExists(TFilterCriteria clientOrAccountOrInvestmentFilterCriteria)
        {
            // If any client details are passed in, check that the client actually exists
            if (clientOrAccountOrInvestmentFilterCriteria.AreClientFiltersProvided())
            {
                var dbClient = await this.clientQuery.GetByReferenceAsync(
                    clientOrAccountOrInvestmentFilterCriteria.ClientId,
                    clientOrAccountOrInvestmentFilterCriteria.ClientNumber).ConfigureAwait(false);

                if (dbClient == null)
                {
                    throw new InvalidDomainOperationException("Client not found", ErrorCodes.ClientNotFound);
                }
            }

            // If any account details are passed in, check that the account actually exists for the client (in the case that client details were also passed in)
            if (clientOrAccountOrInvestmentFilterCriteria.AreAccountFiltersProvided())
            {
                var dbAccount = await this.accountQuery.GetByAnyReferenceAsync(
                    clientOrAccountOrInvestmentFilterCriteria.AccountId,
                    clientOrAccountOrInvestmentFilterCriteria.AccountNumber,
                    clientOrAccountOrInvestmentFilterCriteria.ClientId,
                    clientOrAccountOrInvestmentFilterCriteria.ClientNumber).ConfigureAwait(false);

                if (dbAccount == null)
                {
                    throw new InvalidDomainOperationException("Account not found", ErrorCodes.AccountNotFound);
                }
            }

            // If any investment details are passed in, check that the investment actually exists for the account (in the case that account and client details were also passed in)
            if (clientOrAccountOrInvestmentFilterCriteria.AreInvestmentFiltersProvided())
            {
                var dbInvestment = await this.investmentQuery.GetByAnyReferenceAsync(
                    clientOrAccountOrInvestmentFilterCriteria.InvestmentId,
                    clientOrAccountOrInvestmentFilterCriteria.AccountId,
                    clientOrAccountOrInvestmentFilterCriteria.AccountNumber,
                    clientOrAccountOrInvestmentFilterCriteria.ClientId,
                    clientOrAccountOrInvestmentFilterCriteria.ClientNumber).ConfigureAwait(false);

                if (dbInvestment == null)
                {
                    throw new InvalidDomainOperationException("Investment not found", ErrorCodes.InvestmentNotFound);
                }
            }
        }

        protected override async Task<IQueryable<TDbType>> GetFilterQuery(TFilterCriteria clientOrAccountFilterCriteria)
        {
            return (await this.query.GetAllAsync().ConfigureAwait(false))
                    .FilterByIdAndNumber(
                        x => x.Account.Client.Id,
                        x => x.Account.Client.ClientNumber,
                        clientOrAccountFilterCriteria.ClientId,
                        clientOrAccountFilterCriteria.ClientNumber,
                        ErrorCodes.ClientFilterNotSpecified
                    )
                    .FilterByIdAndNumber(
                        x => x.Account.Id,
                        x => x.Account.AccountNumber,
                        clientOrAccountFilterCriteria.AccountId,
                        clientOrAccountFilterCriteria.AccountNumber,
                        ErrorCodes.AccountFilterNotSpecified
                    )
                    .FilterByInvestmentFilter(clientOrAccountOrInvestmentFilter, ErrorCodes.InvestmentFilterNotSpecified);
        }
    }
Share on: