Developers love patterns. There are many patterns we can use or follow. A few well-known patterns are the strategy pattern, observer pattern, and builder pattern. There are many more and each has its own pros and cons. This time, I want to show you the decorator pattern. The idea behind this pattern is that you can add behavior to an existing object without affecting other objects of the same class. Sounds complicated? Well, it's not... Once you get to know it.
Introduction
If you want to skip all my hard work in the next chapters and have no idea what I am about to tell, you can simply download the end product from my GitHub repository.
If you want to follow all the things I am showing, make sure you open the branch "StartProject
". The branch "EndProject
" contains all the code I will be adding in this tutorial.
Explaining the Start Situation
My start project is pretty straightforward. There are two projects; CachingDemo.Business
and CachingDemo.API
. The API is a minimal API that is just here to show some of the UI. The business is the one that needs some changing and I will be focusing on this one the most. Especially the class MovieService.cs.
If you open this class, you will find the method GetAll()
. It looks like this:
public IEnumerable<Movie> GetAll()
{
string key = "allmovies";
Console.ForegroundColor = ConsoleColor.Red;
if (!memoryCache.TryGetValue(key, out List<Movie>? movies))
{
Console.WriteLine("Key is not in cache.");
movies = _dbContext.Set<Movie>().ToList();
var cacheOptions = new MemoryCacheEntryOptions()
.SetSlidingExpiration(TimeSpan.FromSeconds(10))
.SetAbsoluteExpiration(TimeSpan.FromSeconds(30));
memoryCache.Set(key, movies, cacheOptions);
}
else
{
Console.WriteLine("Already in cache.");
}
Console.ResetColor();
return movies ?? new List<Movie>();
}
It actually does two things: Handle cache data and the actual data if it doesn't exist in the cache. And this is what the decorate pattern could solve. Let the MovieService.cs do what it needs to do and let another class handle the cache.
A Caching Service
The first thing we need to do is create a service for the cache. Each service gets its own cache service. I have one service, MovieService
, and I create another class and call it MovieService_Cache.cs. The reason for this name is simple: It will be placed directly under the original MovieService file.
I reuse the same interface I am using for the MovieService
.
public class MovieService_Cache : IMovieService
{
public void Create(Movie movie)
{
throw new NotImplementedException();
}
public void Delete(int id)
{
throw new NotImplementedException();
}
public Movie? Get(int id)
{
throw new NotImplementedException();
}
public IEnumerable<Movie> GetAll()
{
throw new NotImplementedException();
}
}
Dependency Injections
I am using the IMemoryCache
to inject the caching mechanism into the MovieService
class, so I am doing the same in the cached version.
And here is the trick part 1: I inject the IMovieService
in this class too. The IMovieService
is connected to the MovieService
, not the MovieService_Cache
, so it's safe to do. The cache class looks like this after the changes:
public class MovieService_Cache : IMovieService
{
private readonly IMemoryCache memoryCache;
private readonly IMovieService movieService;
public MovieService_Cache(IMemoryCache memoryCache, IMovieService movieService)
{
this.memoryCache = memoryCache;
this.movieService = movieService;
}
public void Create(Movie movie)
{
throw new NotImplementedException();
}
public void Delete(int id)
{
throw new NotImplementedException();
}
public Movie? Get(int id)
{
throw new NotImplementedException();
}
public IEnumerable<Movie> GetAll()
{
throw new NotImplementedException();
}
}
Adding Some Body to the Methods
Time to add some code to the methods. From the top to bottom:
The Create method doesn't need caching. All it does is send data to the database and done. So I will be returning the result of the injected MovieService
instance.
Delete is the same idea: no caching, so just reuse the original MovieService
instance.
The Get(int id)
could use cache. Here, I am using the caching mechanism. The code is below. But, as soon as the key is not in the cache (the item doesn't exist in the cache), I need to retrieve it from the database. This is something that the original MovieService
does, not the cached version. See how I create and use the single responsibility principle?
I will do the exact same thing with the GetAll()
method.
And here is the code:
public class MovieService_Cache : IMovieService
{
private readonly IMemoryCache memoryCache;
private readonly IMovieService movieService;
public MovieService_Cache(IMemoryCache memoryCache, IMovieService movieService)
{
this.memoryCache = memoryCache;
this.movieService = movieService;
}
public void Create(Movie movie)
{
movieService.Create(movie);
}
public void Delete(int id)
{
movieService.Delete(id);
}
public Movie? Get(int id)
{
string key = $"movie_{id}";
if (memoryCache.TryGetValue(key, out Movie? movie))
return movie;
movie = movieService.Get(id);
var cacheOptions = new MemoryCacheEntryOptions()
.SetSlidingExpiration(TimeSpan.FromSeconds(10))
.SetAbsoluteExpiration(TimeSpan.FromSeconds(30));
memoryCache.Set(key, movie, cacheOptions);
return movie;
}
public IEnumerable<Movie> GetAll()
{
string key = $"movies";
if (memoryCache.TryGetValue(key, out List<Movie>? movies))
return movies ?? new List<Movie>();
movies = movieService.GetAll().ToList();
var cacheOptions = new MemoryCacheEntryOptions()
.SetSlidingExpiration(TimeSpan.FromSeconds(10))
.SetAbsoluteExpiration(TimeSpan.FromSeconds(30));
memoryCache.Set(key, movies, cacheOptions);
return movies;
}
}
If you look at the Get(int id)
method, you see nothing special. If the key does not exist in the cache, it will get the movie from the original MovieService
, puts that in the cache, and returns the result.
One thing: I removed all the caching from the MovieService.cs, including the injection of IMemoryCache
. MovieService.cs now looks like this:
public class MovieService : IMovieService
{
private readonly DataContext _dbContext;
public MovieService(DataContext dbContext)
{
_dbContext = dbContext;
}
public void Create(Movie movie)
{
_dbContext.Set<Movie>().Add(movie);
_dbContext.SaveChanges();
}
public void Delete(int id)
{
Movie? toDelete = Get(id);
if (toDelete == null)
return;
_dbContext.Remove(toDelete);
_dbContext.SaveChanges();
}
public Movie? Get(int id) => _dbContext.Set<Movie>().FirstOrDefault(x => x.Id == id);
public IEnumerable<Movie> GetAll() => _dbContext.Set<Movie>().ToList();
}
Adding Decoration to the MovieService
If you start the API now, it won't use any of the caching methods. It will just get all the movies from the database over and over again. We need to decorate the MovieService
class. This is a dependency injection configuration.
To realize this, I install the package Scrutor
. This package contains the extension method Decorate
, which helps us decorate the MovieService
with the MovieService_Cache
.
So, first, install the package:
Install-Package Scrutor
Then we head over to the API and open the Program.cs. Find the line where the IMovieService
is connected to the MovieService
. Add the following line of code under that:
builder.Services.Decorate<IMovieService, MovieService_Cache>();
That's It, Folks!
Nothing more to do. The decorate pattern is ready to do its job.
To test it, I recommend setting a breakpoint on a method in the MovieService_Cache
, starting up the API, and executing that method with the breakpoint.
What happens is that the API will call the MovieService
, but since it's decorated with the MovieService_Cache
, it will first execute the method in the MovieService_Cache
, overruling the original class.
Conclusion
This is just a small example of the decorator pattern. But it can be very powerful. I wouldn't overuse it; don't decorate every class you have. You don't have to change anything in existing classes, which makes it safer if you are working in a big application where a class already works, but needs to change just a little bit.
I would love to see .NET's own implementation of the decorator so we don't have to use a third-party package.
History
- 6th April, 2023: Initial version