That code leverages the new API, but we’ll stick to our simple implementation instead.
Let’s look at the outline of the unit tests next because the whole code would take pages and be of low value:
namespace Wishlist;
public class InMemoryWishListTest
{
// Constructor and private fields omitted
public class AddOrRefreshAsync : InMemoryWishListTest
{
[Fact]
public async Task Should_create_new_item();
[Fact]
public async Task Should_increment_Count_of_an_existing_item();
[Fact]
public async Task Should_set_the_new_Expiration_date_of_an_existing_item();
[Fact]
public async Task Should_set_the_Count_of_expired_items_to_1();
[Fact]
public async Task Should_remove_expired_items();
}
public class AllAsync : InMemoryWishListTest
{
[Fact]
public async Task Should_return_items_ordered_by_Count_Descending();
[Fact]
public async Task Should_not_return_expired_items();
}
// Private helper methods omitted
}
The full source code is on GitHub: https://adpg.link/ywy8.
In the test class, we can mock the ISystemClock interface and program it to obtain the desired results based on each test case. We can also program some helper methods to make it easier to read the tests. Those helpers use tuples to return multiple values (see Appendix A for more information on language features). Here’s the mock field:
private readonly Mock<ISystemClock> _systemClockMock = new();
Here’s an example of such a helper method setting the clock to the present time and the ExpectedExpiryTime to a later time (UtcNow + ExpirationInSeconds later):
private (DateTimeOffset UtcNow, DateTimeOffset ExpectedExpiryTime) SetUtcNow()
{
var utcNow = DateTimeOffset.UtcNow;
_systemClockMock.Setup(x => x.UtcNow).Returns(utcNow);
var expectedExpiryTime = utcNow.AddSeconds(_options.ExpirationInSeconds);
return (utcNow, expectedExpiryTime);
}
Here is an example of another helper method setting the clock and the ExpectedExpiryTime to the past (two-time ExpirationInSeconds for the clock and once ExpirationInSeconds for the ExpectedExpiryTime):
private (DateTimeOffset UtcNow, DateTimeOffset ExpectedExpiryTime) SetUtcNowToExpired()
{
var delay = -(_options.ExpirationInSeconds * 2);
var utcNow = DateTimeOffset.UtcNow.AddSeconds(delay);
_systemClockMock.Setup(x => x.UtcNow).Returns(utcNow);
var expectedExpiryTime = utcNow.AddSeconds(_options.ExpirationInSeconds);
return (utcNow, expectedExpiryTime);
}
We now have five tests covering the AddOrRefreshAsync method and two covering the AllAsync method. Now that we have those failing tests, here is the implementation of the InMemoryWishList class:
namespace Wishlist;
public class InMemoryWishList : IWishList
{
private readonly InMemoryWishListOptions _options;
private readonly ConcurrentDictionary<string, InternalItem> _items = new();
public InMemoryWishList(InMemoryWishListOptions options)
{
_options = options ??
throw new ArgumentNullException(nameof(options));
}
public Task<WishListItem> AddOrRefreshAsync(string itemName)
{
var expirationTime = _options.SystemClock.UtcNow.AddSeconds(_options.ExpirationInSeconds);
_items
.Where(x => x.Value.Expiration < _options.SystemClock.UtcNow)
.Select(x => x.Key)
.ToList()
.ForEach(key => _items.Remove(key, out _))
;
var item = _items.AddOrUpdate(
itemName,
new InternalItem(Count: 1, Expiration: expirationTime),
(string key, InternalItem item) => item with {
Count = item.Count + 1,
Expiration = expirationTime
}
);
var wishlistItem = new WishListItem(
Name: itemName,
Count: item.Count,
Expiration: item.Expiration
);
return Task.FromResult(wishlistItem);
}
public Task<IEnumerable<WishListItem>> AllAsync()
{
var items = _items
.Where(x => x.Value.Expiration >= _options.SystemClock.UtcNow)
.Select(x => new WishListItem(
Name: x.Key,
Count: x.Value.Count,
Expiration: x.Value.Expiration
))
.OrderByDescending(x => x.Count)
.AsEnumerable()
;
return Task.FromResult(items);
}
private record class InternalItem(int Count, DateTimeOffset Expiration);
}
The InMemoryWishList class uses ConcurrentDictionary<string, InternalItem> internally to store the items and make the wishlist thread-safe. It also uses a with expression to manipulate and copy the InternalItem record class.The AllAsync method filters out expired items, while the AddOrRefreshAsync method removes expired items. This might not be the most advanced logic ever, but that does the trick.
You might have noticed that the code is not the most elegant of all, and I left it this way on purpose. While using the test suite, I invite you to refactor the methods of the InMemoryWishList class to be more readable.
I took a few minutes to refactor it myself and saved it as InMemoryWishListRefactored. You can also uncomment the first line of InMemoryWishListTest.cs to test that class instead of the main one. My refactoring is a way to make the code cleaner, to give you ideas. It is not the only way, nor the best way, to write that class (the “best way” being subjective).
Lastly, optimizing for readability and performance are often very different things.
Back to DI, the line that makes the wishlist shared between users is in the composition root we explored earlier. As a reference, here it is:
builder.Services.AddSingleton<IWishList, InMemoryWishList>();
Yes, only that line makes all the difference between creating multiple instances and a single shared instance. Setting the lifetime to Singleton allows you to open multiple browsers and share the wishlist.
To POST to the API, I recommend using the Wishlist.http file in the project or the Postman collection (https://adpg.link/postman6) that comes with the book. The collection already contains multiple requests you can execute in batches or individually. You can also use the Swagger UI that I added to the project.
That’s it! All that code to demo what a single line of code in the composition root can do, and we have a working program, as tiny as it may be.