Continúo revisando los patrones más útiles para nuestras aplicaciones en la nube. En este caso, hoy vamos a hablar sobre el patrón Cache-Aside, que nos permite limitar el número de solicitudes que nuestros almacenamientos de datos externos reciben de nuestras aplicaciones.
La explicación de este patrón es bastante simple: cuando accedemos a una información almacenada en cualquier tipo de almacenamiento de datos, intentamos obtenerla de la caché. Si existe, recuperamos los datos de la caché, y si no, los recuperamos del motor y los insertamos en la caché para una solicitud futura.

Primero, debemos considerar dónde y cómo implementarlo:
- Tipo de aplicación: Podría parecer obvio, pero es importante tener en cuenta que la implementación de este patrón variará si lo estamos construyendo para una aplicación de escritorio o para una aplicación web distribuida en la nube.
- Tipo de datos: El punto principal a tener en mente es que no todos los datos de la aplicación son susceptibles de ser almacenados en caché. Solo implementaremos este patrón cuando los datos en caché cambien raramente (por ejemplo, datos maestros almacenados en la base de datos) o se accedan intensamente durante un corto período de tiempo.
- Duración de los datos en caché: Seleccionar cuidadosamente el tiempo que nuestros datos estarán vivos dentro de la caché es un desafío en el diseño de nuestra aplicación. Seleccionar un tiempo demasiado corto aumentará el número de llamadas innecesarias al almacenamiento de datos, mientras que un tiempo demasiado largo provocará que nuestra caché deba ser actualizada muchas veces desde nuestro almacenamiento.
- Consistencia: Nuestros datos en caché podrían quedar desactualizados tan pronto como alguien escriba nueva información en nuestro almacenamiento de datos. Por lo tanto, uno de los objetivos de nuestra solución sería descartar o renovar los datos en caché cuando se realice una actualización.
Let’s code
Antes de presentar mi solución para este patrón, quiero aclarar un par de cosas:
- El escenario que presento en esta publicación está hecho para una aplicación distribuida publicada en la nube, donde una o más instancias de la aplicación podrían estar ejecutándose al mismo tiempo. Para ello, elegí Redis como motor de caché.
- Obviamente, el uso de Redis nos da la capacidad de tener nuestros datos en caché distribuidos en varios servidores, pero conlleva costos adicionales.
- Esta no es una solución aislada para el patrón Cache-Aside, ya que para que funcione, la combiné con otros dos patrones: el patrón de Repositorio y el patrón de Inyección de Dependencias.
- El patrón de repositorio es una implementación útil que nos abstrae del motor de almacenamiento de datos y tal vez lo presente en otra publicación. Mientras tanto, voy a suponer que tú, el lector, tienes un conocimiento básico sobre él.
Proveedor de caché
El proveedor de caché será un contrato de un servicio de alto nivel para realizar implementaciones de caché. En nuestro caso, elegí Redis, pero nos da la oportunidad de implementar otro tipo de motor y sustituirlo solo en nuestras definiciones de DI (por ejemplo, podrías implementar un almacenamiento de caché local en lugar de uno distribuido).
public interface ICacheProvider
{
/// <summary>
/// Generic method to get an item from the cache given a key.
/// The result should be serialized to the type T.
/// </summary>
/// <typeparam name="T">Type of expected result</typeparam>
/// <param name="key">Given key</param>
/// <returns>Returns an object of type T or null</returns>
Task<T?> GetValueAsync<T>(string key) where T : class;
/// <summary>
/// Sets an object on the cache.
/// This method is generic to store any object type.
/// </summary>
/// <typeparam name="T">Type of object to save</typeparam>
/// <param name="key">Given key</param>
/// <param name="value">Object to save</param>
/// <param name="duration">Timespan that represents the duration of the object in the cache</param>
Task<bool> SetValueAsync<T>(string key, T value, TimeSpan duration) where T : class;
/// <summary>
/// Method that gets or initialize a cache item with a given key.
/// </summary>
/// <typeparam name="T">Type of object</typeparam>
/// <param name="key">Given key</param>
/// <param name="functionToObtain">Function to obtain a valid value in case of null</param>
/// <param name="duration">Timespan that represents the duration of the object in the cache</param>
/// <returns>Returns a string object</returns>
Task<T?> GetValueOrInitializeAsync<T>(string key, Func<Task<T>> functionToObtain, TimeSpan duration) where T : class;
/// <summary>
/// Removes items from the cache by a given pattern
/// </summary>
/// <param name="key">Given key</param>
/// <returns>Returns true if success, false if not</returns>
bool RemoveByPattern(string pattern);
/// <summary>
/// Check if an item exists given its key.
/// </summary>
/// <param name="key">Key to be checked</param>
/// <returns>Returns a boolean that indicates if exists or not</returns>
Task<bool> Exists(string key);
}
Nuestra implementación para esta interfaz se verá de la siguiente manera. Usaremos la biblioteca StackExchange.Redis
para realizar las acciones contra el motor.
public class RedisCacheProvider : ICacheProvider
{
ConnectionMultiplexer? _connection;
readonly RedisConnectionConfiguration _configuration;
public RedisCacheProvider(RedisConnectionConfiguration configuration)
{
_configuration = configuration;
}
ConnectionMultiplexer GetConnection()
{
if (_connection == null)
{
_connection = ConnectionMultiplexer.Connect(_configuration.ConnectionString, config =>
{
config.ConnectTimeout = _configuration.ConnectionTimeout;
});
}
return _connection;
}
IDatabase? GetDatabase() => GetConnection()?.GetDatabase();
IServer? GetServer() => GetConnection()?.GetServers().LastOrDefault();
public Task<bool> Exists(string key) => GetDatabase()?.KeyExistsAsync(new RedisKey(key)) ?? Task.FromResult(false);
public async Task<T?> GetValueAsync<T>(string key) where T : class
{
var database = GetDatabase();
if (database != null)
{
var data = await database.StringGetAsync(new RedisKey(key));
if (data.HasValue && !data.IsNullOrEmpty)
return JsonSerializer.Deserialize<T>(data.ToString());
}
return null;
}
public async Task<T?> GetValueOrInitializeAsync<T>(string key, Func<Task<T>> functionToObtain, TimeSpan duration) where T : class
{
var value = await GetValueAsync<T>(key);
if (value == null)
{
value = await functionToObtain.Invoke();
if (value != null)
await SetValueAsync(key, value, duration);
}
return value;
}
public bool RemoveByPattern(string pattern)
{
var keys = GetServer()?.Keys(pattern:new RedisValue(pattern));
bool result = true;
if (keys != null)
{
foreach (var key in keys)
{
if (!GetDatabase()?.KeyDelete(key) ?? false)
result = false;
}
}
return result;
}
public Task<bool> SetValueAsync<T>(string key, T value, TimeSpan duration) where T : class =>
GetDatabase()?.StringSetAsync(new RedisKey(key), new RedisValue(JsonSerializer.Serialize(value)), duration) ??
Task.FromResult(false);
}
Patrón repositorio
Como mencioné anteriormente, el patrón de repositorio nos permite abstraer nuestro código del motor subyacente utilizado por nuestras aplicaciones para acceder a los datos. Desde un punto de vista puro, podríamos decir que realizaremos una consulta que accede a una base de datos relacional, una base de datos no relacional o un archivo con el mismo código en las capas de alto nivel de nuestra aplicación.
Para ello, definimos las siguientes interfaces para leer y escribir en nuestros almacenamientos de datos:
public interface IReaderRepository<T> where T : class
{
/// <summary>
/// Get all items of type T
/// </summary>
/// <returns>Collection of items of type T</returns>
Task<List<T>> GetAll();
/// <summary>
/// Get items of type T filtered by a Lambda expression
/// </summary>
/// <param name="expression">Filter to apply</param>
/// <returns>Collection of items of type T that matches with the filter specification</returns>
Task<List<T>> GetAll(Expression<Func<T, bool>> expression);
/// <summary>
/// Get first found item filtered by an expression
/// </summary>
/// <param name="expression">Lambda filter expression</param>
/// <returns>First object found</returns>
Task<T?> GetOne(Expression<Func<T, bool>> expression);
/// <summary>
/// Asks if there is any item filtered by a Lambda expression
/// </summary>
/// <param name="expression">Lambda filter expression</param>
/// <returns>Return true if it finds any, otherwise false</returns>
Task<bool> Any(Expression<Func<T, bool>> expression);
}
public interface IWriterRepository<T> where T : class
{
/// <summary>
/// Add item to the storage
/// </summary>
/// <param name="item">Item to be added</param>
void Add(T item);
/// <summary>
/// Remove an item placed at the remote storage
/// </summary>
/// <param name="item">Item to remove</param>
void Remove(T item);
/// <summary>
/// Updates an item placed at the remote storage
/// </summary>
/// <param name="item">Item to remove</param>
void Update(T item);
/// <summary>
/// Gets a transaction where the actions are going to be executed
/// </summary>
/// <returns>Returns an object fo type ITransaction</returns>
ITransaction BeginTransaction(Action? transactionCommited = null);
}
Implementación para Entity Framework
La siguiente implementación de nuestras interfaces anteriores está enfocada en trabajar con Entity Framework, por lo que necesitaremos usar una instancia de DbContext para interoperar con el motor.
public class EFReaderRepository<T>(DbContext context) : IReaderRepository<T> where T : class
{
public virtual async Task<bool> Any(Expression<Func<T, bool>> expression) => (await GetAll(expression))?.Any() ?? false;
public virtual Task<List<T>> GetAll() => context.Set<T>().ToListAsync();
public virtual Task<List<T>> GetAll(Expression<Func<T, bool>> expression) => context.Set<T>().Where(expression).ToListAsync();
public virtual Task<T?> GetOne(Expression<Func<T, bool>> expression) => context.Set<T>().FirstOrDefaultAsync(expression);
}
public class EFWriterRepository<T>(DbContext context) : IWriterRepository<T> where T : class
{
protected EFTransaction? _currentTransaction;
public virtual void Add(T item)
{
context.Add(item);
context.SaveChanges();
}
public virtual ITransaction BeginTransaction(Action? transactionCommited = null)
{
_currentTransaction = new EFTransaction(
context.Database.BeginTransaction(),
() => transactionCommited?.Invoke()
);
return _currentTransaction;
}
public virtual void Remove(T item)
{
context.Remove(item);
context.SaveChanges();
}
public virtual void Update(T item)
{
context.Update(item);
context.SaveChanges();
}
}
Sobrescritura para implementar el patrón Cache-Aside
Vamos a sobrescribir las implementaciones anteriores de lectura/escritura para implementar el patrón Cache-Aside.
Esta implementación va a cumplir los requisitos con lo siguiente:
- Cuando el usuario lee de la base de datos, el método intentará leer los datos de la caché. Si no hay datos presentes, consultará la base de datos y creará una entrada en la caché con el nombre de la entidad solicitada, el nombre del método y la expresión utilizada para filtrarla convertida en cadena (si la hay).
- Cuando la aplicación escribe en la base de datos, el método borrará todas las entradas almacenadas en la caché para la entidad que estamos guardando dentro de la base de datos.
public class EFCacheReaderRepository<T> : EFReaderRepository<T>, IReaderRepository<T> where T : class
{
ICacheProvider _cacheProvider;
TimeSpan _dataTTL;
public EFCacheReaderRepository(DbContext context, ICacheProvider cacheProvider, TimeSpan dataTTL) : base(context) {
_cacheProvider = cacheProvider;
_dataTTL = dataTTL;
}
public override async Task<bool> Any(Expression<Func<T, bool>> expression)
{
return (await GetAll(expression))?.Any() ?? false;
}
private string GetCacheName([CallerMemberName] string methodName = "", params string[] keys)
{
return $"{typeof(T).Name}:{methodName}:{String.Join(':', keys)}";
}
public override Task<List<T>> GetAll()
{
var cacheName = GetCacheName();
return _cacheProvider.GetValueOrInitializeAsync<List<T>>(cacheName, () => base.GetAll(), _dataTTL)!;
}
public override Task<List<T>> GetAll(Expression<Func<T, bool>> expression)
{
var cacheName = GetCacheName(keys:expression.ToString());
return _cacheProvider.GetValueOrInitializeAsync<List<T>>(
cacheName, () => base.GetAll(expression), _dataTTL
)!;
}
public override Task<T?> GetOne(Expression<Func<T, bool>> expression)
{
var cacheName = GetCacheName(keys: expression.ToString());
return _cacheProvider.GetValueOrInitializeAsync<T>(
cacheName,
() => base.GetOne(expression),
_dataTTL
);
}
}
public class EFCacheWriterRepository<T> : EFWriterRepository<T>, IWriterRepository<T> where T : class
{
ICacheProvider _cacheProvider;
public EFCacheWriterRepository(DbContext context, ICacheProvider cacheProvider) : base(context)
{
_cacheProvider = cacheProvider;
}
public override void Add(T item)
{
base.Add(item);
if (_currentTransaction == null || _currentTransaction.Completed)
ClearCache();
}
private void ClearCache() => _cacheProvider.RemoveByPattern($"{typeof(T).Name}:*");
public override ITransaction BeginTransaction(Action? transactionCommited = null) =>
base.BeginTransaction(() =>
{
ClearCache();
transactionCommited?.Invoke();
});
public override void Remove(T item)
{
base.Remove(item);
if (_currentTransaction == null || _currentTransaction.Completed)
ClearCache();
}
public override void Update(T item)
{
base.Update(item);
if (_currentTransaction == null || _currentTransaction.Completed)
ClearCache();
}
}
Uso en nuestras aplicaciones
Supongamos que comenzamos con una aplicación ya codificada que tiene una implementación de DbContext y algunas entidades adjuntas a ella.
En nuestro ejemplo, voy a utilizar las siguientes clases de modelo y el contexto.
public record Person
{
[Key]
public int Id { get; set; }
[Required]
public string Name { get; set; }
[Required]
public string Surname { get; set; }
}
public class Context : DbContext
{
public DbSet<Person> People { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseMySQL("Server=localhost;Port=3306;Database=sample;Uid=root;Pwd=secretpassword123;");
base.OnConfiguring(optionsBuilder);
}
}
Es hora de ajustar nuestras definiciones de DI. En primer lugar, agregaremos el RedisCacheProvider
para resolver todas las referencias a la interfaz ICacheProvider
dentro de nuestra aplicación. Lo definiremos como Singleton, porque el objeto de caché puede ser único para nuestra aplicación.
serviceCollection.AddSingleton<ICacheProvider>(sp =>
{
return new RedisCacheProvider(new RedisConnectionConfiguration("localhost:6379", 60));
});
Para definir lectores y escritores para nuestras entidades, lo haremos de la siguiente manera:
serviceCollection.AddScoped<IReaderRepository<Person>>(sp =>
{
return new EFCacheReaderRepository<Person>(
sp.GetRequiredService<Context>(),
sp.GetRequiredService<ICacheProvider>(),
TimeSpan.FromMinutes(60)
);
});
serviceCollection.AddScoped<IWriterRepository<Person>, EFCacheWriterRepository<Person>>();
Una vez que hemos definido estas entradas dentro del motor de inyección de dependencias (DI), podemos inyectarlas en nuestros servicios de aplicación y comenzar a usar nuestro nuevo patrón.
public interface IPeopleReaderService
{
Task<IEnumerable<Person>> GetAllPeopleByName(string name);
}
public class PeopleReaderService(IReaderRepository<Person> repository) : IPeopleReaderService
{
public async Task<IEnumerable<Person>> GetAllPeopleByName(string name)
{
return await repository.GetAll(x => x.Name == name);
}
}
public interface IPeopleWriterService
{
bool AddNewPerson(string name, string surname);
}
public class PeopleWriterService(IWriterRepository<Person> repository) : IPeopleWriterService
{
public bool AddNewPerson(string name, string surname)
{
repository.Add(new Person { Name = name, Surname = surname });
return true;
}
}
Este es un ejemplo bastante simple, pero ilustra cómo trabajar con él dentro de nuestras capas de alto nivel.
Conclusiones
Al desplegar nuestras aplicaciones en la nube, debemos ser muy cuidadosos al diseñar nuestra infraestructura. Este tipo de patrones como el Cache-Aside pueden ser útiles para reducir la carga en nuestros motores de almacenamiento y reducir nuestros costos. Sin embargo, debemos tener en cuenta que los motores de caché no son gratuitos, por lo que debemos encontrar el punto ideal entre costo y rendimiento.
El ejemplo completo y el proyecto pueden verse .