.NET Steps – Caching I – In Memory

Introduction

To be completely honest, I was never a fan of caching, always found it a bit annoying to work with.

  • Check if something is in a cache, if it’s not, add it.
    • Aaah, an additional if statement, how nice 🙂
  • How often are we going to refresh cache?
    • Yup, not gonna get it right the first time.
  • What if we have updates, do we update a cache as well?
    • Yes 🙁
  • I changed one value in the database but my response is cached for 10 minutes
    • I know, I can restart my application.
    • Oh, it didn’t work.
    • Right, we are using Redis, I have to do it manually.
    • Fuck it, I’m gonna add RedisController.cs to handle refreshing cache.
  • What the fuck am I gonna use as key?

This is not a post that is going to solve those issues. There is this thing in .NET called output caching that I want to explore so I think that best approach in doing so is to give you guys some introduction to couple of possibilities that .NET offers us for caching. Sooo, series! Yay!

  • Part 1 – In Memory Caching
  • Part 2 – Distributed Caching
  • Part 3 – Output caching

Now, the frequency of these post will entirely depend on how fun the World of Warcraft Season 2 will be. (So you can expect next post in, I don’t know, 2 months?)

What is cache?

General

A cache is a high-speed data storage layer which stores data so that future requests for that data are served faster than before.

Since cache is a key – value pair, I like to picture it as one big JSON object where properties of that objects are unique keys (strings) and values are your cached data (byte []).

When you are working with cache (that JSON object) you are not manipulating with the whole object, just the part of if (you are just adding or removing keys). I wonder if there is data structure similar to this…

When

  • When you have data that doesn’t change frequently
  • When you have requests that take too much time to complete do to unoptimzed queries/third party systems
  • When you have a bunch of requests and you don’t want to open connection to the database for each and every one. Good example is landing page of any news portal. If you have 10000 requests per second just for the landing page, is it better to make one call to the database? And what do you think, can we put cache expiration time to 10 minutes?

How will they know?

In Memory Caching

Currently, .NET provides several ways of caching

The current implementation of the IMemoryCache is a wrapper around the ConcurrentDictionary<TKey,TValue>. Entries within the cache can be any object. The in-memory cache solution is great for apps that run on a single server, where all the cached data rents memory in the app’s process.

This means that cache is just a thread safe object that lives in memory from the moment you start your application to the moment you stop it.

Implementation

Prerequisites
Steps
  • In Infrastructure folder create new project: Company.Shorts.Infrastructure.Cache
  • In Company.Shorts.Application.Contracts add folder Cache
    • Add interface ICacheService.cs -> this will serve as our contract that we are going to implement later. And hopefully, reuse it for Part 2. I guess we can do it better (feel free to try), but for now, this will work.
namespace Company.Shorts.Application.Contracts.Cache
{
    public interface ICacheService
    {
        Task<T> GetOrAdd<T>(string key, Func<Task<T>> fun);
    }
}
  • In Company.Shorts.Infrastructure.Cache.csproj add following nuget packages
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="6.0.0" />
    <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="6.0.1" />
    <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="6.0.0" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\..\Application\Company.Shorts.Application.Contracts\Company.Shorts.Application.Contracts.csproj" />
  </ItemGroup>

</Project>
  • In Company.Shorts.Infrastructure.Cache add dependency to Company.Shorts.Application.Contracts
  • In Company.Shorts.Infrastructure.Cache add folder named Internal
    • In that folder, add CacheService.cs

namespace Company.Shorts.Infrastructure.Cache.Internal
{
    using Company.Shorts.Application.Contracts.Cache;
    using Microsoft.Extensions.Caching.Memory;

    internal sealed class CacheService : ICacheService
    {
        private readonly IMemoryCache _cache;

        public CacheService(IMemoryCache cache)
        {
            _cache = cache;
        }

        public async Task<T> GetOrAdd<T>(string key, Func<Task<T>> fun)
        {
            var item = this._cache.Get<T>(key);

            if (item is null)
            {
                item = await fun();

                this._cache.Set(key, item);
            }

            return item;
        }
    }
}
  • In Company.Shorts.Infrastructure.Cache add DependencyInjection.cs
namespace Company.Shorts.Infrastructure.Cache
{
    using Company.Shorts.Application.Contracts.Cache;
    using Company.Shorts.Infrastructure.Cache.Internal;
    using Microsoft.Extensions.DependencyInjection;

    public static class DependencyInjection
    {
        public static IServiceCollection AddCache(this IServiceCollection services)
        {
            services.AddMemoryCache();
            services.AddScoped<ICacheService, CacheService>();
            return services;
        }
    }
}
  • In Company.Shorts add dependency to Company.Shorts.Infrastructure.Cache
    • Call your AddCache in Startup.cs
namespace Company.Shorts
{
    using Company.Shorts.Application;
    using Company.Shorts.Blocks.Common.Mapping.Configuration;
    using Company.Shorts.Blocks.Common.Serilog.Configuration;
    using Company.Shorts.Blocks.Common.Swagger.Configuration;
    using Company.Shorts.Blocks.Presentation.Api.Configuration;
    using Company.Shorts.Infrastructure.Db.Postgres;
    using Company.Shorts.Infrastructure.ExampleAdapter;
    using Company.Shorts.Presentation.Api;
    using Hellang.Middleware.ProblemDetails;
    using Company.Shorts.Infrastructure.Cache;
    public sealed class Startup
    {
        public Startup(
            IConfiguration configuration,
            IWebHostEnvironment environment)
        {
            Configuration = configuration;
            Environment = environment;
        }

        public IConfiguration Configuration { get; }

        public IWebHostEnvironment Environment { get; }

        public ExampleAdapterSettings ExampleAdapterSettings =>
            Configuration
                .GetSection(ExampleAdapterSettings.Key)
                .Get<ExampleAdapterSettings>();

        public PostgresAdapterSettings PostgresAdapterSettings =>
            Configuration
                .GetSection(PostgresAdapterSettings.Key)
                .Get<PostgresAdapterSettings>();

        public void ConfigureServices(IServiceCollection services)
        {
            // Right here.
            services.AddCache();
            services.AddCors();
            services.AddHealthChecks();
            services.AddPostgresDatabaseLayer(PostgresAdapterSettings);
            services.AddInfrastructureExampleAdapter(ExampleAdapterSettings);
            services.AddApplicationLayer();
            services.AddPresentationLayer(Environment);
            services.AddAutoMapperConfiguration(AppDomain.CurrentDomain);
        }

        public void Configure(IApplicationBuilder app)
        {
            app.UseProblemDetails();

            if (!Environment.IsDevelopment())
            {
                app.UseHsts();
            }

            app.MigratePostgresDb();

            app.UseSwaggerConfiguration();

            app.UseHttpsRedirection();

            app.UseCors(options => options
                .AllowAnyOrigin()
                .AllowAnyMethod()
                .AllowAnyHeader());

            app.UseSerilogConfiguration();

            app.UseRouting();

            app.UseEndpoints(endpoints =>
            {
                endpoints.MapDefaultControllerRoute();
                endpoints.MapDefaultHealthCheckRoute();
            });
        }
    }
}
  • Right click on Company.Shorts and press ManageUserSecrets
{
  "PostgresAdapterSettings:Url": "Server=localhost;Port=5432;Database=ShortsUserDb;User Id=postgres;Password=postgres",
}
  • Now, lets use what we implemented.
  • In Company.Shorts.Application
    • Create folder Command
      • Add CreateUserCommand.cs -> This is here just so that we can easily insert some data in our database.
namespace Company.Shorts.Application.UserAggregate.Command
{
    using Company.Graphql.Application.Contracts.Db;
    using Company.Shorts.Domain;
    using MediatR;
    using System;
    using System.Threading;
    using System.Threading.Tasks;

    public record CreateUserCommand(string UserName, string Email, string Address, string ProfilePicture) : IRequest;

    internal sealed class CreateUserCommandHandler : AsyncRequestHandler<CreateUserCommand>
    {
        private readonly IUnitOfWork unitOfWork;

        public CreateUserCommandHandler(IUnitOfWork unitOfWork)
        {
            this.unitOfWork = unitOfWork;
        }

        protected override async Task Handle(CreateUserCommand request, CancellationToken cancellationToken)
        {
            User user = new User()
            {
                Id = Guid.NewGuid(),
                UserName = request.UserName,
                Email = request.Email,
                Address = request.Address,
                ProfilePicture = request.ProfilePicture
            };

            this.unitOfWork.Users.Add(user);

            await this.unitOfWork.SaveAsync(cancellationToken);
        }
    }
}

  • In Company.Shorts.Application
    • Create folder Query
    • Create GetUsersQuery.cs -> this is where we are going to use our cache.

namespace Company.Shorts.Application.UserAggregate.Query
{
    using Company.Graphql.Application.Contracts.Db;
    using Company.Shorts.Application.Contracts.Cache;
    using Company.Shorts.Domain;
    using MediatR;
    using System.Collections.Generic;
    using System.Threading;
    using System.Threading.Tasks;

    public record GetUsersQuery : IRequest<List<User>>;

    internal sealed class GetUsersQueryHandler : IRequestHandler<GetUsersQuery, List<User>>
    {
        private const string Key = "users";

        private readonly ICacheService cacheService;
        private readonly IUserRepository userRepository;

        public GetUsersQueryHandler(IUserRepository userRepository, ICacheService cacheService)
        {
            this.userRepository = userRepository;
            this.cacheService = cacheService;
        }

        public async Task<List<User>> Handle(GetUsersQuery request, CancellationToken cancellationToken)
        {
            var items = await this.cacheService.GetOrAdd<List<User>>(Key, this.userRepository.GetUsersAsync);

            return items;
        }
    }
}
  • In Company.Shorts.Presentation.Api
    • In Controllers/V1 folder add UsersController.cs
namespace Company.Shorts.Presentation.Api.Controllers.V1
{
    using AutoMapper;
    using MediatR;
    using Microsoft.AspNetCore.Http;
    using Microsoft.AspNetCore.Mvc;
    using Company.Shorts.Application.ExampleAggregate.Common.Responses;
    using Company.Shorts.Presentation.Api.Controllers.V1.Models.Examples;
    using Company.Shorts.Presentation.Api.Internal.Constants;
    using Swashbuckle.AspNetCore.Annotations;
    using Company.Shorts.Application.UserAggregate.Query;
    using Company.Shorts.Domain;
    using Company.Shorts.Application.UserAggregate.Command;

    [ApiVersion(ApiVersions.V1)]
    public class UsersController : ApiControllerBase
    {
        public UsersController(IMediator mediator, IMapper mapper) : base(mediator, mapper)
        {
        }

        /// <summary>
        /// Gets users.
        /// </summary>
        [HttpGet]
        [SwaggerOperation(OperationId = nameof(Get), Tags = new[] { ApiTags.Users })]
        [ProducesResponseType(typeof(List<User>), StatusCodes.Status200OK)]
        [ProducesResponseType(StatusCodes.Status400BadRequest)]
        [ProducesResponseType(StatusCodes.Status500InternalServerError)]
        public async Task<IActionResult> Get([FromQuery] GetUsersQueryDto request)
        {
            return await ProcessAsync<GetUsersQueryDto, GetUsersQuery, List<User>>(request);
        }

        /// <summary>
        /// Creates user.
        /// </summary>
        [HttpPost]
        [SwaggerOperation(OperationId = nameof(Post), Tags = new[] { ApiTags.Users })]
        [ProducesResponseType(StatusCodes.Status400BadRequest)]
        [ProducesResponseType(StatusCodes.Status500InternalServerError)]
        public async Task<IActionResult> Post([FromBody] CreateUserCommandDto request)
        {
            return await ProcessAsync<CreateUserCommandDto, CreateUserCommand>(request);
        }
    }
}
  • In Company.Shorts.Presentation.Api
    • In Controllers/V1/Models/Users add GetUsersQueryDto.cs
namespace Company.Shorts.Presentation.Api.Controllers.V1.Models.Users
{
    using System;

    public record GetUsersQueryDto() : IApiDto;

    public record GetUserByIdQueryDto(Guid Id) : IApiDto;

    public record CreateUserCommandDto(string UserName, string Email, string Address, string ProfilePicture) : IApiDto;
}

Github

Branch: feature/caching-inmemory-impl

P.S.

After a short discussion with one of my friends (yup, one of), he came to an excellent conclusion. I know, I am more surprised than you are. Long story short, if you need to keep your cache in sync with database, you fucked up.