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.
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.
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
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);
});
app.MapGet("/whoami", (HttpContext ctx) => new { Tenant = ctx.Items["TenantId"] });
public IActionResult WhoAmI() => Json(new { Tenant = HttpContext.Items["TenantId"] });
public IActionResult OnGet() => new JsonResult(new { Tenant = HttpContext.Items["TenantId"] });
Ejemplos
app.MapGet("/orders/{id:int}", (int id, int? page) => Results.Ok(new { id, page }));
// GET /orders/5?page=2
[HttpGet("/orders/{id:int}")]
public IActionResult Details(int id, int? page)
=> View(new { id, page });
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)
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
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)
[HttpPost]
[ValidateAntiForgeryToken]
public IActionResult Save(SettingsModel model)
{
// validate & persist
return RedirectToAction(nameof(Summary), new { tab = model.SelectedTab });
}
public IActionResult OnPost(SettingsModel model)
{
return RedirectToPage("/Settings/Summary", new { tab = model.SelectedTab });
}
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.
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]
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 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:
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:
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é.
Cache-aside (más común):
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.
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.
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);
});
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;
}
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.
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:
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).
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:
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.
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:
yyyyMMdd formato en clave tan diferentes tiempos en el mismo día compartir la caché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.
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:
Cuando no uso IMemoryCache:
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) }));
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
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);
});
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");
});
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.
ASP.NET Core tiene dos Sistemas de almacenamiento en caché similares:
Cache-Control, Vary) diciendo a los navegadores y CDNs cómo caché
eSe complementan entre sí. ResponseCache maneja el caché cliente/CDN; OutputCache maneja el caché del lado del servidor.
// 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:
Comprobación de la caché de salida primero: ¿Tengo una respuesta caché para my-post + en ¿Lenguaje?
ResponseCache sets headersDespués de que OutputCache genera la respuesta, ResponseCache añade:
Cache-Control: public, max-age=300
Vary: hx-request
Caché del navegador: El navegador oculta la respuesta durante 300 segundos (5 minutos). Las peticiones posteriores del mismo usuario ni siquiera golpean el servidor.
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.
[ResponseCache(Duration = 300)] // 5 minutes client/CDN cache
[OutputCache(Duration = 3600)] // 1 hour server cache
Razonamiento:
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.
VaryByHeader = "hx-request" // ResponseCache
VaryByHeaderNames = new[] { "hx-request" } // OutputCache
Por qué esto importa: Las solicitudes HTMX incluyen hx-request: true Devuelvo diferentes respuestas:
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
[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 entradapage=2&pageSize=20&language=en → Otra entradapage=1&pageSize=10&language=en → Otra entradaMitigació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
SalidaCaché se salta para:
Set-Cookie encabezadoCache-Control: no-storeEjemplo 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
}
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:
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:
Contras:
Cuando me saltaba el caché:
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.
Estos tres son a menudo confundidos. He aquí cómo difieren y lo que realmente uso en la producción.
// 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í # |
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:
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.
// 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:
Title y Category propiedades de cada modelo de vistaAntipatró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:
ViewBag.Usr falla en tiempo de ejecución)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);
}
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:
[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!"
});
}
Cuando TempData tiene sentido:
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.
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:
¿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
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");
});
TempData["Flash"] = "Profile saved";
return RedirectToAction("Index");
Vista Razor:
@if (TempData["Flash"] is string flash) {
<div class="alert alert-info">@flash</div>
}
flowchart LR U[User] -- cart-id cookie --> S[Server] S --> DB[(Cart Table)] S <--> Cache[Distributed Cache]
Secure, HttpOnly, SameSite, IsEssential (si así lo requiere el consentimiento/necesidad funcional).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:
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:
Results.*.Todos ellos comparten los mismos mecanismos estatales discutidos aquí.
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.
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) #
Para lo que lo uso:
Pros en la práctica:
Contras que he golpeado:
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.
Para lo que lo uso:
Pros en la práctica:
Contras que he golpeado:
Lo mejor para: Aplicaciones de lectura pesadas con mayormente contenido estático. No es bueno para datos personalizados o en tiempo real.
Para lo que lo uso:
Pros en la práctica:
Contras que he golpeado:
Regla que sigo: ViewBag sólo para escalares simples. Los objetos complejos van en ViewModels.
Para lo que lo uso:
sub reclamación contra configuración)Pros en la práctica:
User.Claims en todas partesContras que he golpeado:
Mejores prácticas: Mantenga las reclamaciones mínimas y estables. No ponga datos que cambian con frecuencia en las reclamaciones.
Sesión (nunca utilizada):
TempData (nunca utilizado):
HttpContext.Temas (nunca utilizados):
IDistributedCache (nunca utilizado):
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.
Comience aquí:
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:
Lecciones clave:
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.
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.
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.
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();
[ValidateAntiForgeryToken] y uso @Html.AntiForgeryToken() en formas.asp-antiforgery="true" si es necesario.IAntiforgery como se muestra.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.
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
}));
});
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.
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");
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);
});
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");
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.
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.
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;
}
}
// 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) });
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:
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>());
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);
}
});
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.
© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.