Using LazyCache to make a cached client in .NET 7

Using LazyCache to make a cached client in .NET 7

Save costs by limiting requests without changing existing implementation ๐Ÿš€

ยท

4 min read

Why should I be using a cached client? ๐Ÿค”

Implementing a client towards external (or internal) services is a central part of most developers' work. There might be several reasons to consider caching responses from such services.

  • Throttling by external service providers ๐ŸŒ

  • Lowering the cost of "pay-per-call" services ๐Ÿ’ฐ

  • Limiting overhead ๐Ÿคฏ

  • Decreasing response times โฑ

Making a weather client ๐ŸŒฆ

In this article, I will be exposing data from a third-party weather client. The code can be found in this repository.

I begin by creating a simple WeatherReport entity.

public class WeatherReport
{
    public string WeatherType { get; set; }
    public string WeatherDescription { get; set; }
}

I proceed by making an interface for a WeatherClient able to GetCurrentWeather.

public interface IWeatherClient
{
    Task<WeatherReport> GetCurrentWeather();
}

For this implementation, I am going to use the OpenWeather API as a third-party service provider. The only thing needed is to register, and retrieve an API Key. ๐Ÿ”‘

After registering, it is time to create a configuration object, to be able to configure the implementation.

public class OpenWeatherClientConfiguration
{
    public string URL { get; set; }
    public string ApiKey { get; set; }
}

Furthermore, I configure the client in appsettings.development.json

  "OpenWeatherClientConfiguration": {
    "URL": "https://api.openweathermap.org/",
    "ApiKey": "YOUR-API-KEY"
  }

And register the configuration in Progam.cs

builder.Services.Configure<OpenWeatherClientConfiguration>(  builder.Configuration.GetSection(nameof(OpenWeatherClientConfiguration)));

Based on the API description provided by OpenWeather, a response object for the CurrentWeather endpoint is created.

public class WeatherResponse
{
    public List<Weather> weather { get; set; }
}
public class Weather
{
    public string main { get; set; }
    public string description { get; set; }
}

I now have everything in place to create the actual weather client. (This client always checks the weather at the lat/long of my city, Copenhagen).

public class OpenWeatherClient : IWeatherClient
{
    private readonly IOptions<OpenWeatherClientConfiguration> _openWeatherConfig;
    private readonly HttpClient _httpClient;

    public OpenWeatherClient(IOptions<OpenWeatherClientConfiguration> openWeatherConfig, HttpClient httpClient)
    {
        _openWeatherConfig = openWeatherConfig;
        _httpClient = httpClient;
    }

    public async Task<WeatherReport> GetCurrentWeather()
    {
        var response = await _httpClient
            .GetAsync(
                $"/data/2.5/weather?lat={55.676098}&lon={12.568337}&appid={_openWeatherConfig.Value.ApiKey}");

        var weatherResponse = JsonConvert
            .DeserializeObject<WeatherResponse>(
                await response.Content.ReadAsStringAsync());

        return new WeatherReport()
        {
            WeatherDescription = weatherResponse?.weather?.FirstOrDefault()?.description ?? "",
            WeatherType = weatherResponse?.weather?.FirstOrDefault()?.main ?? ""
        };
    }
}

The last piece of the puzzle is an endpoint to expose our newly created integration.

app.MapGet("/", (IWeatherClient weatherClient) => weatherClient.GetCurrentWeather());

and to inject the client implementation with DI.

var accuWeatherUrl = new Uri(builder.Configuration
    .GetSection(nameof(OpenWeatherClientConfiguration))
    .Get<OpenWeatherClientConfiguration>()
    .URL);

builder.Services.AddHttpClient<IWeatherClient, OpenWeatherClient>(options =>
{
    options.BaseAddress = accuWeatherUrl;
});

And the endpoint returns something like

{"weatherType":"Clouds","weatherDescription":"broken clouds"}

Introducing client caching ๐Ÿ™Œ

Now that I have my weather client in place, I can check the weather in Copenhagen using my API. Problem is, I pay per request and would therefore like to limit the number of calls made to OpenWeather. ๐Ÿ’ธ

For the caching implementation, I am going to use a NuGet package called LazyCache and we are going to make an abstract CachedClient class to inherit from.

public abstract class CachedClient
{
    protected readonly IAppCache Cache;

    protected CachedClient(IAppCache cache)
    {
        Cache = cache;
    }
}

With the CachedClient in place, I will make another implementation of the IWeatherClient interface.

The new implementation is using an existing IWeatherClient implementation and the IAppCache provided by LazeCache to GetOrAdd responses from the API.
The GetOrAdd method checks the cache for existing responses. If none are found, it will use the existing implementation to get a response and cache it for 15 minutes. ๐Ÿค˜

public class CachedOpenWeatherClient : CachedClient, IWeatherClient
{
    private readonly IWeatherClient _weatherClient;

    public CachedOpenWeatherClient(IAppCache cache, IWeatherClient weatherClient) : base(cache)
    {
        _weatherClient = weatherClient;
    }

    public async Task<WeatherReport> GetCurrentWeather()
    {
        return await Cache.GetOrAddAsync("GetCurrentWeather",
            () => _weatherClient.GetCurrentWeather(),
            DateTimeOffset.UtcNow.AddMinutes(15));
    }
}

At last, I am wiring it all up and making sure the correct client gets injected with a little DI magic. ๐Ÿช„

builder.Services.AddHttpClient<OpenWeatherClient>(options =>
{
    options.BaseAddress = accuWeatherUrl;
});

builder.Services.AddScoped<IWeatherClient>(
    x => new CachedOpenWeatherClient(x.GetService<IAppCache>(), x.GetService<OpenWeatherClient>()));

The endpoint I created will now get the cached client injected instead of the original client, without me changing any original code. The cached client caches the response in 15 minutes meaning my API will make at most 96 requests per day to the OpenWeather endpoint. ๐ŸŽ‰

Conclusion

With this simple approach, it is possible to cache an existing client implementation without changing the original implementation.
Being able to introduce caching at a later stage is very important, as we all know that premature optimizations are the root of all evil! ๐Ÿฆนโ€โ™‚๏ธ

Further considerations

When thinking about caching a client it is important to consider if caching is the correct solution!

  • Is using "old" data from the service provider sufficient?

  • Is the service provider being called enough for the caching to matter?

  • Could I limit the call rate of the client by other means?

Thanks for reading ๐Ÿ‘‹

Did you find this article valuable?

Support Mathias Milter by becoming a sponsor. Any amount is appreciated!

ย