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