🎨 DRY the paint on your Cross Cutting Concerns

Techniques to achieve clean cross cutting concerns

  1. Scenario
    1. Requirements
  2. Techniques
    1. Simple approach
    2. Dynamic dispatch
    3. Query pattern
    4. Aspect Oriented Programming (AOP)
  3. Conclusion

There are many techniques of varying sophistication to achieve clean cross cutting concerns. Let’s walk through some of them and discuss their pros and cons.

This post mostly serves as a reference for myself, to gather my thoughts. I won’t bother with implementation details and sample code may not compile.

Scenario

Imagine we have a repository that fetches weather data from a database.

It could look similar to the below, but assume many more methods.

public interface IWeatherRepository
{
    WeatherForecast GetForecast();

    int GetTemperature();
}

Requirements

We will evaluate the techniques based on the following requirements:

  1. We don’t really care about the implementation, but we want to start applying some caching to avoid repeated database calls, as we have a lot of read requests coming in and the data won’t change for some time.
    • Bonus points for not needing to duplicate the caching code in each method.
  2. Initially we can use a memory cache, but later we might want to switch to a distributed cache.
    • Bonus points for least amount of changes needed to switch the caching implementation.
  3. We want to apply other cross-cutting concerns to the repository, like logging.
    • Bonus points for ease of extensibility and maintainability.
  4. Lastly, we want to be able to test the repository without caching and logging.

Techniques

Simple approach

Often the first approach that comes to mind is to inject a cache and add the caching logic to each of the methods.

public class WeatherRepositoryWithCache : IWeatherRepository
{
    private readonly IMemoryCache cache;

    public WeatherForecast GetForecast() => cache.GetOrCreate(
        nameof(GetForecast),
        entry => Extensions.GetDemoForecast());

    public virtual int GetTemperature() => cache.GetOrCreate(
        nameof(GetTemperature),
        entry => new Random().Next(-10, 40));
}

It might seem simple at first, but once you start applying this at scale, you will quickly realize that this is not maintainable. Think about tens of repository classes, each with tens of methods.

Dynamic dispatch

By overriding the methods in our repository, we can achieve separation of concerns between the business logic and the caching.

public class CachedWeatherRepository : WeatherRepository
{
    private readonly MemoryCache cache;

    public override WeatherForecast GetForecast()
    {
        var key = new DateOnly();
        return cache.GetOrCreate<WeatherForecast>(
            key,
            entry => base.GetForecast());
    }
}

Now we have a semi-central place for caching, although it still has to be duplicated per repository class. We also still have to duplicate the caching logic in each method.

Note that this requires the methods to be virtual. We can work around this limitation with the decorator pattern.

Given n repositories, and m cross-cutting concerns, we would need n * m subclasses. Simple code, but not maintainable.

Query pattern

This approach combines several patterns to achieve high flexibility.

The central idea is to treat any request to the database as a query object, which is passed to an executor. Contrary to their name, these query objects actually implement the command pattern, meaning they encapsulate a set of instructions that can be defined separately from their execution.

Usually, you will have some interface like this at the top level:

interface IQuery<T>
{
    T Execute();
}

Important is that these queries can be newed without any dependencies. We can then isolate the query specification / construction by using a factory. The concrete implementation depends on your database.

public sealed class QueryFactory
{
    public IQuery<WeatherForecast> BuildForecastQuery() => new QueryExpression(...);
    
    public IQuery<int> BuildTemperatureQuery() => new QueryExpression(...);
}

Using the decorator pattern, we can wrap the core query logic with additional behavior.

Combining this, you will end up with something like this:

public class QueryWeatherRepository : IWeatherRepository
{
    private readonly QueryExecutor queryExecutor;
    private readonly QueryFactory queryFactory;
    
    public WeatherForecast GetForecast()
    {
        var query = queryFactory.BuildForecastQuery()
            .UseCaching() // extensions that leverage the decorator pattern
            .UseLogging();
        return queryExecutor.Execute(query);
    }

No subclassing, no virtual methods, no need to duplicate the caching or logging logic.

A downside is that you have to think and come up with a setup for this.

Let’s check off the requirements:

  • No duplicated caching logic
  • Switching caching implementation only touches a single class (caching decorator)
  • We can add any cross-cutting concern by adding a decorator
  • We can test the queries independently

One more thing to observe is how our code becomes much more declarative and high level. Our repository no longer dishes out specific, brittle instructions. Rather, we specify the behavior we would like to see and rely on the framework to execute it.

Aspect Oriented Programming (AOP)

Although I haven’t had a chance to try AOP in C# yet, I am familiar with AspectJ and Spring AOP.

PostSharp is similar to Spring, with the main downside that it is commercial. We can add caching with a few attributes:

[CacheConfiguration(AbsoluteExpiration = 10)]
public sealed class WeatherRepository : IWeatherRepository
{
    [Cache]
    public WeatherForecast GetForecast() => throw NotImplementedException();
}

This is easily the cleanest approach, requiring the least amount of code. You can add more behavior like logging or retry simply by adding the corresponding attributes.

It feels like magic, which is sometimes claimed to be a bad thing. I for one believe AOP is a true boon to software development. One downside is the framework buy-in.

Even if you don’t use AOP in your daily work, I still highly recommend learning about it on your own time. It gives you a new perspective that will help you identify and solve architectural problems in your code.

In particular, I found this course to be a great learning resource:

Conclusion

My personal favorite is the AOP approach, when available. Otherwise the query pattern is a good alternative that offers a lot of flexibility.

The simpler patterns can be used during POC, but any non-trivial app will benefit from a clear-defined architecture.


© 2024. All rights reserved.