Repository with cache

This article describes the approach of implementing the cached repository with the following scenario:

  • Repository provides simple operating for storing and reading phone numbers in the MS SQL database;

  • Support the atomicity of multiple updates and insert operations. Possibility to wrap batch upsert operations into the transaction via TransactionScope;

  • On read and write store phone numbers in memory cache. On read return most recent phone number from cache, hence, reduce the amount of database read operations;

  • In memory cache should implement the same repository interface as MSSQL repository. Thereby, MSSQL repository can be easily replaced with in-memory cached repository;

Repository

Suppose an application has following phone book repository interface:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public interface IPhoneBookRepository
{
  // Creates database table.
  Task CreateRepositoryAsync();
  
  // Updates or inserts phone number for the specified name.
  Task StorePhoneNumberAsync(string name, string phoneNumber);

  // Gets phone number for the specified name.
  Task<string?> GetPhoneNumberAsync(string name);
}

With the following MSSQL repository implementation: src/Repository/PhoneBookRepository.cs

The following test demonstrates the usage of PhoneBookRepository;

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
[Fact]
public async Task Should_get_stored_phone_number()
{
    // Arrange
    var repository = new PhoneBookRepository();
    var name = "Luke";
    var phoneNumber = "111-222-333";
    
    // Act
    await repository.CreateRepositoryAsync();
    await repository.StorePhoneNumberAsync(name, phoneNumber);
    var resultPhoneNumber = await repository.GetPhoneNumberAsync(name);
    
    // Assert
    Assert.Equal(phoneNumber, resultPhoneNumber);
}

Cached repository

GetPhoneNumberAsync implementation

GetPhoneNumberAsync implementation is quite straight forward. The read operation method should check if the phone number is not in cache then read it from the database.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class CachedPhoneBookRepository : IPhoneBookRepository, ICache
{
    public Dictionary<string, string> Cache { get; } = new Dictionary<string, string>();
    private readonly IPhoneBookRepository _phoneBookRepository;

    // ...

    public async Task<string?> GetPhoneNumberAsync(string name)
    {
        if (Cache.TryGetValue(name, out var phoneNumber))
        {
            return phoneNumber;
        }

        phoneNumber = await _phoneBookRepository.GetPhoneNumberAsync(name);

        if (phoneNumber != null)
        {
            Cache[name] = phoneNumber;
        }
        
        return phoneNumber;
    }
}

StorePhoneNumberAsync implementation

StorePhoneNumberAsync is tricky, it should respect transaction boundaries. In case if StorePhoneNumberAsync is called within TransactionScope the cache should be updated only when the transaction committed. In case if the transaction is failed cache shouldn’t be updated.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
using (var transaction = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled))
{
    await cachedRepository.StorePhoneNumberAsync(name1, phoneNumber1);
    await cachedRepository.StorePhoneNumberAsync(name2, phoneNumber2);

    // In TransactionScope: cachedRepository shoudn't store phoneNumber1 & phoneNumber2 
    Assert.False(cachedRepository.Cache.TryGetValue(name1, out _));
    Assert.False(cachedRepository.Cache.TryGetValue(name2, out _));
    transaction.Complete();
}

// After TransactionScope: cachedRepository should provide phoneNumber1 & phoneNumber2
Assert.True(cachedRepository.Cache.TryGetValue(name1, out _));
Assert.True(cachedRepository.Cache.TryGetValue(name2, out _));

To achive desired behaviour CachedPhoneBookRepository should participate in transaction life cycle via implementation of IEnlistmentNotification.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
public class EnlistResource : IEnlistmentNotification
{
    private readonly Action _onCommit;

    public EnlistResource(Action onCommit)
    {
        _onCommit = onCommit;
    }

    public void Commit(Enlistment enlistment)
    {
        _onCommit();
        enlistment.Done();
    }
    
    // ...
}

Now when the transaction is succeeded StorePhoneNumberAsync can write into the cache.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class CachedPhoneBookRepository : IPhoneBookRepository, ICache
{
    public Dictionary<string, string> Cache { get; } = new Dictionary<string, string>();
    private readonly IPhoneBookRepository _phoneBookRepository;

    // ...

    public async Task StorePhoneNumberAsync(string name, string phoneNumber)
    {
        await _phoneBookRepository.StorePhoneNumberAsync(name, phoneNumber);

        if (Transaction.Current != null)
        {
            Transaction.Current.EnlistVolatile(
                new EnlistResource(() => StoreInternal(name, phoneNumber)), 
                EnlistmentOptions.None);
        }
        else
        {
            StoreInternal(name, phoneNumber);
        }
    }

    private void StoreInternal(string name, string phoneNumber)
    {
        Cache[name] = phoneNumber;
    }
}

Results

As result we receive the CachedPhoneBookRepository wich implements the same IPhoneBookRepository as MSSQL PhoneBookRepository one. Hence repository can be replaced with cached version without any additional effort.

The complete solution can be found in the following repository: blog-repository-cache