Let’s get into another sample to illustrate using the singleton lifetime and DI. Seeing DI in action should help understand it and then leverage it to create SOLID software.Context: The application is a site-wide wishlist where users can add items. Items expire every 30 seconds. When a user adds an existing item, the system must increment the count and reset the item’s expiration time. That way, popular items stay on the list longer, making it to the top. When displayed, the system must sort the items by count (highest count first).
An expiration time of 30 seconds is very fast, but I’m sure you don’t want to wait days before an item expires when running the app. It is a test config.
The program is a tiny web API that exposes two endpoints:
- Add an item to the wishlist (POST).
- Read the wishlist (GET).
The wishlist interface looks like this:
public interface IWishList
{
Task<WishListItem> AddOrRefreshAsync(string itemName);
Task<IEnumerable<WishListItem>> AllAsync();
}
public record class WishListItem(string Name, int Count, DateTimeOffset Expiration);
The two operations are there, and by making them async (returning a Task<T>), we could implement another version that relies on a remote system, such as a database, instead of an in-memory store. Then, the WishListItem record class is part of the IWishList contract; it is the model. To keep it simple, the wishlist only stores the names of items.
Note
Trying to foresee the future is not usually a good idea, but designing APIs to be awaitable is generally a safe bet. Other than this, I’d recommend you stick to the simplest code that satisfies the program’s needs (KISS). When you try to solve problems that do not exist yet, you usually end up coding a lot of useless stuff, leading to additional unnecessary maintenance and testing time.
In the composition root, we must serve the IWishList implementation instance in a singleton scope (highlighted) so all requests share the same instance. Let’s start with the first half of the Program.cs file:
var builder = WebApplication.CreateBuilder(args);
builder.Services
.ConfigureOptions<InMemoryWishListOptions>()
.AddTransient<IValidateOptions<InMemoryWishListOptions>, InMemoryWishListOptions>()
.AddSingleton(serviceProvider => serviceProvider.GetRequiredService<IOptions<InMemoryWishListOptions>>().Value)
// The singleton registration
.AddSingleton<IWishList, InMemoryWishList>()
;
If you are wondering where IConfigureOptions, IValidateOptions, and IOptions come from, we cover the ASP.NET Core Options pattern in Chapter 9, Options, Settings, and Configuration.
Let’s now look at the second half of the Program.cs file that contains the minimal API code to handle the HTTP requests:
var app = builder.Build();
app.MapGet(“/”, async (IWishList wishList) =>
await wishList.AllAsync());
app.MapPost(“/”, async (IWishList wishList, CreateItem?
newItem) =>
{
if (newItem?.Name == null)
{
return Results.BadRequest();
}
var item = await wishList.AddOrRefreshAsync(newItem.Name);
return Results.Created(“/”, item);
});
app.Run();
public record class CreateItem(string?
Name);
The GET endpoint delegates the logic to the injected IWishList implementation and returns the result, while the POST endpoint validates the CreateItem DTO before delegating the logic to the wishlist.To help us implement the InMemoryWishList class, we started by writing some tests to back our specifications up. Since static members are hard to configure in tests (remember globals?), I borrowed a concept from the ASP.NET Core memory cache and created an ISystemClock interface that abstracts away the static call to DateTimeOffset.UtcNow or DateTime.UtcNow.This way, we can program the value of UtcNow in our tests to create expired items. Here’s the clock interface and implementation:
namespace Wishlist.Internal;
public interface ISystemClock
{
DateTimeOffset UtcNow { get; }
}
public class SystemClock : ISystemClock
{
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
}
.NET 8 adds a new TimeProvider class to the System namespace, which does not help us much here. However, if we want to leverage that API, we could update the SystemClock to the following:
public class CustomClock : ISystemClock
{
private readonly TimeProvider _timeProvider;
public CustomClock(TimeProvider timeProvider)
{
_timeProvider = timeProvider ??
throw new ArgumentNullException(nameof(timeProvider));
}
public DateTimeOffset UtcNow => _timeProvider.GetUtcNow();
}