MetalamaConceptual documentationUsing Metalama PatternsCachingCaching special types with value adapters
Open sandboxFocusImprove this doc

Caching mutable or stream-like types with value adapters

In theory, only immutable types should be cached. However, in practice, there are some problematic types that we might still want to cache:

  • Stream-like types such as the <xref:System.Collections.Generic.IEnumerator`1> interface or the <xref:System.IO.Stream> class cannot be directly cached because the position of the enumerator or stream can be altered by the caller.
  • Interfaces like <xref:System.Collections.Generic.IEnumerable`1> cannot be cached because the actual value might be a query rather than the result of this query, which would be pointless to cache.
  • Some types, like <xref:System.Collections.Generic.List`1> or arrays, are mutable, and the caller may modify the instance stored in the cache.

So, how can we safely cache these problematic types?

Metalama addresses this issue through the concept of a value adapter. A value adapter allows you to store a different type than the one returned by the cached method. The method return value is referred to as the exposed value because this is the value exposed by your API. The exposed value must be type-compatible with the method return type. The value that is actually stored in the cache is called the stored value. For instance, for a method returning a <xref:System.IO.Stream>, the stored value is an array of bytes and the exposed value is a MemoryStream.

Standard value adapters

By default, the following value adapters are used automatically:

Return type Stored type Exposed type Comments
<xref:System.Collections.Generic.IEnumerable`1> <xref:System.Collections.Generic.List`1> <xref:System.Collections.Generic.List`1>
<xref:System.Collections.Generic.IEnumerator`1> <xref:System.Collections.Generic.List`1> <xref:System.Collections.Generic.List`1.Enumerator> The Reset() method is not supported by the exposed value.
<xref:System.IO.Stream> <xref:System.Byte> [] MemoryStream

Implementing a custom value adapter

To implement a custom value adapter:

  1. Create a class implementing the IValueAdapter<T> interface or the non-generic IValueAdapter interface.

  2. Go back to the code that initialized the Metalama Caching by calling serviceCollection.AddMetalamaCaching or CachingService.Create. Call the AddValueAdapter method, then pass an instance of your IValueAdapter<T>.

[!metalama-file ~/code/Metalama.Documentation.SampleCode.Caching/ValueAdapter/ValueAdapter.Program.cs marker="AddMetalamaCaching"]
Note

Null values are automatically handled outside of the value adapters.

Example: Caching a StringBuilder

Let's say you are maintaining a legacy service that implements the unusual practice of returning a StringBuilder instead of a string. You are responsible for improving the performance of this API, so you want to cache the result of this method. However, you cannot cache mutable objects, as this would mean that if a caller modifies the StringBuilder, the next caller would receive the modified copy. Therefore, you decide to cache the string instead of the StringBuilder, and return a new StringBuilder every time the value is fetched from the cache.

1using Metalama.Documentation.Helpers.ConsoleApp;
2using System;
3
4namespace Doc.ValueAdapter
5{
6    public sealed class ConsoleMain : IConsoleMain
7    {
8        private readonly ProductCatalogue _catalogue;
9
10        public ConsoleMain( ProductCatalogue catalogue )
11        {
12            this._catalogue = catalogue;
13        }
14
15        public void Execute()
16        {
17            for ( var i = 0; i < 2; i++ )
18            {
19                // Get the StringBuilder through the cache.
20                var products = this._catalogue.GetProductsAsStringBuilder();
21
22                // Modify the StringBuilder. Without the ValueAdapter, we would receive the
23                // mutated instance and we would have our prefix twice.
24                products.Insert( 0, "The list of products is: " );
25
26                // Print.
27                Console.WriteLine( products );
28            }
29        }
30    }
31}
1using Metalama.Documentation.Helpers.ConsoleApp;
2using Metalama.Patterns.Caching.Building;
3using Microsoft.Extensions.DependencyInjection;
4
5namespace Doc.ValueAdapter
6{
7    internal static class Program
8    {
9        public static void Main()
10        {
11            var builder = ConsoleApp.CreateBuilder();
12
13            // Add the caching service and register out ValueAdapter.
14            builder.Services.AddMetalamaCaching(
15                caching =>
16                    caching.AddValueAdapter( new StringBuilderAdapter() ) );
17
18            // Add other components as usual, then run the application.
19            builder.Services.AddConsoleMain<ConsoleMain>();
20            builder.Services.AddSingleton<ProductCatalogue>();
21
22            using var app = builder.Build();
23            app.Run();
24        }
25    }
26}
1using Metalama.Patterns.Caching.ValueAdapters;
2using System.Text;
3
4namespace Doc.ValueAdapter
5{
6    internal class StringBuilderAdapter : ValueAdapter<StringBuilder>
7    {
8        public override StringBuilder? GetExposedValue( object? storedValue ) => storedValue == null ? null : new StringBuilder( (string) storedValue );
9
10        public override object? GetStoredValue( StringBuilder? value ) => value?.ToString();
11    }
12}
Source Code
1using Metalama.Patterns.Caching.Aspects;
2using System;

3using System.Collections.Generic;

4using System.Linq;
5using System.Text;
6

7namespace Doc.ValueAdapter
8{
9    public sealed class ProductCatalogue
10    {
11        private readonly Dictionary<string, decimal> _dbSimulator = new() { ["corn"] = 100 };
12
13        public int DbOperationCount { get; private set; }
14
15        // Very weird API but suppose it's legacy and we need to keep it, but cache it.
16        [Cache]
17        public StringBuilder GetProductsAsStringBuilder()
18        {
19            Console.WriteLine( "Getting the product list from database." );
20
21            this.DbOperationCount++;






22




23            var stringBuilder = new StringBuilder();
24
25            foreach ( var productId in this._dbSimulator.Keys )
26            {
27                if ( stringBuilder.Length > 0 )
28                {
29                    stringBuilder.Append( "," );
30                }
31
32                stringBuilder.Append( productId );
33            }
34
35            return stringBuilder;
36        }
37    }















38}
Transformed Code
1using Metalama.Patterns.Caching;
2using Metalama.Patterns.Caching.Aspects;
3using Metalama.Patterns.Caching.Aspects.Helpers;
4using System;
5using System.Collections.Generic;
6using System.Linq;
7using System.Reflection;
8using System.Text;
9
10namespace Doc.ValueAdapter
11{
12    public sealed class ProductCatalogue
13    {
14        private readonly Dictionary<string, decimal> _dbSimulator = new() { ["corn"] = 100 };
15
16        public int DbOperationCount { get; private set; }
17
18        // Very weird API but suppose it's legacy and we need to keep it, but cache it.
19        [Cache]
20        public StringBuilder GetProductsAsStringBuilder()
21        {
22            static object? Invoke(object? instance, object?[] args)
23            {
24                return ((ProductCatalogue)instance).GetProductsAsStringBuilder_Source();
25            }
26
27            return _cachingService!.GetFromCacheOrExecute<StringBuilder>(_cacheRegistration_GetProductsAsStringBuilder!, this, new object[] { }, Invoke);
28        }
29
30        private StringBuilder GetProductsAsStringBuilder_Source()
31        {
32            Console.WriteLine("Getting the product list from database.");
33
34            this.DbOperationCount++;
35
36            var stringBuilder = new StringBuilder();
37
38            foreach (var productId in this._dbSimulator.Keys)
39            {
40                if (stringBuilder.Length > 0)
41                {
42                    stringBuilder.Append(",");
43                }
44
45                stringBuilder.Append(productId);
46            }
47
48            return stringBuilder;
49        }
50
51        private static readonly CachedMethodMetadata _cacheRegistration_GetProductsAsStringBuilder;
52        private ICachingService _cachingService;
53
54        static ProductCatalogue
55        ()
56        {
57            ProductCatalogue._cacheRegistration_GetProductsAsStringBuilder = CachedMethodMetadata.Register(RunTimeHelpers.ThrowIfMissing(typeof(ProductCatalogue).GetMethod("GetProductsAsStringBuilder", BindingFlags.Public | BindingFlags.Instance, null, Type.EmptyTypes, null)!, "ProductCatalogue.GetProductsAsStringBuilder()"), new CachedMethodConfiguration() { AbsoluteExpiration = null, AutoReload = null, IgnoreThisParameter = null, Priority = null, ProfileName = (string?)null, SlidingExpiration = null }, true);
58        }
59
60        public ProductCatalogue
61        (ICachingService? cachingService = default)
62        {
63            this._cachingService = cachingService ?? throw new System.ArgumentNullException(nameof(cachingService));
64        }
65    }
66}
Getting the product list from database.
The list of products is: corn
The list of products is: corn