Back to "Mantener el estado entre peticiones en ASP.NET Core: Una guía práctica y sin sentido (MVC, Páginas Razor, APIs Mínimas)"

This is a viewer only at the moment see the article on how this works.

To update the preview hit Ctrl-Alt-R (or ⌘-Alt-R on Mac) or Enter to refresh. The Save icon lets you save the markdown file to disk

This is a preview from the server running through my markdig pipeline

AI-Article ASP.NET ASP.NET Core State Web Development

Mantener el estado entre peticiones en ASP.NET Core: Una guía práctica y sin sentido (MVC, Páginas Razor, APIs Mínimas)

Sunday, 09 November 2025

ACTUALIZACIÓN (2025-11-10): Añadió ejemplos más prácticos de mi base de código real mostrando patrones reales, compensaciones, gotchas y la evolución de enfoques simples a sofisticados de gestión del estado. Incluye patrones detallados de IMemoryCache, uso de ViewBag (bueno y malo), estrategias de ResponseCache/OutputCache y lecciones aprendidas de la producción.

Introducción

HTTP es famosamente apátrida. Tu aplicación... no lo es. Los usuarios inician sesión, agregan artículos a las cestas, saltan entre páginas, vuelven mañana y esperan que recuerdes. En ASP.NET Core (MVC, Razor Pages, Minimal APIs), hay muchas maneras de preservar y transferir el estado entre solicitudes. Algunas son solo por petición. Algunas duran para una sesión. Algunas viven en el cliente. Algunas se distribuyen y sobreviven a los reinicios del servidor. Cada opción tiene compensaciones entre seguridad, rendimiento, escala y ergonomía del desarrollador.

Este post cataloga las opciones, muestra ejemplos concretos, copia-pasteables en las tres pilas, y le da orientación de decisión para que pueda elegir la herramienta adecuada para el trabajo.

NOTA: Esto es parte de mis experimentos con IA (redacción asistida) + mi propia edición. La misma voz, el mismo pragmatismo; sólo los dedos más rápidos.

El paisaje a la vista

flowchart LR
  subgraph Client
    Q[Query String]
    R[Route Values]
    H[Headers]
    F[Form/Hidden Fields]
    CK[Cookies]
    LS[Local/Session Storage]
  end
  subgraph Server
    I[HttpContext.Items<br/>\nper request only]
    TD[TempData<br/>\none redirect]
    SS[Session]
    MC[IMemoryCache]
    DC[IDistributedCache]
    AU[Auth Cookie / Claims]
    JT[JWT / Bearer]
    DB[Database / Durable Store]
    BUS[Outbox / Queue]
  end
  Q --> Model[Model Binding]
  R --> Model
  F --> Model
  H --> Model
  CK --> App[Your Code]
  Model --> App
  App -->|Set| CK
  App -->|Set| TD
  App -->|Set| SS
  App -->|Set| MC
  App -->|Set| DC
  App -->|Issue| AU
  App -->|Issue| JT
  App -->|Persist| DB
  • Cliente-llevado: consulta, ruta, encabezados, formularios, cookies, JWT. Escala horizontalmente pero es visible para el cliente (debe ser validado/firmado/encriptado cuando corresponda).
  • Servidor: TempData, Session, cachés, DB. Requiere estrategia de afinidad o distribución.
  • Por petición: HttpContext.Items - útiles para pasar datos internamente durante una sola solicitud.

Reglas de Oro (antes de sumergirnos en APIs)

  1. La seguridad en primer lugar: la fuga del Estado es una amenaza real. El estado del lado del servidor (Sesión, cachés en memoria) puede filtrarse entre los usuarios a través de errores de codificación, condiciones de carrera o gestión incorrecta del ciclo de vida. Los patrones apátridas no solo son más escalables, sino que son más seguros.
  2. Prefiere los patrones de "servidor sin estado" cuando necesite escalar horizontalmente (empuje el estado a tokens del cliente, tiendas duraderas o cachés distribuidas).
  3. Nunca confíe en el estado controlado por el cliente. Validar, firmar y/o cifrar.
  4. Mantenga los grandes datos fuera de las cookies y cabeceras; hinchan cada petición.
  5. Utilice TempData sólo para obtener una sola imagen (PRG) después de la redirección, como mensajes flash.
  6. Use Session solo cuando deba mantener el estado de conversación del lado del servidor y haya planeado la distribución, y sea paranoico con la seguridad.
  7. Las reclamaciones son para la identidad y la autorización de grano grueso, no el estado general de la aplicación.
  8. Cache no es una fuente de verdad. Retrocede con un almacén duradero si los datos importan.

Estado por solicitud: HttpContext.Items

  • Ámbito de aplicación: solo solicitud actual (desgarro al final de la tubería)
  • Uso para: pasar los valores calculados entre middleware y endpoints/controllers
  • Escala: sin impacto
  • Seguridad: sólo para el servidor
sequenceDiagram
  participant M as Middleware
  participant E as Endpoint/Controller
  M->>M: Compute TenantId
  M->>E: HttpContext.Items["TenantId"] = 42
  E->>E: Read Items["TenantId"]

Ejemplo de Middleware (todas las pilas):

app.Use(async (context, next) =>
{
    var tenantId = context.Request.Headers["X-TenantId"].FirstOrDefault() ?? "public";
    context.Items["TenantId"] = tenantId;
    await next(context);
});
  • Endpoint API mínimo:
app.MapGet("/whoami", (HttpContext ctx) => new { Tenant = ctx.Items["TenantId"] });
  • Controlador MVC:
public IActionResult WhoAmI() => Json(new { Tenant = HttpContext.Items["TenantId"] });
  • Manejador de página Razor:
public IActionResult OnGet() => new JsonResult(new { Tenant = HttpContext.Items["TenantId"] });

Estado transportado por clientes transitorios: valores de ruta y cadena de consulta

  • Alcance: solicitud actual; el cliente la lleva explícitamente.
  • Uso para: contexto de navegación, filtrado, paginación, identidad de recursos
  • Seguridad: debe validar/autorizar; no incrustar secretos

Ejemplos

  • APIs mínimas:
app.MapGet("/orders/{id:int}", (int id, int? page) => Results.Ok(new { id, page }));
// GET /orders/5?page=2
  • MVC:
[HttpGet("/orders/{id:int}")]
public IActionResult Details(int id, int? page)
  => View(new { id, page });
  • Páginas Razor (Ordenes/Details.cshtml.cs):
public IActionResult OnGet(int id, int? page)
  => Page();

Generar enlaces que preserven el estado:

// Razor Pages
<a asp-page="/Orders/Details" asp-route-id="@Model.Id" asp-route-page="@Model.Page">Next</a>

// MVC
@Html.ActionLink("Next", "Details", "Orders", new { id = Model.Id, page = Model.Page }, null)

Encabezados: Id. de Correlación, Claves de Idempotencia, Banderas de Característica

  • Ámbito de aplicación: solicitud actual, recogida opcionalmente en las respuestas
  • Uso para: rastreo, seguridad de reintento, banderas A/B
  • Seguridad: tratar como entrada no confiable, validar/lista blanca
app.Use(async (ctx, next) =>
{
    var correlationId = ctx.Request.Headers["X-Correlation-Id"].FirstOrDefault()
                      ?? Guid.NewGuid().ToString("n");
    ctx.Response.Headers["X-Correlation-Id"] = correlationId;
    await next(ctx);
});

Idempotencia (patrón):

flowchart TD
  C[Client POST /pay\nIdempotency-Key:k] --> S{Seen k?}
  S -- No --> E[Execute charge]
  E --> P[Persist result by k]
  P --> R[Return 200 + result]
  S -- Yes --> L[Load result by k]
  L --> R

Formas y campos ocultos (PRG)

  • Alcance: sólo la siguiente petición (el cliente devuelve los valores)
  • Uso para: pasos de asistente, tokens anti-falsificación, manteniendo pequeños bits de estado a través de PRG
  • Seguridad: validar siempre; combinar con antifalsificación

Patrón PRG en páginas MVC/Razor:

sequenceDiagram
  participant U as User
  participant P as POST Action
  participant R as Redirect
  participant G as GET Action
  U->>P: POST form
  P-->>R: 302 Redirect
  U->>G: GET redirected
  G-->>U: Final page (no resubmits)
  • MVC:
[HttpPost]
[ValidateAntiForgeryToken]
public IActionResult Save(SettingsModel model)
{
    // validate & persist
    return RedirectToAction(nameof(Summary), new { tab = model.SelectedTab });
}
  • Páginas Razor:
public IActionResult OnPost(SettingsModel model)
{
    return RedirectToPage("/Settings/Summary", new { tab = model.SelectedTab });
}

Cookies: Pequeñas, firmadas, a veces cifradas

  • Ámbito de aplicación: todas las solicitudes del navegador hasta la expiración
  • Uso para: preferencias, banderas no sensibles, consentimiento; cookies de autenticación (sección separada)
  • Cesiones: límites de tamaño (~4KB por cookie), impacto en el rendimiento, debe cumplir con las leyes de consentimiento

Ejemplo de API mínima:

app.MapPost("/prefs/theme/{value}", (HttpContext ctx, string value) =>
{
    ctx.Response.Cookies.Append("theme", value, new CookieOptions
    {
        HttpOnly = false,
        Secure = true,
        SameSite = SameSiteMode.Lax,
        Expires = DateTimeOffset.UtcNow.AddYears(1)
    });
    return Results.Ok();
});

app.MapGet("/prefs/theme", (HttpContext ctx)
  => Results.Text(ctx.Request.Cookies["theme"] ?? "system"));

El uso de páginas MVC/Razor es idéntico a través de HttpContext.

Para la integridad/confidencialidad, utilice el sistema ASP.NET Core Data Protection para proteger las cargas útiles que usted mismo puso en las cookies.


TempData: Autobús de mensajes unidireccional

  • Alcance: sobrevive a una redireccionamiento
  • Backing store: Cookie (predeterminado) o Sesión
  • Uso para: mensajes flash, resúmenes de validación después de PRG

Configuración (Programa.cs):

builder.Services.AddControllersWithViews().AddSessionStateTempDataProvider(); // optional
builder.Services.AddSession();
var app = builder.Build();
app.UseSession();

En el controlador MVC:

TempData["StatusMessage"] = "Saved!";
return RedirectToAction("Index");

En el manejador de página Razor:

TempData["StatusMessage"] = "Saved!";
return RedirectToPage("/Index");

En vista/página:

@if (TempData["StatusMessage"] is string msg) {
  <div class="alert alert-success">@msg</div>
}
flowchart LR
  A[POST /save] -->|TempData set| B[302 Redirect]
  B --> C[GET /index]
  C -->|TempData read consumed| D[Render message]

Sesión: Estado de conversación del servidor-side

  • Alcance: sesión del navegador (cookie key + server store)
  • Uso para: asistentes de varios pasos, datos de carritos pequeños, contadores de estrangulamiento
  • Reembolsos: requiere sesiones pegajosas o tienda de respaldo distribuida; puede limitar la escala

Configurar:

builder.Services.AddDistributedMemoryCache(); // or AddStackExchangeRedisCache
builder.Services.AddSession(options =>
{
    options.IdleTimeout = TimeSpan.FromMinutes(20);
    options.Cookie.HttpOnly = true;
    options.Cookie.IsEssential = true;
});
var app = builder.Build();
app.UseSession();

Usar sesión (cualquier pila):

app.MapPost("/cart/add/{id:int}", (HttpContext ctx, int id) =>
{
    var key = "cart";
    var bytes = ctx.Session.Get(key);
    var list = bytes is null ? new List<int>() : System.Text.Json.JsonSerializer.Deserialize<List<int>>(bytes)!;
    list.Add(id);
    ctx.Session.Set(key, System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(list));
    return Results.Ok(list);
});

Ayudantes de sesión:

public static class SessionExtensions
{
    public static void Set<T>(this ISession session, string key, T value)
      => session.SetString(key, System.Text.Json.JsonSerializer.Serialize(value));

    public static T? Get<T>(this ISession session, string key)
      => session.TryGetValue(key, out var data)
         ? System.Text.Json.JsonSerializer.Deserialize<T>(data)
         : default;
}

Una historia de advertencia: Cuando el estado de sesión se convierte en el cuello de botella

Una vez trabajé en un proyecto masivo de TI del gobierno del Reino Unido donde el mal uso del estado de sesión (entre muchos otros pecados arquitectónicos) se convirtió en un cuello de botella asesino de rendimiento. todo en sesión: preferencias del usuario, datos del formulario de varios pasos, resultados de búsqueda, cálculos temporales, incluso búsquedas en caché que deberían haber estado en una caché o base de datos adecuada.

El problema: El estado de sesión se almacenó en el proceso (estado de sesión de ASP.NET en web.config, esto era días previos a Core). Cada solicitud tenía que deserializar objetos masivos de sesión. A medida que aumentaba la carga, el estado de sesión se volaba a decenas de megabytes por usuario. Con miles de usuarios concurrentes, los servidores se quedaron sin memoria.

La solución desesperada: Volamos a las instalaciones de HP en Stuttgart para hacer pruebas de carga en su Superdome, en ese momento, La máquina Windows más potente de Europa. Era una bestia: docenas de procesadores de Itanium, cientos de gigabytes de RAM. La idea era demostrar que con suficiente hardware, el sistema podría satisfacer los requisitos.

El resultado: Incluso en el Superdome, no pudimos alcanzar los objetivos de usuario concurrentes requeridos. La arquitectura del estado de la sesión estaba fundamentalmente rota. El escalado vertical no podía salvar el mal diseño. La serieización/deserialización de la sesión, combinada con la presión de memoria de objetos masivos de sesión, significaba que el sistema simplemente no podía escalar, no a ningún costo razonable.

La pesadilla de la seguridad: Peor que los problemas de rendimiento, descubrimos un error de codificación que causó el estado de la sesión a fugas entre usuarios. Los datos de la sesión del usuario A aparecían ocasionalmente en la sesión del usuario B. Esto no sólo era embarazoso, sino que era catastrófico. Personal del Servicio Nacional de Salud Hemos creado accidentalmente un mecanismo de protección de datos que podría exponer información médica sensible en diferentes sesiones de profesionales de la salud.

Lo que debería haber pasado:

  1. Apátridas por defecto: La mayoría de los datos de ese período de sesiones nunca deberían haber existido
  2. Base de datos para un estado duradero: El progreso del formulario en varios pasos debería haber estado en la base de datos con un ID de flujo de trabajo
  3. Caché para las búsquedas: Las búsquedas compartidas pertenecían a IMemoryCache o a una caché distribuida
  4. El lado del cliente para las preferencias: Las preferencias de usuario podrían haber estado en cookies o almacenamiento local
  5. Sesión distribuida si es necesario: Si la sesión fuera realmente necesaria, la sesión respaldada por Redis habría compartido la carga

La lección: El estado de sesión no escala verticalmente y apenas escala horizontalmente (incluso con sesiones pegajosas o tiendas distribuidas, sigues seriando/deserializando en cada petición).El experimento Superdome demostró que lanzar hardware a problemas arquitectónicos es caro y a menudo inútil.

Pero lo más importante: Los fallos de estado de sesión se convierten en vulnerabilidades de seguridad. Problemas de seguridad del hilo de discusión, condiciones de carrera, manejo incorrecto de la identificación de sesión—estos no solo causan problemas de rendimiento, sino que pueden filtrar datos sensibles entre los usuarios.En un contexto de salud (o banca, o cualquier industria regulada), esto es una pesadilla de cumplimiento y responsabilidad penal potencial.

Consejos modernos:

  • Si te encuentras necesitando más de un par de KB de datos de sesión, probablemente tengas un problema de diseño.
  • Si estás almacenando datos sensibles en sesión, estás creando una superficie de ataque de seguridad
  • Las arquitecturas apátridas no solo escalan mejor, sino que son intrínsecamente más seguras porque no hay estado del lado del servidor que filtrar
  • Repensa tu estrategia de gestión estatal antes de tener que volar a Stuttgart (o explicar una violación de datos al Comisionado de Información)

Caché: IMemoryCache y IDistributedCache

  • Alcance: proceso del servidor (ImemoryCache) o distribuido (IDistributedCache)
  • Uso para: datos derivados/computados, búsquedas, estado de corta duración
  • Transacciones: invalidación de caché, serialización para distribución

IMemoryCache:

builder.Services.AddMemoryCache();

app.MapGet("/rates", (IMemoryCache cache) =>
{
    var key = "fx:usd:eur";
    if (!cache.TryGetValue(key, out decimal rate))
    {
        rate = 0.92m; // pretend fetch
        cache.Set(key, rate, TimeSpan.FromMinutes(5));
    }
    return Results.Ok(rate);
});

IDistributedCache (por ejemplo, Redis):

builder.Services.AddStackExchangeRedisCache(o => o.Configuration = "localhost:6379");

app.MapGet("/feature/{name}", async (IDistributedCache cache, string name) =>
{
    var val = await cache.GetStringAsync($"feat:{name}");
    return Results.Text(val ?? "off");
});

Cache-a-state anti-patrón de advertencia: si debe ser duradero o autorizado, almacenarlo en una base de datos y, opcionalmente, guardarlo en caché.

Elegir entre IMemoryCache y IDistributedCache

  • IMemoryCache:
    • Blanqueando objetos rápidos, durante el proceso, permanecen como objetos (sin serialización).
    • Desahucio por presión de memoria, límite de tamaño, caducidad absoluta/deslizante y prioridad.
    • No se comparte entre los nodos; se elimina en la aplicación reciclar / desplegar.
    • Ideal para juegos calientes por nodo, búsquedas computadas, TTLs cortos.
  • IDistributedCache (Redis/SQL/etc.):
    • Compartido en una granja; sobrevive a los reinicios de la aplicación; requiere serialización (cadenas/bytes).
    • Latencia ligeramente mayor; el rendimiento depende de la red y el motor.
    • Soporta la expiración absoluta/deslizante (dependiente del proveedor; el proveedor de Redis actualiza TTL en el acceso para deslizarse).
    • Ideal para la consistencia de nodos cruzados, lectura de ventiladores grandes, banderas de características y sesión.

Estrategias comunes de caché

  • Cache-aside (más común):

    1. Prueba caché; 2) si fallas, carga desde el origen; 3) escribe en caché; 4) devuelve.
    • Pros: simple; la fuente de la verdad sigue siendo la base de datos.
    • Contras: la primera solicitud después de la expiración es lenta; posibles estampidas.
  • Read-through (a través de una biblioteca/proveedor): caché maneja la carga de errores.

  • Write-through: escribe ir a caché y backup store sincrónicamente.

  • Escribe detrás: escribe en caché, descarga para almacenar asíncronamente (riesgo: pérdida/inconsistencia).

  • Refrescar-ahead: refresque las teclas calientes antes de que expiren para evitar fallas frías.

Caducidad, desalojo y dimensionamiento

  • Expiración absoluta: siempre expira después de una duración fija (bueno para la frescura de datos externos).
  • Expiración deslizante: extiende el TTL en el acceso (bueno para sesiones/datos específicos del usuario).
  • Desalojamiento basado en el tamaño (IMemoryCache): establece la entrada.Size y configura SizeLimit a memoria encuadernada.
  • Prioridad (ImemoryCache): CacheItemPriority.High/Normal/Low/NeverRemove afecta el desalojo bajo presión.
  • Jitter: añadir pequeños offsets aleatorios a los TTL para evitar la expiración sincronizada (estampidos).

Prevención de las estampidas de caché (rebaño de arrastre)

  • Utilice GetOrCreate/GetOrCreateAsync (ImemoryCache) para garantizar la población de un solo hilo por nodo.
  • Distribuido: utilice una clave de bloqueo de corta duración (SET NX EX) o soporte de biblioteca; agregue jitter TTL; considere la actualización de fondo.
  • Servir revalidado rancio-mientras: mantener una clave secundaria con valor rancio y extensión corta mientras se calcula el nuevo valor.

Diseño y diseño de nombres clave

  • Prefiere las teclas minúsculas, delimitadas por el colon: app:entity:123 o inquilino:us:users:42.
  • Incluir segmento de versión para invalidar clases enteras de claves sin eliminar: v2:products:123.
  • Teniente-consciente: llaves de prefijo con inquilino u organización id para evitar colisiones y facilitar purgas.
  • Mantenga las teclas pequeñas pero descriptivas; evite la entrada en bruto controlada por el usuario sin normalización.

Caducidad de los juegos de teclas (tags/grupos)

Cuando necesita invalidar muchas entradas relacionadas:

  • Prefijos de versiones (invalidación suave): golpear una versión global en una tecla pequeña y componer claves con ella.

    // version key: "v:products"; keys like $"{version}:product:{id}"
    var version = await cache.GetStringAsync("v:products") ?? "1";
    var key = $"{version}:product:{id}";
    

    Para invalidar todos los productos: incremento v:productos (los clientes naturalmente perderán las teclas prefijadas antiguas).

  • Conjunto de etiquetas por grupo (Redis): mantener un conjunto de claves por etiqueta; en la invalidación, buscar miembros y eliminar.

    // using StackExchange.Redis directly for sets + efficient deletes
    var mux = await ConnectionMultiplexer.ConnectAsync("localhost:6379");
    var db = mux.GetDatabase();
    var tag = "tag:category:42";
    var key = $"prod:{prodId}";
    await db.StringSetAsync(key, serialized, expiry: TimeSpan.FromMinutes(30));
    await db.SetAddAsync(tag, key); // remember membership
    
    // later, invalidate the whole tag
    var members = await db.SetMembersAsync(tag);
    if (members.Length > 0)
    {
        var keys = Array.ConvertAll(members, m => (RedisKey)m);
        await db.KeyDeleteAsync(keys);
    }
    await db.KeyDeleteAsync(tag);
    
  • Invalidación Pub/Sub: publica un mensaje "invalidate:key"; cada nodo elimina la clave de su IMemoryCache local.

  • Escanear con patrones: SCAN/KEYS debe evitarse en las rutas de prod hot; está bien para la herramienta de administración en pequeños espacios clave.

Ayudantes prácticos

  • IMemoryCache get-or-set con opciones:
    T GetOrAdd<T>(IMemoryCache cache, string key, Func<ICacheEntry, T> factory)
      => cache.GetOrCreate(key, e =>
      {
          e.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
          e.SlidingExpiration = TimeSpan.FromMinutes(2);
          e.Priority = CacheItemPriority.Normal;
          e.Size = 1;
          return factory(e);
      });
    
  • IDistribuidoCaché con JSON y vencimiento:
    static async Task<T?> GetOrSetJsonAsync<T>(IDistributedCache cache, string key, Func<Task<T>> factory, TimeSpan ttl)
    {
        var json = await cache.GetStringAsync(key);
        if (json is not null)
            return System.Text.Json.JsonSerializer.Deserialize<T>(json);
    
        var value = await factory();
        var opts = new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = ttl };
        await cache.SetStringAsync(key,
            System.Text.Json.JsonSerializer.Serialize(value),
            opts);
        return value;
    }
    

Vigilancia y visibilidad

  • Tasas de éxito/error de la pista y tiempo medio de carga; métricas de exposición (cuentas Prometheus) por grupo clave.
  • Agregue registro alrededor de la población de caché y callbacks de desalojo para IMemoryCache.
  • Para Redis, observe los hits/fallos de keyspace, latencia y fragmentación de memoria; establezca las políticas de maxmemory según corresponda.

Patrones IMemoryCache del mundo real de la producción

Así es como realmente uso IMemoryCache en mi plataforma de blog, evolucionado a través de prueba y error. Te mostraré tres patrones reales de simple a sofisticado.

Patrón 1: Sencilla reserva de caché para datos que rara vez cambian (Categorías)

Esta fue mi primera implementación de caché. Las categorías de blog no cambian a menudo, así que guárdelas durante 30 minutos:

// From BaseController.cs
private const string CacheKey = "Categories";

private async Task<List<string>> GetCategories()
{
    baseControllerService.MemoryCache.TryGetValue(CacheKey, out var value);

    if (value is List<string> categories) return categories;

    logger.LogInformation("Fetching categories from BlogService");
    categories = (await BlogViewService.GetCategories(true)).OrderBy(x => x).ToList();
    baseControllerService.MemoryCache.Set(CacheKey, categories, TimeSpan.FromMinutes(30));
    return categories;
}

Por qué esto funciona:

  • Las categorías se leen en cada página (mostradas en la navegación)
  • Rara vez cambian (sólo cuando añado nuevos posts de blog con nuevas categorías)
  • TTL de 30 minutos está bien; si aparecen nuevas categorías, los usuarios las ven en 30 minutos
  • Expiración absoluta sólo (sin deslizamiento) porque no nos importa la frecuencia con la que se accede

Tengo que golpear: Inicialmente usé una clave de cadena "Categorías". Funciona bien hasta que tengas varios controladores y uno accidentalmente reutilice la misma clave. Ahora uso constantes o teclas fuertemente tecleadas (vea la sección anterior sobre evitar colisiones).

Patrón 2: Estado por usuario con límites de tamaño y vencimiento deslizante (tareas de traducción)

Esta cachea el estado de la traducción por usuario. Es más complejo porque necesita límites y debe mantenerse vivo mientras el usuario esté activo:

// From TranslateCacheService.cs - tracks translation tasks per user
public void AddTask(string userId, TranslateTask task)
{
    CachedTasks CachedTasks() => new()
    {
        Tasks = new List<TranslateTask> { task },
        AbsoluteExpiration = DateTime.Now.AddHours(6)
    };

    if (memoryCache.TryGetValue(userId, out CachedTasks? tasks))
    {
        tasks ??= CachedTasks();
        var currentTasks = tasks.Tasks;

        // Keep only the 5 most recent tasks
        currentTasks = currentTasks.OrderByDescending(x => x.StartTime).ToList();
        if (currentTasks.Count >= 5)
        {
            var lastTask = currentTasks.Last();
            currentTasks.Remove(lastTask);
        }

        currentTasks.Add(task);
        currentTasks = currentTasks.OrderByDescending(x => x.StartTime).ToList();
        tasks.Tasks = currentTasks;

        memoryCache.Set(userId, tasks, new MemoryCacheEntryOptions
        {
            AbsoluteExpiration = tasks.AbsoluteExpiration,
            SlidingExpiration = TimeSpan.FromHours(1) // Extends on access
        });
    }
    else
    {
        var absoluteExpiration = DateTime.Now.AddHours(6);
        var cachedTasks = CachedTasks();
        memoryCache.Set(userId, cachedTasks, new MemoryCacheEntryOptions
        {
            AbsoluteExpiration = absoluteExpiration,
            SlidingExpiration = TimeSpan.FromHours(1)
        });
    }
}

Por qué esto es diferente:

  • Expiración deslizante: Si el usuario sigue comprobando el estado de la traducción, mantenga la caché viva hasta 6 horas como máximo
  • Límites de tamaño: Sólo mantener 5 tareas más recientes por usuario para evitar la hinchazón de la memoria
  • Gestión manual: Limito explícitamente el tamaño porque MemoryCache no impone límites de conteo de elementos de nivel de entrada (sólo el tamaño total de caché)

Error que cometí: Al principio no limitaba el número de tareas. Un usuario de potencia desencadenó más de 50 traducciones y tuve una fuga de memoria. Ahora mantengo un máximo de 5 por usuario.

Negociaciones: Esto no se escalará a millones de usuarios. Si esto se convierte en un problema, me movería a IDistributedCache (Redis) o almacenaría en la base de datos con un índice en userId + startTime.

Patrón 3: Caché con observabilidad (caché métrico)

Esta cachea métricas analíticas y rastrea la eficacia de la cache utilizando el rastreo de Serilog:

// From UmamiDataSortService.cs - caches Umami analytics metrics
public async Task<List<MetricsResponseModels>?> GetMetrics(DateTime startAt, DateTime endAt, string prefix = "")
{
    using var activity = Log.Logger.StartActivity("GetMetricsWithPrefix");
    try
    {
        var cacheKey = $"Metrics_{startAt:yyyyMMdd}_{endAt:yyyyMMdd}_{prefix}";

        if (cache.TryGetValue(cacheKey, out List<MetricsResponseModels>? metrics))
        {
            activity?.AddProperty("CacheHit", true);
            return metrics;
        }

        activity?.AddProperty("CacheHit", false);
        var metricsRequest = new MetricsRequest
        {
            StartAtDate = startAt,
            EndAtDate = endAt,
            Type = MetricType.url,
            Limit = 500
        };

        var metricRequest = await dataService.GetMetrics(metricsRequest);
        if (metricRequest.Status != HttpStatusCode.OK) return null;

        var filteredMetrics = metricRequest.Data
            .Where(x => x.x.StartsWith(prefix))
            .ToList();

        cache.Set(cacheKey, filteredMetrics, TimeSpan.FromHours(1));

        activity?.AddProperty("MetricsCount", filteredMetrics?.Count() ?? 0);
        activity?.Complete();
        return filteredMetrics;
    }
    catch (Exception e)
    {
        activity?.Complete(LogEventLevel.Error, e);
        return null;
    }
}

Lo que hace que esta producción esté lista:

  • Observabilidad: SerilogTracing activity tracks cache hit/ miss rate and records métricas count
  • Tecla compuesta: Incluye rango de fechas y prefijo para evitar colisiones clave
  • Formato de fecha: Usos yyyyMMdd formato en clave tan diferentes tiempos en el mismo día compartir la caché
  • Manipulación nula: Devuelve null si falla el servicio externo; no falla la caché
  • Caché filtrado: Cachea el resultado filtrado, no la respuesta en bruto

Evolución: Inicialmente caché durante 10 minutos. Pero las métricas de Umami son pesadas para buscar y no cambian mucho, así que 1 hora está bien. Descubrí esto mirando los datos de SerilogTracing y viendo pérdidas excesivas de caché.

Seguimiento en acción: En Seq (mi agregador de registro), puedo consultar:

ActivityName = "GetMetricsWithPrefix" and CacheHit = false

Esto me dice que mi caché falla. Si es alta, ajusto la estrategia TTL o clave.

Comparación de los tres enfoques

Patrón Uso Caso Caducidad Control de Tamaño Observabilidad |---------|----------|------------|--------------|---------------| | Categorías Global, raramente cambiante 30 min absoluto No se necesita (pequeño) Tala básica | Tareas de traducción Por usuario, limitado 6h absoluto + 1h deslizante Manual (5 ítems max) Ninguno (debería añadir!) | Métricas Llamadas externas caras 1h absolutas Natural (internalizado por el tiempo) Trazado completo

Lecciones clave:

  1. Iniciar simple (patrón 1), añadir complejidad sólo cuando sea necesario
  2. Siempre piense en el uso de memoria caché - añadir límites de tamaño para cachés por usuario
  3. Para operaciones costosas, añada observabilidad desde el primer día
  4. Ajustar TTL basado en la frecuencia real de cambio de datos, no conjeturas

Cuando no uso IMemoryCache:

  • Para el estado de autenticación del usuario (en su lugar, reclama en la cookie de autenticación)
  • Para carrito de compras (utilizaría DB + caché distribuido en la producción)
  • Para contenido de blog post (ya en DB, cargado una vez por solicitud)
  • Estado de servidor cruzado (necesitaría IDistributedCache/Redis)

Cookies de autenticación y reclamaciones

  • Ámbito de aplicación: todas las solicitudes hasta su expiración o firma
  • Uso para: identidad, roles/permisos gruesos, una pequeña cantidad de datos de perfil
  • Negociaciones: tamaño de las galletas; no exageres. Las reclamaciones deben ser estables.

Auth de cookies de configuración:

builder.Services.AddAuthentication("Cookies")
    .AddCookie("Cookies", o =>
    {
        o.LoginPath = "/login";
        o.Cookie.SecurePolicy = CookieSecurePolicy.Always;
        o.SlidingExpiration = true;
    });
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();

Inscripción con reclamaciones (CMV/mínimo):

app.MapPost("/login", async (HttpContext ctx) =>
{
    var claims = new[]
    {
        new Claim(ClaimTypes.NameIdentifier, "123"),
        new Claim(ClaimTypes.Name, "Alice"),
        new Claim(ClaimTypes.Role, "Admin")
    };
    var identity = new ClaimsIdentity(claims, "Cookies");
    await ctx.SignInAsync("Cookies", new ClaimsPrincipal(identity));
    return Results.Redirect("/");
});

Lea las reclamaciones (cualquier pila):

[Authorize]
app.MapGet("/me", (ClaimsPrincipal user)
  => Results.Ok(new { user.Identity!.Name, Roles = user.Claims.Where(c => c.Type == ClaimTypes.Role).Select(c => c.Value) }));

JWT / Tokens del portador

  • Alcance: cliente lleva token; servidor apátrida
  • Uso para: SPAs/móviles/APIs, cross-domain, microservicios
  • Cesiones: tamaño del token; rotación/refrigeración; almacenar reclamaciones mínimas, utilizar la introspección si es necesario
builder.Services.AddAuthentication("Bearer")
   .AddJwtBearer("Bearer", o =>
   {
       o.Authority = "https://demo.identityserver.io"; // example
       o.Audience = "api";
       o.RequireHttpsMetadata = true;
   });

Uso:

[Authorize(AuthenticationSchemes = "Bearer")]
app.MapGet("/secure", () => "ok");

Visión general de la sirena:

sequenceDiagram
  participant C as Client
  participant STS as Token Service
  participant API as API
  C->>STS: Authenticate (username/password)
  STS-->>C: JWT (signed)
  C->>API: GET /secure (Authorization: Bearer <jwt>)
  API->>API: Validate signature, expiry, audience
  API-->>C: 200

Estado duradero: Base de datos y amigos

  • Alcance: para siempre (hasta que lo elimines)
  • Uso para: cualquier cosa que no se debe perder: carros, pedidos, perfiles, flujos de trabajo de larga duración
  • Patrones: CRUD estándar con EF Core; CQRS; abastecimiento de eventos; patrón de salida para la fiabilidad

Boceto básico de la EF:

builder.Services.AddDbContext<AppDb>(o => o.UseSqlServer(cs));

app.MapPost("/cart/items", async (AppDb db, AddItem cmd) =>
{
    var cart = await db.Carts.FindAsync(cmd.CartId) ?? new Cart(cmd.CartId);
    cart.Add(cmd.ProductId, cmd.Qty);
    await db.SaveChangesAsync();
    return Results.Created($"/cart/{cart.Id}", cart);
});

Respuesta Caché, ETAgs y peticiones condicionales

  • No estrictamente “establecer la carga”, pero reduce el trabajo repetido al permitir que el cliente/proxies reutilice las respuestas previas. A menudo emparejado con el estado de consulta/ruta.
builder.Services.AddResponseCaching();
var app = builder.Build();
app.UseResponseCaching();

app.MapGet("/products", (HttpContext ctx) =>
{
    ctx.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue { Public = true, MaxAge = TimeSpan.FromSeconds(30) };
    return Results.Ok(new[] { new { Id = 1, Name = "Widget" } });
}).CacheOutput();

Ejemplo de Etag:

app.MapGet("/resource", (HttpContext ctx) =>
{
    var version = "W/\"abc123\""; // compute based on data hash
    ctx.Response.Headers.ETag = version;
    if (ctx.Request.Headers.IfNoneMatch == version)
        return Results.StatusCode(StatusCodes.Status304NotModified);
    return Results.Text("payload");
});

RespuestaCache vs OutputCache: Uso del mundo real en la producción

Uso tanto ResponseCache como OutputCache juntos en mi blog para diferentes propósitos. He aquí por qué es posible que desees ambos y cómo difieren.

La confusión: ¿Dos atributos de caché?

ASP.NET Core tiene dos Sistemas de almacenamiento en caché similares:

  1. ResponseCache (encaché HTTP): Establece encabezados HTTP (Cache-Control, Vary) diciendo a los navegadores y CDNs cómo caché e
  2. Caché de salida (lado servidor): Cachea la salida renderizada en el servidor para saltarse la ejecución de la acción por completo

Se complementan entre sí. ResponseCache maneja el caché cliente/CDN; OutputCache maneja el caché del lado del servidor.

Mi blog post acción (ambos cachés aplicados)

// From BlogController.cs
[Route("{slug}")]
[HttpGet]
[ResponseCache(Duration = 300, VaryByHeader = "hx-request",
    VaryByQueryKeys = new[] { nameof(slug), nameof(language) },
    Location = ResponseCacheLocation.Any)]
[OutputCache(Duration = 3600, VaryByHeaderNames = new[] { "hx-request" },
    VaryByQueryKeys = new[] { nameof(slug), nameof(language) })]
public async Task<IActionResult> Show(string slug, string language = "en")
{
    var post = await blogViewService.GetPost(slug, language);
    if (post == null) return NotFound();

    // ... populate user info, comments, etc ...

    if (Request.IsHtmx()) return PartialView("_PostPartial", post);
    return View("Post", post);
}

¿Qué sucede cuando alguien lo solicita? /blog/my-post:

  1. Comprobación de la caché de salida primero: ¿Tengo una respuesta caché para my-post + en ¿Lenguaje?

    • Hit: Devuelve HTML en caché, el método de acción nunca se ejecuta (rápido! ~1ms)
    • Señorita.: Ejecutar acción, renderizar vista, caché resultado durante 3600 segundos (1 hora)
  2. ResponseCache sets headersDespués de que OutputCache genera la respuesta, ResponseCache añade:

    Cache-Control: public, max-age=300
    Vary: hx-request
    
  3. Caché del navegador: El navegador oculta la respuesta durante 300 segundos (5 minutos). Las peticiones posteriores del mismo usuario ni siquiera golpean el servidor.

  4. Caché CDN (si se utiliza Cloudflare/Fastly): CDN cachés durante 5 minutos. Los usuarios de todo el mundo golpean el CDN, no mi servidor.

¿Por qué duraciones diferentes? (300 vs. 3600)

[ResponseCache(Duration = 300)]    // 5 minutes client/CDN cache
[OutputCache(Duration = 3600)]     // 1 hour server cache

Razonamiento:

  • La caché del servidor es más larga (1 hora): Controlo mi servidor; puedo purgar la caché si actualizo una publicación
  • La caché del cliente es más corta (5 minutos): No puedo purgar navegadores de usuario o CDNs fácilmente; 5 minutos es una ventana de rancio razonable
  • Negociaciones: Si edito una publicación, aparece nuevo contenido:
    • Parte del servidor: Inmediatamente (Puedo invalidar la caché)
    • CDN/navegadores: Dentro de 5 minutos (o purgar manualmente CDN)

Evolución: Inicialmente tenía ambos a los 5 minutos, pero eso significaba que mi servidor volvía a renderizarse cada 5 minutos aunque el contenido rara vez cambia.

  • Servidor felizmente sirve HTML en caché durante 1 hora
  • Los clientes obtienen suficiente contenido cada 5 minutos

VaryByHeader para HTMX

VaryByHeader = "hx-request"  // ResponseCache
VaryByHeaderNames = new[] { "hx-request" }  // OutputCache

Por qué esto importa: Las solicitudes HTMX incluyen hx-request: true Devuelvo diferentes respuestas:

  • Solicitud completa: Página HTML completa con diseño
  • Solicitud HTMX: Vista parcial sin diseño

Sin VaryBy, la caché devolvería el formato incorrecto. VaryBy, caché dos versiones de cada página.

Ejemplo

User requests /blog/my-post → Cache key: "blog/my-post:en:hx=false" → Full HTML cached
HTMX requests /blog/my-post → Cache key: "blog/my-post:en:hx=true" → Partial HTML cached

VaryByQueryKeys para lenguaje y paginación

[ResponseCache(VaryByQueryKeys = new[] { nameof(slug), nameof(language) })]
[OutputCache(VaryByQueryKeys = new[] { nameof(slug), nameof(language) })]

Problema sin esto: /blog/my-post?language=fr serviría a la versión en caché en inglés.

Con VaryByQueryKeys: Separar entradas de caché:

  • /blog/my-post?language=en → Tecla caché incluye "en"
  • /blog/my-post?language=fr → Tecla caché incluye "fr"

Uso real de mi lista de blogs:

[Route("blog")]
[ResponseCache(Duration = 300, VaryByHeader = "hx-request",
    VaryByQueryKeys = new[] { "page", "pageSize", "startDate", "endDate", "language", "orderBy", "orderDir" })]
[OutputCache(Duration = 3600, VaryByHeaderNames = new[] { "hx-request" },
    VaryByQueryKeys = new[] { "page", "pageSize", "startDate", "endDate", "language", "orderBy", "orderDir" })]
public async Task<IActionResult> Index(int page = 1, int pageSize = 20, /* ... */)

Alerta de explosión de caché: Cada combinación única de parámetros = entrada de caché separada:

  • page=1&pageSize=20&language=en → Una entrada
  • page=2&pageSize=20&language=en → Otra entrada
  • page=1&pageSize=10&language=en → Otra entrada

Mitigación:

  • Tamaño razonable de caché máximo (ResultadoCaché auto-evictos menos recientemente utilizado)
  • Parámetros comunes en caché (página 1, por defecto pageSize)
  • Las combinaciones poco frecuentes pueden perder caché (aceptable)

Se requiere configuración

ResponseCache funciona fuera de la caja, pero para Caché de salida necesita configuración:

// Program.cs
builder.Services.AddOutputCache(options =>
{
    options.MaximumBodySize = 64 * 1024 * 1024; // 64 MB max response size
    options.SizeLimit = 100 * 1024 * 1024; // 100 MB total cache size
});

var app = builder.Build();
app.UseOutputCache(); // Must be in middleware pipeline

Cuando OutputCache no ayuda

SalidaCaché se salta para:

  • Solicitudes autenticadas (los diferentes usuarios ven datos diferentes)
  • POST/PUT/DELETE (solo GET/HEAD caché)
  • Respuestas con Set-Cookie encabezado
  • Respuestas que establecen explícitamente Cache-Control: no-store

Ejemplo donde no lo uso:

// Comment submission - authenticated, POST, and per-user
[HttpPost]
[Authorize]
public async Task<IActionResult> AddComment(CommentModel model)
{
    // No caching attributes - this is user-specific and changes state
}

Medición de la eficacia

Uso métricas Prometheus (expuestas a través de mi aplicación) para realizar el seguimiento:

// Pseudo-code for metrics
cache_hits_total{cache="output"} 45230
cache_misses_total{cache="output"} 892

Tasa de éxito de mi caché: ~98% para los posts de blog (la mayoría del tráfico golpea los mismos posts populares repetidamente).

Impacto:

  • Sin caché: tiempo medio de respuesta ~50ms (pregunta deDB + renderizado Markdown)
  • Con OutputCache: ~1-2ms para respuestas en caché
  • Velocidad de 25x

Cuando deshabilite temporalmente el caché

A veces estoy depurando y necesito respuestas nuevas cada vez:

// During development, comment out caching
// [ResponseCache(Duration = 300, ...)]
// [OutputCache(Duration = 3600, ...)]
public async Task<IActionResult> Show(string slug, string language = "en")

O utilice ajustes específicos del entorno:

#if DEBUG
    // No caching in development
#else
    [ResponseCache(Duration = 300, ...)]
    [OutputCache(Duration = 3600, ...)]
#endif

Mejor enfoque: Usar configuración:

[ResponseCache(Duration = responseCacheDuration, ...)]

donde responseCacheDuration es 0 en desarrollo, 300 en producción.

Pros y contras de esta estrategia de doble caché

Pros:

  • Eficiencia del servidor: OutputCache reduce la carga CPU/DB en un 98%
  • Beneficios para el cliente/DCR: ResponseCache reduce mi ancho de banda y mejora la latencia global
  • Flexibilidad: Diferentes TTL para servidor vs cliente
  • Ahorro de costos: Menos consultas DB, menos ancho de banda

Contras:

  • Estanqueidad: Las actualizaciones de contenido tardan hasta 5 minutos en propagarse a los clientes
  • Complejidad de invalidación de caché: Actualizar un post requiere invalidar tanto las cachés del servidor como las CDN
  • Uso de memoria: OutputCache sostiene HTML renderizado en la memoria del servidor
  • Depuración de confusión: A veces olvidar caché está encendido y se preguntan por qué los cambios no aparecen

Cuando me saltaba el caché:

  • Datos en tiempo real (precios de las acciones, deportes en vivo)
  • Contenido personalizado (recomendaciones específicas para el usuario)
  • Páginas de tráfico bajo (recaudación de gastos generales > beneficio)
  • Páginas que cambian con mucha frecuencia

Mi veredicto: Para un blog con contenido sobre todo estático y alta proporción de lectura/escritura, el caché dual es una gran victoria. Yo no usaría esto en paneles de administración o tableros con datos que cambian rápidamente.


ViewBag, ViewData, y TempData: Controlador-a-Ver Estado (y por qué sobre todo evito dos de ellos)

Estos tres son a menudo confundidos. He aquí cómo difieren y lo que realmente uso en la producción.

Los tres amigos comparados

// ViewData: string-keyed dictionary
ViewData["Title"] = "Blog";
ViewData["Categories"] = new List<string> { "ASP.NET", "C#" };

// ViewBag: dynamic wrapper around ViewData
ViewBag.Title = "Blog";
ViewBag.Categories = new List<string> { "ASP.NET", "C#" };

// TempData: survives one redirect (backed by session or cookie)
TempData["Message"] = "Post saved!";
return RedirectToAction("Index");

Característica ViewData ViewBag TempData

--------- ---------- --------- ----------
Tiempo de vida # Solicitud actual # # Solicitud actual # Una redirección #
Acceso clave Teclas de cadena Sintaxis de propiedad Teclas de cadena
Seguridad del tipo Ninguno (las transmisiones necesarias) Ninguno (dinámica) Ninguno (las transmisiones necesarias)
Comprobación del tiempo de compilación # No # No # No #
Sobrevivir redirecciona # No # No # # Sí #

Lo que realmente uso: ViewBag para datos de diseño global

En mi blog, uso ViewBag exclusivamente para pasar datos de controladores a diseños compartidos (analíticos, categorías, etc.):

// From BaseController.cs - runs before every action
public override async Task OnActionExecutionAsync(ActionExecutingContext filterContext,
    ActionExecutionDelegate next)
{
    logger.LogInformation("OnActionExecutionAsync");

    if (!Request.IsHtmx())
    {
        // Analytics settings for layout
        ViewBag.UmamiPath = AnalyticsSettings.UmamiPath;
        ViewBag.UmamiWebsiteId = AnalyticsSettings.WebsiteId;
        ViewBag.UmamiScript = AnalyticsSettings.UmamiScript;
    }

    logger.LogInformation("Adding categories to viewbag");
    ViewBag.Categories = await GetCategories(); // Cached list

    await base.OnActionExecutionAsync(filterContext, next);
}

Entonces en mi diseño (_Layout.cshtml):

@if (ViewBag.Categories is List<string> categories)
{
    <nav>
        @foreach (var cat in categories)
        {
            <a asp-controller="Blog" asp-action="Category" asp-route-category="@cat">@cat</a>
        }
    </nav>
}

@if (!string.IsNullOrEmpty(ViewBag.UmamiPath))
{
    <script async src="@ViewBag.UmamiScript"
            data-website-id="@ViewBag.UmamiWebsiteId"></script>
}

Por qué funciona este patrón:

  • Mundial: Cada página necesita categorías de navegación y análisis
  • Computado una vez: BaseController se ejecuta antes de cada acción
  • Optimización HTMX: Saltar script de análisis en solicitudes parciales (HTMX no necesita que se vuelva a inyectar)
  • En caché: Las categorías están en caché (ver mi patrón de IMemoryCache arriba), así que no golpear DB cada solicitud

Error que cometí al principio: Estaba a punto. ViewBag.Categories en cada método de acción. DRY violación y fácil de olvidar. OnActionExecutionAsync en el controlador de base resuelto esto.

ViewBag para datos específicos de la página (patrón aceptable)

// From BlogController.cs
[Route("category/{category}")]
public async Task<IActionResult> Category(string category, int page = 1, int pageSize = 10)
{
    ViewBag.Category = category; // Used in view for heading
    ViewBag.Title = category + " - Blog"; // Used in layout <title>

    var posts = await blogViewService.GetPostsByCategory(category, page, pageSize);
    // ... populate posts model ...

    if (Request.IsHtmx()) return PartialView("_BlogSummaryList", posts);
    return View("Index", posts);
}

Teniendo en cuenta:

@{
    ViewData["Title"] = ViewBag.Title; // Standard MVC convention for <title>
}

<h1>Category: @ViewBag.Category</h1>

Esto está bien porque:

  • Valores escalares simples (cadena, int)
  • Utilizado sólo en la vista, no pasó alrededor
  • Alternativa sería añadir Title y Category propiedades de cada modelo de vista

Lo que evito: Objetos complejos en ViewBag

Antipatrón:

// DON'T DO THIS
ViewBag.User = new UserViewModel { Name = "Scott", IsAdmin = true };
ViewBag.Posts = new List<Post> { ... };
ViewBag.Metadata = new { Tags = new[] { "a", "b" }, Date = DateTime.Now };

Problemas:

  • Sin seguridad en el tiempo de compilación (typo ViewBag.Usr falla en tiempo de ejecución)
  • Difícil de rastrear qué datos están disponibles a la vista
  • Hace las pruebas más difíciles (necesita inspeccionar el diccionario ViewBag)
  • Sin IntelliSense

Mejor: Modelos de vista fuertemente tecleados:

// DO THIS instead
public class BlogIndexViewModel : BaseViewModel
{
    public string Category { get; set; }
    public List<PostSummary> Posts { get; set; }
    public PaginationInfo Pagination { get; set; }
}

public IActionResult Category(string category, int page = 1)
{
    var model = new BlogIndexViewModel
    {
        Category = category,
        Posts = await GetPosts(category, page),
        // Inherited from BaseViewModel:
        Authenticated = user.LoggedIn,
        Name = user.Name,
        AvatarUrl = user.AvatarUrl
    };
    return View("Index", model);
}

TempData: Yo no lo uso (y he aquí por qué)

Caso estándar de uso de TempData:

[HttpPost]
public IActionResult SavePost(PostModel model)
{
    // Save post...
    TempData["SuccessMessage"] = "Post saved successfully!";
    return RedirectToAction("Index");
}

public IActionResult Index()
{
    // TempData["SuccessMessage"] available here (consumed on read)
    return View();
}

Por qué no uso TempData en mi blog:

  1. Uso HTMX en lugar de redireccionamientos: Mis formularios se envían a través de HTMX y devuelven vistas parciales con mensajes de error/éxito en línea. No redireccionamiento = no hay necesidad de TempData.
[HttpPost]
public async Task<IActionResult> Submit(ContactViewModel model)
{
    if (!ModelState.IsValid)
        return PartialView("_ContactForm", model); // Show errors inline

    await sender.SendEmailAsync(contactModel);

    // Return success view directly (no redirect)
    return PartialView("_Response", new ContactViewModel
    {
        Email = model.Email,
        Name = model.Name,
        Comment = "Message sent!"
    });
}
  1. **Para PRG tradicional (Post-Redirect-Get)**Pero prefiero evitar redirecciones cuando sea posible para una mejor UX.

Cuando TempData tiene sentido:

  • Aplicaciones MVC tradicionales con redireccionamientos de página completa después del mensaje
  • Asistentes de varios pasos donde redirige entre pasos
  • Mensajes flash después de redireccionamientos de autenticación

TempData tiene: Por defecto respaldado por cookies (desde ASP.NET Core 2.0). Si usted pone objetos grandes en TempData, usted está hinchando la cookie enviada con cada solicitud. Para un estado grande, utilice Sesión con una tienda de respaldo o DB.

Árbol de decisión rápida

flowchart TD
    A[Need to pass data to view?] --> B{What kind?}
    B -->|Global layout data| C[ViewBag in BaseController]
    B -->|Simple page-specific| D[ViewBag in action]
    B -->|Complex model| E[Strongly-typed ViewModel]
    B -->|Survive redirect| F{Using HTMX?}
    F -->|Yes| G[Return partial with message]
    F -->|No| H[TempData for flash message]

Mis reglas:

  1. VerBag solo para material de diseño global (analíticos, nav, migajas de pan)
  2. VerBag para títulos/cabezas de página simples (opcional; podría usar ViewModel)
  3. Never ViewBag para objetos complejos (Use ViewModels)
  4. Nunca verData (ViewBag tiene una sintaxis más agradable)
  5. TempData solo si realmente necesitas PRG (No, gracias a HTMX)

Patrón: Asistentes y flujos de múltiples pasos

¿Qué titular estatal usar?

flowchart TD
  A[Start Wizard] --> B{Short-lived?\nSingle browser?}
  B -- Yes --> S[Session/TempData]
  B -- No/Complex --> D[DB + key in route]
  S --> PRG[Use PRG between steps]
  D --> PRG
  • Pequeña sesión única: Sesión o TempData entre pasos.
  • Cross-device/long-running: persistir en DB, llevar una clave en la URL.

Ejemplo (DB + llave de ruta):

app.MapPost("/wizard/{id}", async (AppDb db, Guid id, StepInput input) =>
{
    var flow = await db.Flows.FindAsync(id) ?? new Flow(id);
    flow.Apply(input);
    await db.SaveChangesAsync();
    return Results.Redirect($"/wizard/{id}/next");
});

Patrón: Mensajes Flash con TempData

  • Establecer en POST; leer una vez después de redireccionamiento.
TempData["Flash"] = "Profile saved";
return RedirectToAction("Index");

Vista Razor:

@if (TempData["Flash"] is string flash) {
  <div class="alert alert-info">@flash</div>
}

Patrón: Carrito de compras

  • Carros pequeños: Sesión (si su escala es modesta y tiene sesión pegajosa/distribuida).
  • Carros más grandes/multidispositivo: DB + cart-id en cookie o URL. Caché para la velocidad.
flowchart LR
  U[User] -- cart-id cookie --> S[Server]
  S --> DB[(Cart Table)]
  S <--> Cache[Distributed Cache]

Lista de comprobación de seguridad, privacidad y cumplimiento

  • Validar todo el estado proporcionado por el cliente: consultas, cabeceras, formularios, cookies, reclamaciones JWT.
  • Proteja el estado de almacenamiento del cliente sensible: use Protección de datos para las cookies que emite; nunca almacene secretos en cadenas de consulta.
  • Establecer banderas de cookies: Secure, HttpOnly, SameSite, IsEssential (si así lo requiere el consentimiento/necesidad funcional).
  • Regenerar las cookies de autenticación en los cambios de privilegios; mantener las reclamaciones mínimas.
  • Cifrar en reposo para las tiendas del lado del servidor según sea necesario; asegurar la rotación de claves (teclas de protección de datos, claves de firma JWT).
  • GDPR/CCPA: proporcionar rutas de exportación/eliminación de datos del usuario; minimizar la retención.

Matriz de decisión (hoja de trigo)

classDiagram
  class Items {
    +Per-request only
    +Great for middleware->endpoint handoff
  }
  class QueryRoute {
    +Explicit, linkable
    -User-controlled
  }
  class Headers {
    +Tracing, idempotency
    -Noisy, untrusted
  }
  class Cookies {
    +Persist small prefs
    -Size, perf, consent
  }
  class TempData {
    +One-redirect messages
    -Ephemeral
  }
  class Session {
    +Conversational state
    -Scaling complexity
  }
  class MemoryCache {
    +Fast, in-proc
    -Not shared across instances
  }
  class DistributedCache {
    +Shared across servers
    -Serialization, ops
  }
  class AuthCookieClaims {
    +Identity, roles
    -Don’t overstuff
  }
  class JWT {
    +Stateless, cross-domain
    -Revocation/rotation
  }
  class DB {
    +Durable, authoritative
    -Latency, complexity
  }

Púas rápidas:

  • ¿Necesitas un flash de redirección?
  • ¿Necesita asistente para múltiples peticiones en una sesión? Sesión (o tecla DB + si es de larga duración/multidispositivo).
  • ¿Necesita escalabilidad y APIs apátridas? JWT para la identidad, DB/DistributedCache para el estado.
  • ¿Necesita pasar los datos dentro de la tubería solamente? HttpContext.Items.
  • ¿Necesita caché para búsquedas computadas? IMemoryCache localmente; IDistribuidoCache a través de una granja.

MVC vs Páginas Razor vs APIs Mínimas: Mismas Fundaciones, Diferentes Formas

Las tres pilas se sitúan en los mismos primitivos (HttpContext, encuadernación de modelos, auth, protección de datos). Los ejemplos anteriores muestran que las API difieren principalmente en ergonomía:

  • APIs mínimas: enlace de parámetros de route/query/body/claims; retorno Results.*.
  • MVC: atributos, filtros, unión de modelos en los parámetros de acción/modelos de visión.
  • Páginas Razor: manejadores de páginas con propiedades encuadernadas y ayudantes de etiquetas para generar enlaces/formularios.

Todos ellos comparten los mismos mecanismos estatales discutidos aquí.


Pitfalls y anti-patrones

  • Almacenar datos grandes o sensibles en cookies o TempData.
  • Dependiendo de la caché en memoria para la corrección (es una caché, no la verdad).
  • Autorización de construcción basada en las banderas de ruta/consulta del cliente sin cheques del servidor.
  • El exceso de relleno de las cookies de autenticación o JWT con reclamaciones volátiles.
  • Sesión sin estrategia de distribución (funciona localmente, se rompe a escala).

Gestión del Estado en el Mundo Real: Lo que realmente uso (y no uso)

Después de mostrarles todas estas opciones, aquí está mi evaluación honesta de lo que funciona en la producción para mi plataforma de blog.

Mi pila de gestión de estado (en orden de frecuencia)

Mecanismo Frecuencia Casos de uso Satisfacción |-----------|-----------|-----------|--------------| | IMemoryCache Categorías, métricas, tareas de traducción | Caché de salida Alto Rendered blog posts, lists Enorme perf win | ResponseCache Las cabeceras de caché HTTP funcionan con la caché de salida. | VerBag Configuraciones analíticas, títulos de página OK para cosas simples | Auth Claims Medio Identidad del usuario, bandera del administrador Herramienta derecha para la auth | Ruta/Query Medio Paginación, filtrado, babosas Apátrida y enlazable | Base de datos Medium Blog posts, comentarios, estado Fuente de verdad | Cookies Las preferencias del usuario (futuro) No se han necesitado todavía | Sesión # Nunca # - # # No hay tienda distribuida # | TempData # Nunca # - # HTMX elimina la necesidad # | HttpContext.Items Nunca - No he tenido caso de uso | IDistributedCache # Nunca # - # Un solo servidor (por ahora) #

Pros/contras detallados de la experiencia de producción

IMemoryCache

Para lo que lo uso:

  • Lista de categorías (global, 30 min TTL)
  • Seguimiento de tareas de traducción por usuario (6h absoluta + 1h deslizante)
  • Respuestas externas de la API (métricas de Umami, 1h TTL)

Pros en la práctica:

  • Blanqueo rápido (en proceso)
  • Sin gastos generales de serialización
  • Reducción de la carga DB/API en ~95%
  • Fácil de implementar y entender
  • Observabilidad a través de SerilogTracing

Contras que he golpeado:

  • Pérdidas de memoria si no limitas las cachés por usuario
  • Limpiado en el reinicio de la aplicación (aceptable para mi caso de uso)
  • No compartido entre servidores (bien para una sola instancia)
  • La invalidación de caché es manual (necesita eliminar() explícitamente)

Límite de escala: Si llego a múltiples servidores, necesitaría IDistributedCache (Redis) para el estado compartido. Por ahora, un solo servidor + memoria caché es perfecto.

Caché de salida + Caché de respuesta

Para lo que lo uso:

  • Renderización de posts de blog (1 hora de servidor, 5 minutos de cliente/CDN)
  • Lista de blogs/páginas de categorías
  • Datos del calendario

Pros en la práctica:

  • Velocidad de 25x (50ms → 2ms)
  • Escalas a alto tráfico sin romper el sudor
  • TTLs separados para servidor vs cliente
  • Funciona perfectamente con HTMX (VaryByHeader)
  • Impacto mensurable a través de métricas Prometheus

Contras que he golpeado:

  • Depuración de confusión (olvidar caché está activado)
  • Explosión de caché con muchas combinaciones de parámetros de consulta
  • Ventana de rancio (5 min para los clientes)
  • Necesidad de invalidar manualmente en las actualizaciones de contenido

Lo mejor para: Aplicaciones de lectura pesadas con mayormente contenido estático. No es bueno para datos personalizados o en tiempo real.

ViewBag

Para lo que lo uso:

  • Datos de la distribución mundial (analíticos, categorías)
  • Títulos de las páginas

Pros en la práctica:

  • Sencillo y rápido para datos de nivel de diseño
  • Establecer una vez en BaseController, disponible en todas partes
  • Funciona bien con la lista de categorías en caché

Contras que he golpeado:

  • Ningún tipo de seguridad (los tipos fallan en el tiempo de ejecución)
  • Tentador a sobreutilizar datos complejos
  • Difícil de probar

Regla que sigo: ViewBag sólo para escalares simples. Los objetos complejos van en ViewModels.

Auth Claims

Para lo que lo uso:

  • ID de usuario, nombre, correo electrónico, URL de avatar
  • Indicador de administración (chequeo) sub reclamación contra configuración)

Pros en la práctica:

  • Seguro (cookie firmada, Protección de datos)
  • Automático con la identidad/OAuth del núcleo de ASP.NET
  • Disponible a través de User.Claims en todas partes
  • Expiración deslizante mantiene a los usuarios registrados

Contras que he golpeado:

  • Límite del tamaño de las galletas (no exageres las reclamaciones)
  • Las reclamaciones son estáticas hasta que se reinician
  • Si añado una reclamación (como "IsEditor"), necesito volver a emitir la cookie de autenticación

Mejores prácticas: Mantenga las reclamaciones mínimas y estables. No ponga datos que cambian con frecuencia en las reclamaciones.

Cosas que no uso (y por qué)

Sesión (nunca utilizada):

  • Requeriría una tienda distribuida (Redis)
  • Añade complejidad para un beneficio mínimo
  • Mis casos de uso son mejor atendidos por:
    • Auth claims (identidad)
    • IMemoryCache (estado de corta duración)
    • Base de datos (estado duradero)

TempData (nunca utilizado):

  • HTMX eliminado Patrón post-Redirect-Get
  • Los formularios devuelven vistas parciales con mensajes en línea
  • No hay necesidad de sobrevivir redireccionamientos

HttpContext.Temas (nunca utilizados):

  • No he tenido un caso de uso para el estado de solicitud
  • Mi middleware no calcula valores para controladores
  • Si necesitara detección de inquilinos, la usaría.

IDistributedCache (nunca utilizado):

  • Despliegue de un solo servidor
  • IMemoryCache satisface todas las necesidades
  • Usaría Redis si escala a varios servidores

Evolución de mi enfoque

Fase 1 (inicial): No hay caché en absoluto. Cada petición golpeó la base de datos y rindió Markdown. Funcionó bien para el tráfico bajo.

Fase 2 (primera optimización): Añadido IMemoryCache para categorías. Sierra de reducción inmediata de carga DB. Se mantuvo simple: 30 min TTL, sin lógica de fantasía.

Fase 3 (ampliación): Se agregó OutputCache para los posts de blog cuando el tráfico aumentó. Mejora masiva del rendimiento. Error inicial: caché durante 5 minutos solamente. Aumento a 1 hora después de la monitorización mostró raramente cambios de contenido.

Fase 4 (observabilidad): SerilogTracing añadido a caché de métricas. Las fallas de caché descubiertas fueron altas debido al formato de la fecha en las teclas. yyyyMMdd La tasa de éxito pasó del 60% al 95%.

Fase 5 (integración HTMX): Añadido VaryByHeader en lugar de hx-request. Inicialmente se olvidó de esto y sirvió páginas completas a las solicitudes HTMX. Depurar pesadilla hasta que lo descubrí.

Estado actual: Feliz con la pila. IMemoryCache + OutputCache + ResponseCache maneja el 98% de mis necesidades de administración del estado. Base de datos para el estado duradero.

Consejos para tu aplicación

Comience aquí:

  1. Parametros de ruta/consulta para navegación/filtrado (siempre apátrida primero)
  2. Solicitudes de identidad de la Auth
  3. Base de datos para todo lo que debe persistir
  4. IMemoryCache para datos computados de lectura pesada
  5. Caché de salida para páginas caras de renderizar

Si es necesario, añádase: 6. Sesión (sólo si usted debe tener estado de conversación del lado del servidor) 7. IDistributedCache (sólo cuando escala a varios servidores) 8. Cookies (para preferencias del cliente, consentimiento)

Evitar:

  • Objetos complejos en ViewBag/TempData
  • Sesión sin tienda de respaldo distribuida
  • Encaje de datos específicos del usuario o que cambian con frecuencia
  • Optimización prematura (¡medida primero!)

Lecciones clave:

  • Comience simple, añadir complejidad sólo cuando las mediciones muestran que lo necesita
  • La observación es crítica (¡conozca las tasas de éxito de su caché!)
  • Piense en los límites de escala temprana (los cachés por usuario necesitan límites)
  • Comprobar la invalidación de la caché a fondo (la validez es un problema real)
  • Documente sus opciones TTL (futuro le preguntará "¿por qué 30 minutos?")

Enjuague

Estado en las aplicaciones web no es un solo tamaño se adapta a todos. Elija la opción más ligera que se adapte a sus necesidades, prefiera patrones apátridas cuando pueda, y sea explícito sobre la seguridad y el ciclo de vida.

Si quieres profundizar en cómo estas piezas fluyen a través de la tubería, mira mi serie empezando con Parte 1: Visión general y Fundación y especialmente el middleware y las piezas de encaminamiento.

Feliz edificio.


Apéndice: Más ejemplos de copia-pasteables (refinamientos)

Estos ejemplos profundizan las secciones anteriores con detalles de grado de producción que puede pegar en plantillas mínimas net9, MVC o Páginas Razor.

Cookies: Proteger los valores con protección de datos

using Microsoft.AspNetCore.DataProtection;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDataProtection();
var app = builder.Build();

app.MapPost("/prefs/secure/{value}", (HttpContext ctx, string value, IDataProtectionProvider dp) =>
{
    var protector = dp.CreateProtector("prefs.theme");
    var protectedValue = protector.Protect(value);
    ctx.Response.Cookies.Append("pref.theme.p", protectedValue, new CookieOptions
    {
        HttpOnly = true,
        Secure = true,
        SameSite = SameSiteMode.Lax,
        Expires = DateTimeOffset.UtcNow.AddYears(1)
    });
    return Results.Ok();
});

app.MapGet("/prefs/secure", (HttpContext ctx, IDataProtectionProvider dp) =>
{
    if (ctx.Request.Cookies.TryGetValue("pref.theme.p", out var v))
    {
        var protector = dp.CreateProtector("prefs.theme");
        return Results.Text(protector.Unprotect(v));
    }
    return Results.NotFound();
});

Consejo: En las implementaciones de múltiples nodos, persistan las claves de protección de datos (por ejemplo, a un sistema de archivos compartido, Redis o Azure Key Vault) para que las cookies puedan leerse en todas las instancias.

Antifalsificación en MVC, Páginas de Razor y APIs Mínimas

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews();
builder.Services.AddRazorPages();
builder.Services.AddAntiforgery(o => o.HeaderName = "X-CSRF-TOKEN");
var app = builder.Build();

app.MapGet("/antiforgery/token", (IAntiforgery af, HttpContext ctx) =>
{
    var tokens = af.GetAndStoreTokens(ctx);
    return Results.Json(new { token = tokens.RequestToken });
});

app.MapPost("/submit", (HttpContext ctx) => Results.Ok("posted"))
   .AddEndpointFilter(async (efiContext, next) =>
   {
       var af = efiContext.HttpContext.RequestServices.GetRequiredService<IAntiforgery>();
       await af.ValidateRequestAsync(efiContext.HttpContext);
       return await next(efiContext);
   });

app.MapControllers();
app.MapRazorPages();
  • MVC: Decorar acciones con [ValidateAntiForgeryToken] y uso @Html.AntiForgeryToken() en formas.
  • Páginas Razor: habilitadas por defecto en los mensajes de formulario; use asp-antiforgery="true" si es necesario.
  • Mínimo: validar a través de IAntiforgery como se muestra.

Sesión con Redis y deslizamiento vs vencimiento absoluto

builder.Services.AddStackExchangeRedisCache(o => o.Configuration = "localhost:6379");
builder.Services.AddSession(o =>
{
    o.IdleTimeout = TimeSpan.FromMinutes(20); // sliding
    o.IOTimeout = TimeSpan.FromSeconds(2);
    o.Cookie.HttpOnly = true;
    o.Cookie.IsEssential = true;
});
var app = builder.Build();
app.UseSession();

Almacenar datos pequeños y comprimibles solamente. Persista los carritos/órdenes reales a un DB.

IMemoryCache con opciones de entrada y devolución de llamada de desalojo

builder.Services.AddMemoryCache();

app.MapGet("/fx/{pair}", (IMemoryCache cache, string pair) =>
{
    var key = $"fx:{pair.ToLowerInvariant()}";
    return Results.Ok(cache.GetOrCreate(key, entry =>
    {
        entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
        entry.SlidingExpiration = TimeSpan.FromMinutes(2);
        entry.Size = 1; // enable size-based eviction if configured
        entry.RegisterPostEvictionCallback((k, v, reason, state) =>
        {
            Console.WriteLine($"Evicted {k} because {reason}");
        });
        return 0.92m; // fetch from external service in real life
    }));
});

IDistributedCache get-or-set con nerviosismo para evitar estampidas

builder.Services.AddStackExchangeRedisCache(o => o.Configuration = "localhost:6379");

app.MapGet("/feature/{name}", async (IDistributedCache cache, string name) =>
{
    var key = $"feat:{name}";
    var cached = await cache.GetStringAsync(key);
    if (cached is not null) return Results.Text(cached);

    // Lock key to prevent thundering herd (very simple approach)
    var lockKey = key + ":lock";
    var gotLock = await cache.SetStringAsync(lockKey, "1", new DistributedCacheEntryOptions
    {
        AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(5)
    });

    try
    {
        cached = await cache.GetStringAsync(key);
        if (cached is null)
        {
            var computed = "on"; // expensive work
            var rnd = Random.Shared.Next(0, 15); // jitter
            await cache.SetStringAsync(key, computed, new DistributedCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5).Add(TimeSpan.FromSeconds(rnd))
            });
            cached = computed;
        }
    }
    finally
    {
        await cache.RemoveAsync(lockKey);
    }

    return Results.Text(cached);
});

Para un bloqueo robusto, prefiera los primitivos Redis (SET NX EX) a través de StackExchange.Redis.

Expedir y validar JWT localmente (demo)

using System.IdentityModel.Tokens.Jwt;
using Microsoft.IdentityModel.Tokens;
using System.Security.Claims;

var key = new SymmetricSecurityKey(System.Text.Encoding.UTF8.GetBytes("super-secret-key-please-rotate"));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

builder.Services.AddAuthentication("Bearer")
    .AddJwtBearer("Bearer", o =>
    {
        o.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = false,
            ValidateAudience = false,
            IssuerSigningKey = key,
            ValidateIssuerSigningKey = true,
            ValidateLifetime = true
        };
    });
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();

app.MapPost("/token", () =>
{
    var claims = new[] { new Claim(ClaimTypes.Name, "alice") };
    var jwt = new JwtSecurityToken(claims: claims, expires: DateTime.UtcNow.AddMinutes(30), signingCredentials: creds);
    var token = new JwtSecurityTokenHandler().WriteToken(jwt);
    return Results.Json(new { access_token = token });
});

app.MapGet("/who", [Microsoft.AspNetCore.Authorization.Authorize] () => "ok");

Actualizaciones condicionales con Etags (If-Match)

record Todo(int Id, string Title, string Version);
var store = new Dictionary<int, Todo> { [1] = new(1, "Ship", "v1") };

app.MapGet("/todo/{id:int}", (int id, HttpContext ctx) =>
{
    if (!store.TryGetValue(id, out var t)) return Results.NotFound();
    ctx.Response.Headers.ETag = t.Version;
    return Results.Json(t);
});

app.MapPut("/todo/{id:int}", (int id, HttpContext ctx, Todo input) =>
{
    if (!store.TryGetValue(id, out var current)) return Results.NotFound();
    var ifMatch = ctx.Request.Headers["If-Match"].ToString();
    if (string.IsNullOrEmpty(ifMatch) || ifMatch != current.Version)
        return Results.StatusCode(StatusCodes.Status412PreconditionFailed);

    var next = current with { Title = input.Title, Version = $"v{DateTime.UtcNow.Ticks}" };
    store[id] = next;
    ctx.Response.Headers.ETag = next.Version;
    return Results.Ok(next);
});

TempData: objetos complejos vía JSON

public static class TempDataJsonExtensions
{
    public static void Put<T>(this ITempDataDictionary tempData, string key, T value)
        => tempData[key] = System.Text.Json.JsonSerializer.Serialize(value);

    public static T? Get<T>(this ITempDataDictionary tempData, string key)
        => tempData.TryGetValue(key, out var o) && o is string s
           ? System.Text.Json.JsonSerializer.Deserialize<T>(s)
           : default;
}

// Usage in MVC action
TempData.Put("WizardState", new { Step = 2, Name = "Alice" });
var state = TempData.Get<dynamic>("WizardState");

EF token de concurrencia del núcleo

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    [Timestamp] public byte[] RowVersion { get; set; } = default!;
}

// On update
try
{
    await db.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
    return Results.StatusCode(StatusCodes.Status412PreconditionFailed);
}
app.MapPost("/promote", async (HttpContext ctx) =>
{
    var u = ctx.User;
    var claims = u.Claims.ToList();
    claims.Add(new Claim(ClaimTypes.Role, "Editor"));
    var id = new ClaimsIdentity(claims, "Cookies");
    await ctx.SignInAsync("Cookies", new ClaimsPrincipal(id));
    return Results.Ok();
});

Esto debería cubrir los vacíos: fallos de seguridad más fuertes, preparación multinodo y patrones del mundo real para cachés, tokens y peticiones condicionales.


Buceo profundo: HttpContext.Items (Patrones prácticos y ayudantes)

HttpContext.Items es una bolsa por petición (IDictionary<object, object?>) que vive sólo para la vida útil de una sola solicitud. Es perfecto para pasar valores calculados de middleware/filters a sus endpoints, controladores y manejadores de Razor Pages sin tocar estado global o tiendas de larga duración.

  • Ciclo de vida: creado a petición de inicio; descartado cuando la respuesta se completa.
  • Alcance: sólo petición actual — nunca cruza redireccionamientos o trabajo de fondo.
  • Rendimiento: búsquedas O(1); ideal para caché por petición.
  • Seguridad: solo en el lado del servidor; no visible para el cliente.

¿Por qué artículos en lugar de?

  • Session/TempData: Estas peticiones cruzadas e introducen preocupaciones de distribución. Los artículos son efímeros y fáciles de escalar.
  • DI Scoped services: Use éstos para comportamiento y dependencias compartidas. Los elementos son mejores para ad-hoc, valores computados (teniente, localización del usuario, banderas de características) y cachés por petición.
  • Características: Para características de nivel de framework/transport (IEndpointFeature, IHttpUpgradeFeature). Los elementos son para datos de nivel de aplicación.

Evitar colisiones clave: teclas fuertemente tecleadas

Debido a que Items utiliza teclas de objetos, prefiere las teclas de objetos estáticos privados o un tipo de clave dedicado para evitar colisiones de nombres.

public static class ItemKeys
{
    public static readonly object TenantId = new();
    public static readonly object UserLocale = new();
    public static readonly object PerRequestCache = new();
}

O cree una envoltura mecanografiada con extensiones:

public static class HttpContextItemsExtensions
{
    public static void Set<T>(this HttpContext ctx, object key, T value)
        => ctx.Items[key] = value!;

    public static T? Get<T>(this HttpContext ctx, object key)
        => ctx.Items.TryGetValue(key, out var v) ? (T?)v : default;

    public static T GetOrCreate<T>(this HttpContext ctx, object key, Func<T> factory)
    {
        if (ctx.Items.TryGetValue(key, out var existing) && existing is T typed)
            return typed;
        var created = factory();
        ctx.Items[key] = created!;
        return created;
    }
}

Patrón: Computar en middleware, consumir en endpoints/controllers/pages

// Program.cs
app.Use(async (ctx, next) =>
{
    var tenant = ctx.Request.Headers["X-TenantId"].FirstOrDefault() ?? "public";
    ctx.Set(ItemKeys.TenantId, tenant); // using extension above

    // Per-request cache holder (optional)
    ctx.Set(ItemKeys.PerRequestCache, new Dictionary<string, object?>());

    await next(ctx);
});

// Minimal API
app.MapGet("/whoami", (HttpContext ctx) => new
{
    Tenant = ctx.Get<string>(ItemKeys.TenantId),
});

// MVC Controller
public IActionResult WhoAmI()
    => Json(new { Tenant = HttpContext.Get<string>(ItemKeys.TenantId) });

// Razor Page handler
public IActionResult OnGet()
    => new JsonResult(new { Tenant = HttpContext.Get<string>(ItemKeys.TenantId) });

Patrón: Por petición de caché para evitar el trabajo repetido

Usar elementos como una caché tan pequeña que las lecturas repetidas dentro de la misma petición no vuelven a golpear bases de datos/servicios.

public static class PerRequestCacheExtensions
{
    public static async Task<T> GetOrAddAsync<T>(this HttpContext ctx, string key, Func<Task<T>> factory)
    {
        var bag = ctx.Get<Dictionary<string, object?>>(ItemKeys.PerRequestCache)
                  ?? ctx.GetOrCreate(ItemKeys.PerRequestCache, () => new Dictionary<string, object?>());

        if (bag.TryGetValue(key, out var val) && val is T hit)
            return hit;

        var created = await factory();
        bag[key] = created!;
        return created;
    }
}

// Usage in endpoint
app.MapGet("/profile", async (HttpContext ctx, IUserRepo repo) =>
{
    var userId = ctx.User.Identity?.Name ?? "anon";
    var profile = await ctx.GetOrAddAsync($"profile:{userId}", () => repo.LoadAsync(userId));
    return Results.Json(profile);
});

Notas:

  • Threading: Una sola petición normalmente se ejecuta en una ruta lógica; los elementos no son seguros para las escrituras paralelas. Si inicia tareas paralelas que comparten elementos, agregue su propia sincronización.
  • Tamaño: Mantenga valores pequeños y baratos para calcular/serializar. Es en memoria por petición.

Patrón: Filtros que pueblan elementos (páginas de MVC/Razor)

public class TenantFilter : IAsyncResourceFilter
{
    public async Task OnResourceExecutionAsync(ResourceExecutingContext context, ResourceExecutionDelegate next)
    {
        var tenant = context.HttpContext.Request.Headers["X-TenantId"].FirstOrDefault() ?? "public";
        context.HttpContext.Set(ItemKeys.TenantId, tenant);
        await next();
    }
}

// Register filter globally
services.AddControllersWithViews(o => o.Filters.Add<TenantFilter>());

Patrón: Enriquecer registros sin asignaciones en todas partes

Compute una vez, luego lea en los visores de registro o middleware.

app.Use(async (ctx, next) =>
{
    var correlationId = ctx.Request.Headers["X-Correlation-Id"].FirstOrDefault() ?? Guid.NewGuid().ToString("n");
    ctx.Items["CorrelationId"] = correlationId; // string key acceptable for app-local use

    using (logger.BeginScope(new { CorrelationId = correlationId }))
    {
        await next(ctx);
    }
});

Cuándo no usar elementos

  • Los datos necesarios después de redirigir o a través de las solicitudes (usar TempData/Session/DB en su lugar).
  • Singletons o cachés de petición cruzada (usar IMemoryCache/IDistributedCache).
  • Valores que pertenecen a la identidad/autorización (utilizar reclamaciones/políticas).

Regla rápida: Si se calcula durante esta solicitud y se lee dentro de esta solicitud por su propio código, los elementos es ideal.

logo

© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.