← maurobernal.com.ar

Etiqueta: performance

  • Arquitectura de Web APIs en .NET 10 — Parte 3: Observabilidad, Resiliencia y Tiempo Real

    Llegamos a la tercera y última parte de la serie Arquitectura Esencial de Web APIs en .NET 10. En la Parte 1 vimos los fundamentos, en la Parte 2 los componentes de performance y escalabilidad. Ahora cerramos con los cuatro que elevan una API de «funcional» a «profesional»: observabilidad, resiliencia, despliegue progresivo y comunicación en tiempo real.

    8. OpenTelemetry: ver lo que pasa adentro

    Tuve años en los que mi «monitoreo» consistía en revisar logs manualmente y rezar para encontrar el error antes de que me llamara el cliente. Hoy, con OpenTelemetry integrado nativamente en .NET 10, no hay excusa para no tener observabilidad completa desde el día uno.

    OpenTelemetry unifica tres pilares de observabilidad:

    • Trazas distribuidas: seguir el flujo de una request a través de múltiples servicios
    • Métricas: CPU, memoria, requests por segundo, latencia
    • Logs: correlacionados automáticamente con las trazas
    builder.Services.AddOpenTelemetry()
        .WithMetrics(metrics => metrics
            .AddAspNetCoreInstrumentation()   // Métricas de HTTP
            .AddRuntimeInstrumentation()      // GC, threadpool, memoria
            .AddPrometheusExporter())         // Expone /metrics para Prometheus
        .WithTracing(tracing => tracing
            .AddAspNetCoreInstrumentation()   // Trazas de requests HTTP
            .AddHttpClientInstrumentation()   // Trazas de llamadas salientes
            .AddEntityFrameworkCoreInstrumentation() // Trazas de queries SQL
            .AddOtlpExporter(otlp =>          // Exporta a Jaeger, Grafana Tempo, etc.
            {
                otlp.Endpoint = new Uri("http://localhost:4317");
            }));
    
    // Opcional: exponer el endpoint de métricas de Prometheus
    app.MapPrometheusScrapingEndpoint("/metrics");

    Una vez que tenés OpenTelemetry configurado, herramientas como Grafana + Prometheus + Tempo te dan dashboards completos, alertas automáticas y trazas distribuidas entre microservicios. Todo lo que necesitás para diagnosticar cualquier problema en producción en minutos en lugar de horas.

    9. Resiliencia con Polly: sobrevivir a las caídas externas

    En arquitecturas de microservicios hay una certeza: el servicio que llamás va a fallar en algún momento. No es una posibilidad, es una garantía estadística. La pregunta es si tu API se cae con él o lo maneja con elegancia.

    Polly —integrado de forma estándar en .NET desde la versión 8— implementa patrones de resiliencia: reintentos con backoff exponencial, timeouts y circuit breakers.

    // Opción 1: Pipeline estándar preconfigurado (la manera más rápida)
    // Incluye: reintentos exponenciales + circuit breaker + timeout + hedging
    builder.Services.AddHttpClient("ExternalAPI", client =>
        client.BaseAddress = new Uri("https://api.externa.com"))
        .AddStandardResilienceHandler();
    
    // Opción 2: Pipeline personalizado para control fino
    builder.Services.AddHttpClient("CriticalService", client =>
        client.BaseAddress = new Uri("https://servicio-critico.com"))
        .AddResilienceHandler("custom", pipeline =>
        {
            // Timeout total de la operación
            pipeline.AddTimeout(TimeSpan.FromSeconds(10));
    
            // Reintentos: 3 intentos con backoff exponencial
            pipeline.AddRetry(new HttpRetryStrategyOptions
            {
                MaxRetryAttempts = 3,
                Delay = TimeSpan.FromMilliseconds(500),
                BackoffType = DelayBackoffType.Exponential,
                UseJitter = true, // Evita thundering herd
                ShouldHandle = args => args.Outcome switch
                {
                    { Exception: HttpRequestException } => PredicateResult.True(),
                    { Result.StatusCode: HttpStatusCode.ServiceUnavailable } => PredicateResult.True(),
                    _ => PredicateResult.False()
                }
            });
    
            // Circuit Breaker: abre después de 50% de fallos en 30 segundos
            pipeline.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions
            {
                FailureRatio = 0.5,
                SamplingDuration = TimeSpan.FromSeconds(30),
                MinimumThroughput = 10,
                BreakDuration = TimeSpan.FromSeconds(15)
            });
        });

    El Circuit Breaker es especialmente importante: cuando un servicio externo falla repetidamente, el circuit breaker «abre» y deja de intentar llamarlo durante un período, evitando que los timeouts se acumulen y degraden toda tu API.

    10. Feature Flags: deployar sin activar

    Los feature flags cambiaron mi forma de deployar. Antes, cada feature nueva era un riesgo: si algo fallaba en producción, había que hacer rollback, lo cual es lento y estresante. Hoy puedo subir código a producción con la funcionalidad apagada, activarla para el 5% de los usuarios, monitorear, y si todo va bien, activarla para todos. Sin rollback, sin estrés.

    dotnet add package Microsoft.FeatureManagement.AspNetCore
    builder.Services.AddFeatureManagement();
    
    app.MapGet("/api/beta-feature", async (IFeatureManager featureManager) =>
    {
        if (await featureManager.IsEnabledAsync("BetaFeatureX"))
            return Results.Ok("¡Nueva funcionalidad activada!");
    
        return Results.StatusCode(StatusCodes.Status404NotFound);
    });
    
    // También podés proteger endpoints completos con filtros de acción
    app.MapGet("/api/new-algorithm", [FeatureGate("NewAlgorithm")] () =>
        Results.Ok(RunNewAlgorithm()));

    Los flags se configuran en appsettings.json (para desarrollo) o en Azure App Configuration (para producción con control en tiempo real sin redeploy):

    {
      "FeatureManagement": {
        "BetaFeatureX": false,
        "NewAlgorithm": {
          "EnabledFor": [
            {
              "Name": "Percentage",
              "Parameters": { "Value": 10 }
            }
          ]
        }
      }
    }

    Podés activar features para un porcentaje de usuarios, para usuarios específicos, por fecha de activación, o con cualquier lógica custom. Ideal para Canary Releases y pruebas A/B.

    11. Server-Sent Events (SSE): tiempo real sin la complejidad de WebSockets

    WebSockets es la primera opción que se te ocurre para «tiempo real», pero tiene overhead de setup y complejidad de manejo de conexiones bidireccionales. Si solo necesitás enviar datos del servidor al cliente —notificaciones, progreso de tareas, feeds de datos— SSE es mucho más simple y funciona sobre HTTP estándar.

    En .NET 10, implementarlo con IAsyncEnumerable es elegante y eficiente:

    // Ejemplo 1: Stream simple de notificaciones
    app.MapGet("/api/notifications", async IAsyncEnumerable<string> (CancellationToken ct) =>
    {
        for (int i = 0; i < 5 && !ct.IsCancellationRequested; i++)
        {
            await Task.Delay(1000, ct);
            yield return $"data: Notificación {i + 1}\n\n";
        }
    });
    
    // Ejemplo 2: Progreso de una tarea larga
    app.MapPost("/api/export", async IAsyncEnumerable<ProgressUpdate> (ExportRequest request, CancellationToken ct) =>
    {
        var items = await GetItemsToExport(request);
        int total = items.Count;
    
        for (int i = 0; i < total && !ct.IsCancellationRequested; i++)
        {
            await ProcessItem(items[i]);
            yield return new ProgressUpdate
            {
                Current = i + 1,
                Total = total,
                Percentage = (int)((i + 1.0) / total * 100)
            };
        }
    });
    
    record ProgressUpdate(int Current, int Total, int Percentage);

    El cliente recibe un flujo continuo de datos sin necesidad de polling. Perfecto para dashboards en tiempo real, barras de progreso de procesos largos, o feeds de eventos de sistema.

    El checklist completo de producción

    Antes de llevar cualquier Web API a producción, repaso esta lista de los 11 componentes que cubrimos en la serie:

    • Health Checks — Liveness y Readiness configurados
    • Exception Handler global — ProblemDetails para todos los errores
    • Validación — Endpoint Filters antes de la lógica de negocio
    • OpenAPI nativo — Documentación sin Swashbuckle
    • Rate Limiting — Protección en endpoints públicos
    • Output Cache — En endpoints de lectura costosos
    • API Versioning — Desde el primer endpoint
    • OpenTelemetry — Métricas, trazas y logs exportando
    • Polly — Resiliencia en todas las llamadas externas
    • Feature Flags — Para funcionalidades en desarrollo activo
    • SSE — En lugar de polling para actualizaciones en tiempo real

    No hace falta implementarlos todos desde el día uno, pero sí conviene tenerlos en el radar desde que empezás. Cuanto más temprano los incorporés, menos dolores de cabeza cuando la API escale.

    ¿Hay algún componente que uses habitualmente y no esté en la lista? Dejamelo en los comentarios.

    Parte 2: Rate Limiting, Output Cache y API Versioning

  • Arquitectura de Web APIs en .NET 10 — Parte 2: Performance y Escalabilidad

    Esta es la segunda parte de la serie Arquitectura Esencial de Web APIs en .NET 10. En la Parte 1 cubrimos los fundamentos: Health Checks, Exception Handling, Validación y OpenAPI nativo. Ahora le toca el turno a los componentes que marcan la diferencia cuando tu API empieza a recibir carga real.

    Estos tres los ignoré durante demasiado tiempo, hasta que los problemas aparecieron solos en producción.

    5. Rate Limiting: protegé tu API del abuso

    Una API pública sin rate limiting es un blanco fácil. Me tocó ver picos de miles de requests por minuto desde una sola IP dejando el servidor de rodillas. Desde .NET 7 el middleware de rate limiting viene integrado en el framework, y en .NET 10 está completamente maduro.

    .NET ofrece cuatro estrategias distintas según el caso de uso:

    • Fixed Window: X peticiones por ventana de tiempo fija
    • Sliding Window: igual pero la ventana se mueve (más preciso)
    • Token Bucket: permite bursts controlados
    • Concurrency: limita las peticiones simultáneas, no el rate
    builder.Services.AddRateLimiter(options =>
    {
        // Política general: 100 requests por minuto por IP
        options.AddFixedWindowLimiter("General", opt =>
        {
            opt.Window = TimeSpan.FromMinutes(1);
            opt.PermitLimit = 100;
            opt.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
            opt.QueueLimit = 10; // Cuántas requests pueden esperar en cola
        });
    
        // Política estricta para endpoints sensibles
        options.AddSlidingWindowLimiter("Strict", opt =>
        {
            opt.Window = TimeSpan.FromMinutes(1);
            opt.SegmentsPerWindow = 6; // Segmentos de 10 segundos
            opt.PermitLimit = 20;
        });
    
        // Respuesta personalizada cuando se supera el límite
        options.OnRejected = async (context, cancellationToken) =>
        {
            context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
            await context.HttpContext.Response.WriteAsJsonAsync(
                new ProblemDetails { Title = "Too Many Requests", Status = 429 },
                cancellationToken);
        };
    });
    
    var app = builder.Build();
    app.UseRateLimiter();
    
    // Aplicar a endpoints específicos
    app.MapGet("/api/data", () => "Datos")
       .RequireRateLimiting("General");
    
    app.MapPost("/api/auth/login", (LoginDto dto) => Results.Ok())
       .RequireRateLimiting("Strict");

    Para APIs que manejan usuarios autenticados, podés segmentar el rate limiting por user ID en lugar de IP, así los usuarios legítimos no se ven afectados por el comportamiento de otros.

    6. Output Cache: no calcules lo mismo dos veces

    En un proyecto de reportes que tenía consultas de 2 segundos contra SQL Server, implementar Output Caching llevó el tiempo de respuesta promedio a menos de 10ms. Sin tocar una sola línea de lógica de negocio.

    Para endpoints que devuelven datos que no cambian constantemente, el Output Cache guarda la respuesta completa en memoria (o Redis) y la sirve directamente sin ejecutar nada de la lógica de negocio.

    builder.Services.AddOutputCache(options =>
    {
        // Política base: cache de 60 segundos
        options.AddBasePolicy(builder => builder.Expire(TimeSpan.FromSeconds(60)));
    
        // Política nombrada para datos de referencia
        options.AddPolicy("ReferenceData", builder =>
            builder.Expire(TimeSpan.FromMinutes(10))
                   .Tag("reference")); // Tag para invalidación selectiva
    });
    
    var app = builder.Build();
    app.UseOutputCache();
    
    // Cache por 10 segundos (override de la política base)
    app.MapGet("/api/stats", () => GetExpensiveStats())
       .CacheOutput(c => c.Expire(TimeSpan.FromSeconds(10)));
    
    // Usando política nombrada
    app.MapGet("/api/products", () => GetProducts())
       .CacheOutput("ReferenceData");
    
    // Invalidar el cache cuando cambian los datos
    app.MapPost("/api/products", async (ProductDto product, IOutputCacheStore cache) =>
    {
        await SaveProduct(product);
        await cache.EvictByTagAsync("reference", CancellationToken.None); // Invalida el cache
        return Results.Created($"/api/products/{product.Id}", product);
    });

    La invalidación por tags es la clave para usar Output Cache sin miedo a servir datos stale. Cuando mutás datos, invalidás el tag correspondiente y la próxima request regenera el cache.

    Para ambientes distribuidos (múltiples instancias), podés configurar Redis como backing store con AddStackExchangeRedisOutputCache().

    7. API Versioning: cambiar sin romper a tus clientes

    Esta es una de las lecciones más caras que aprendí: si tu API tiene clientes externos y no versiona desde el principio, el día que necesités hacer un cambio incompatible vas a tener un problema enorme. Agregarle versionado a una API ya desplegada es doloroso. Hacerlo desde el inicio es trivial.

    En Minimal APIs el versionado se maneja mediante Version Sets. Primero instalá el paquete Asp.Versioning.Http:

    dotnet add package Asp.Versioning.Http
    // Program.cs
    builder.Services.AddApiVersioning(options =>
    {
        options.DefaultApiVersion = new ApiVersion(1, 0);
        options.AssumeDefaultVersionWhenUnspecified = true;
        options.ReportApiVersions = true; // Devuelve las versiones disponibles en headers
    });
    
    var app = builder.Build();
    
    var apiVersionSet = app.NewApiVersionSet()
        .HasApiVersion(new ApiVersion(1, 0))
        .HasApiVersion(new ApiVersion(2, 0))
        .ReportApiVersions()
        .Build();
    
    // V1: respuesta simple
    app.MapGet("/api/v{version:apiVersion}/users", () =>
        new[] { new { Id = 1, Name = "Juan" } })
       .WithApiVersionSet(apiVersionSet)
       .MapToApiVersion(1, 0);
    
    // V2: respuesta enriquecida con datos extra
    app.MapGet("/api/v{version:apiVersion}/users", () =>
        new[] { new { Id = 1, Name = "Juan", Email = "juan@ejemplo.com", Role = "Admin" } })
       .WithApiVersionSet(apiVersionSet)
       .MapToApiVersion(2, 0);

    Los clientes existentes siguen usando /api/v1/users sin ningún cambio. Los nuevos pueden adoptar /api/v2/users con el formato enriquecido. Convivencia perfecta.

    Además del versionado por URL, podés usar query string (?api-version=2.0) o header (X-API-Version: 2.0) según las necesidades de tus clientes.

    Resumen de la Parte 2

    Tres componentes que hacen la diferencia cuando la API escala:

    • Rate Limiting → protección contra abuso, con estrategias flexibles por caso de uso
    • Output Cache → performance brutal en endpoints de lectura, con invalidación inteligente
    • API Versioning → libertad para evolucionar sin romper contratos existentes

    En la Parte 3 cerramos la serie con los componentes más avanzados: OpenTelemetry, Polly, Feature Flags y Server-Sent Events.

  • Arquitectura de Web APIs en .NET 10 — Parte 1: Los fundamentos que no pueden faltar

    Cuando empecé a trabajar con .NET, mi idea de una «API lista para producción» era que compilara y los endpoints respondieran correctamente. Con el tiempo aprendí que hay una diferencia enorme entre una API que funciona en tu máquina y una que sobrevive el mundo real.

    Esta es la primera parte de una serie de tres artículos donde te cuento los 11 componentes que hoy considero esenciales en cualquier Web API con .NET 10. Empezamos por los fundamentos: los cuatro que deberías tener desde el primer día, sin importar el tamaño del proyecto.

    1. Health Checks: que el orquestador sepa si tu API está viva

    La primera vez que deployé en Kubernetes sin health checks fue un desastre silencioso. El pod figuraba como «Running», pero la base de datos no conectaba y las requests simplemente fallaban sin que el orquestador se enterara.

    Los Health Checks exponen endpoints que le dicen a Kubernetes (o a cualquier balanceador de carga) dos cosas fundamentales:

    • Liveness: ¿el proceso sigue vivo?
    • Readiness: ¿está listo para recibir tráfico? (sus dependencias —base de datos, Redis, etc.— están funcionando)
    // Program.cs
    builder.Services.AddHealthChecks()
        .AddNpgSql(builder.Configuration.GetConnectionString("Default")); // Verifica PostgreSQL
    
    var app = builder.Build();
    
    app.MapHealthChecks("/health");          // Endpoint general
    app.MapHealthChecks("/health/ready",    // Solo readiness
        new HealthCheckOptions { Predicate = check => check.Tags.Contains("ready") });
    app.MapHealthChecks("/health/live",     // Solo liveness
        new HealthCheckOptions { Predicate = _ => false });

    Con el paquete AspNetCore.HealthChecks.* podés agregar chequeos para SQL Server, Redis, servicios HTTP externos y más. Una línea por dependencia crítica.

    2. Exception Handling Global con IExceptionHandler

    Nada peor que una API que le devuelve un stack trace de C# al cliente, o un error 500 genérico sin información útil. Durante mucho tiempo usé try/catch en cada endpoint, hasta que descubrí IExceptionHandler.

    Con esta interfaz centralizás el manejo de errores en un solo lugar y respondés siempre con el formato estándar ProblemDetails (RFC 7807), que es lo que los clientes modernos esperan.

    public class GlobalExceptionHandler : IExceptionHandler
    {
        private readonly ILogger<GlobalExceptionHandler> _logger;
    
        public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
        {
            _logger = logger;
        }
    
        public async ValueTask<bool> TryHandleAsync(
            HttpContext context, Exception exception, CancellationToken cancellationToken)
        {
            _logger.LogError(exception, "Error no controlado: {Message}", exception.Message);
    
            var problemDetails = new ProblemDetails
            {
                Status = StatusCodes.Status500InternalServerError,
                Title = "Ocurrió un error inesperado",
                Detail = exception.Message
            };
    
            context.Response.StatusCode = problemDetails.Status.Value;
            await context.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
            return true; // Excepción manejada, detiene la cadena
        }
    }
    
    // Program.cs
    builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
    builder.Services.AddProblemDetails();
    app.UseExceptionHandler();

    Podés crear múltiples handlers y registrarlos en orden: el primero que devuelva true corta la cadena. Útil para manejar ValidationException, NotFoundException, etc., con respuestas específicas.

    3. Validación Nativa con Endpoint Filters

    Validar el payload antes de que llegue a la lógica de negocio es crítico. En .NET 10, las Minimal APIs tienen Endpoint Filters que actúan como un pipeline de validación declarativo, sin necesidad de librerías externas para los casos más comunes.

    app.MapPost("/api/users", (UserDto user) =>
    {
        return Results.Created($"/api/users/{user.Id}", user);
    })
    .AddEndpointFilter(async (context, next) =>
    {
        var user = context.GetArgument<UserDto>(0);
    
        var errors = new Dictionary<string, string[]>();
    
        if (string.IsNullOrEmpty(user.Name))
            errors["Name"] = new[] { "El nombre es obligatorio" };
    
        if (user.Age < 0 || user.Age > 120)
            errors["Age"] = new[] { "La edad debe estar entre 0 y 120" };
    
        if (errors.Any())
            return Results.ValidationProblem(errors);
    
        return await next(context);
    });

    Para proyectos más grandes donde necesitás validaciones complejas, FluentValidation sigue siendo una excelente opción que se integra perfectamente con este patrón.

    4. OpenAPI Integrado: adiós Swashbuckle

    Durante años, generar documentación Swagger significaba instalar Swashbuckle y rezar para que no conflictuara con otras dependencias. En .NET 10, la generación de especificación OpenAPI es nativa, sin dependencias externas, con mejor performance de arranque y mantenimiento garantizado por Microsoft.

    builder.Services.AddOpenApi(); // Generación nativa
    
    var app = builder.Build();
    
    if (app.Environment.IsDevelopment())
    {
        app.MapOpenApi(); // Expone la spec en /openapi/v1.json
    
        // Opción A: Scalar (UI moderna, recomendada)
        app.MapScalarApiReference();
    
        // Opción B: Swagger UI clásico
        // app.UseSwaggerUI(c => c.SwaggerEndpoint("/openapi/v1.json", "API v1"));
    }

    Para la UI de documentación, te recomiendo probar Scalar: tiene mucho mejor UX que Swagger UI clásico y es trivial de integrar.

    Resumen de la Parte 1

    Estos cuatro componentes son los que agrego en cualquier proyecto nuevo desde el día uno:

    • Health Checks → para que Kubernetes y el balanceador sepan el estado real de tu API
    • Exception Handler global → respuestas de error consistentes y sin leakear internos
    • Validación con Endpoint Filters → datos limpios antes de tocar la lógica de negocio
    • OpenAPI nativo → documentación siempre actualizada sin overhead

    En la Parte 2 vemos los tres componentes que marcan la diferencia cuando la API empieza a escalar: Rate Limiting, Output Cache y API Versioning.

  • Guía práctica: cómo migré mis proyectos de .NET 8 a .NET 10 sin romper producción

    Migrar un proyecto de .NET 8 a .NET 10 es el tipo de tarea que parece riesgosa pero en la práctica es incremental y reversible. Lo hice en varios proyectos de producción sin downtime. El proceso siempre es el mismo: actualizar el TFM, correr los tests, adoptar las nuevas features de a poco.

    Paso 1: Actualizar el Target Framework

    # Verificar SDK instalado
    dotnet --list-sdks
    
    # Instalar .NET 10 SDK si no está
    # Linux/WSL:
    wget https://dot.net/v1/dotnet-install.sh -O dotnet-install.sh
    bash dotnet-install.sh --channel 10.0
    
    # En cada .csproj de la solución:
    # <TargetFramework>net10.0</TargetFramework>
    
    # Para actualizar todos los .csproj de una solución en Linux:
    find . -name "*.csproj" -exec sed -i 's/net8.0/net10.0/g; s/net9.0/net10.0/g' {} \;
    
    # Restaurar y compilar
    dotnet restore
    dotnet build

    Paso 2: Actualizar paquetes NuGet

    # Ver paquetes desactualizados
    dotnet list package --outdated
    
    # Actualizar todos los paquetes de Microsoft.* a versiones compatibles con .NET 10
    dotnet add package Microsoft.AspNetCore.OpenApi
    dotnet add package Microsoft.Extensions.Http.Resilience
    
    # Paquetes que pueden desinstalarse en .NET 10:
    # - Swashbuckle.AspNetCore (reemplazado por OpenAPI nativo)
    # - Microsoft.AspNetCore.Mvc.NewtonsoftJson (System.Text.Json mejorado)
    
    # Remover Swashbuckle si usabas OpenAPI:
    dotnet remove package Swashbuckle.AspNetCore

    Paso 3: Adoptar las nuevas features de C# 14

    No hay que cambiar nada para que el proyecto compile. Las nuevas features son opt-in. La estrategia es adoptarlas gradualmente donde aporten más valor.

    // Prioridad 1: field — máximo impacto con mínimo riesgo
    // Buscar propiedades con backing fields privados y migrarlas
    
    // Antes:
    private string _nombre = string.Empty;
    public string Nombre
    {
        get => _nombre;
        set => _nombre = value?.Trim() ?? throw new ArgumentNullException();
    }
    
    // Después:
    public string Nombre
    {
        get => field;
        set => field = value?.Trim() ?? throw new ArgumentNullException();
    }
    
    // Prioridad 2: Primary Constructors en servicios con DI
    // Especialmente útil en proyectos con muchos servicios inyectados
    
    // Prioridad 3: params ReadOnlySpan en métodos variádicos de uso frecuente
    // Buscar: params int[], params string[], params object[]
    // Reemplazar por: params ReadOnlySpan<T> donde sea posible
    
    // Prioridad 4: Lock para thread safety
    // Buscar: private readonly object _lockObj = new object();
    // Reemplazar por: private readonly Lock _lockObj = new();

    Paso 4: Migrar a OpenAPI nativo (si usabas Swashbuckle)

    // Program.cs — reemplazar Swashbuckle por OpenAPI nativo
    
    // ❌ Antes con Swashbuckle:
    // builder.Services.AddSwaggerGen(c => { c.SwaggerDoc("v1", ...); });
    // app.UseSwagger();
    // app.UseSwaggerUI();
    
    // ✅ .NET 9/10 nativo:
    builder.Services.AddOpenApi(options =>
    {
        options.AddDocumentTransformer((document, context, ct) =>
        {
            document.Info = new()
            {
                Title = "Mi API",
                Version = "v1",
                Description = "API de gestión interna"
            };
            return Task.CompletedTask;
        });
    });
    
    // En el pipeline:
    if (app.Environment.IsDevelopment())
    {
        app.MapOpenApi();
        // Opcional: seguir usando Swagger UI solo para dev
        app.UseSwaggerUI(options =>
            options.SwaggerEndpoint("/openapi/v1.json", "Mi API v1"));
    }

    Checklist de migración completo

    PasoAcciónImpacto
    1Cambiar TFM a net10.0 en todos los .csprojObligatorio
    2dotnet restore && dotnet buildObligatorio
    3Ejecutar suite de tests completaObligatorio
    4Actualizar paquetes Microsoft.* a versión 10.xRecomendado
    5Adoptar field en propiedades con backing fieldGradual
    6Primary Constructors en servicios con DIGradual
    7Reemplazar object _lock por Lock _lockGradual
    8Migrar OpenAPI de Swashbuckle a nativoOpcional
    9Adoptar params ReadOnlySpan<T> en hot pathsOpcional
    10Evaluar Native AOT para Workers/APIs simplesOpcional avanzado

    Errores comunes y cómo resolverlos

    // ERROR 1: Paquete no compatible con net10.0
    // "Package X is not compatible with net10.0"
    // Solución: actualizar a la versión más reciente del paquete
    // Si no hay versión compatible, usar <TargetFrameworks>net8.0;net10.0</TargetFrameworks>
    
    // ERROR 2: Reflection en Native AOT
    // "System.InvalidOperationException: Type ... is not reflection-enabled"
    // Solución: agregar source generator o usar [DynamicDependency]
    [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(MiClase))]
    static void MetodoConReflection() { ... }
    
    // ERROR 3: field en C# 14 — feature no disponible
    // "error CS8652: The feature 'field keyword' is currently in Preview"
    // Solución: habilitar preview features en .csproj
    // <LangVersion>preview</LangVersion>
    // (o esperar a que salga de preview en la versión final de .NET 10)
    
    // ERROR 4: Cambios breaking en serialización JSON
    // System.Text.Json en .NET 10 es más estricto con algunos tipos
    // Solución: revisar JsonSerializerOptions y ajustar si es necesario

    Mi experiencia: cuánto tardó cada proyecto

    • API Minimal con 15 endpoints: 30 minutos (cambiar TFM, actualizar paquetes, correr tests)
    • Worker Service con threading complejo: 2 horas (migración de locks + tests de concurrencia)
    • Aplicación ASP.NET MVC grande (80 controllers): medio día (TFM + paquetes + resolver warnings)
    • Adopción gradual de C# 14 features: 2-3 semanas en paralelo con desarrollo normal

    La migración del TFM en sí es rápida. Lo que toma tiempo es la adopción de las nuevas features, y eso es completamente optativo. Podés migrar a .NET 10 hoy y adoptar field o Primary Constructors a tu ritmo, archivo por archivo, cuando tocás esa parte del código.


    ← dotnet run script.cs y las nuevas herramientas de .NET 10: OpenAPI, HttpClient y más | Fin de la Serie .NET 8 → .NET 10

  • Rendimiento extremo en .NET 10: Stack allocation, Native AOT y el GC que trabaja menos

    El rendimiento de .NET siempre fue bueno. Pero en los últimos dos años, con .NET 9 y .NET 10, el equipo fue más allá: Stack allocation inteligente para objetos pequeños, Native AOT que genera binarios sin runtime, y un GC que hace menos trabajo porque el runtime hace más con menos memoria. En APIs que proceso millones de requests por día, estos cambios se notan.

    Stack vs Heap: la distinción que importa para el rendimiento

    El Garbage Collector de .NET es excelente, pero tiene un costo: cada objeto en el heap eventual­mente debe ser recolectado. La forma más eficiente de evitar ese costo es no poner cosas en el heap en primer lugar. .NET 10 hace esto automáticamente en más situaciones.

    // .NET 10: el runtime puede asignar arrays pequeños en el stack automáticamente
    // cuando el compilador puede probar que no "escapan" del scope
    
    // Caso 1: array de tamaño conocido en compilación que no escapa
    void ProcesarLote()
    {
        // .NET 10 puede convertir esto en stack allocation transparentemente
        int[] buffer = new int[8];  // pequeño, scope limitado
        for (int i = 0; i < buffer.Length; i++)
            buffer[i] = i * 2;
        // buffer no escapa a este método → candidato a stack allocation
    }
    
    // Caso 2: stackalloc explícito para control total
    void ProcesarDatos(ReadOnlySpan<byte> datos)
    {
        // Siempre en el stack, sin GC pressure
        Span<byte> buffer = stackalloc byte[256];
        datos.CopyTo(buffer);
        // Procesamiento...
    }
    
    // Caso 3: ArrayPool para buffers grandes que SÍ van al heap
    // (cuando stackalloc no alcanza)
    var pool = ArrayPool<byte>.Shared;
    byte[] rentado = pool.Rent(4096);
    try
    {
        // usar rentado...
    }
    finally
    {
        pool.Return(rentado);  // devolver al pool en lugar de GC
    }

    Native AOT: binarios sin runtime instalado

    Native AOT (Ahead-Of-Time compilation) compila tu aplicación .NET a código nativo — un ejecutable que no requiere el runtime de .NET instalado. En .NET 10, el soporte mejoró significativamente: más APIs compatibles, binarios más pequeños y arranque más rápido.

    # Publicar con Native AOT
    # En el .csproj:
    <PropertyGroup>
      <PublishAot>true</PublishAot>
      <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
    </PropertyGroup>
    
    # Publicar para Linux x64
    dotnet publish -r linux-x64 -c Release
    
    # Resultado típico en .NET 10:
    # Tamaño del binario: ~8-12 MB (vs ~200 MB con runtime incluido)
    # Tiempo de arranque: ~5ms (vs ~100-300ms con JIT)
    # Memoria RSS inicial: ~15 MB (vs ~50 MB con JIT)

    Las limitaciones de Native AOT siguen existiendo — no soporta reflection dinámica, ciertas operaciones de serialización requieren source generators — pero en .NET 10 el porcentaje de código compatible aumentó considerablemente. Mis Minimal APIs y Workers services migran sin problemas.

    Mejoras del GC en .NET 10

    // Configurar el GC para workloads específicos
    // En appsettings.json o como variables de entorno:
    
    // Para APIs de alta frecuencia (latencia baja):
    // DOTNET_GCConserveMemory=0 (default — más GC, menos memoria)
    
    // Para servicios de larga duración (memoria controlada):
    // DOTNET_GCConserveMemory=9 (agresivo en liberar memoria)
    
    // Programmaticamente:
    GCSettings.LatencyMode = GCLatencyMode.SustainedLowLatency; // Para APIs
    
    // .NET 10: Dynamic Heap Regions
    // El GC puede ajustar el tamaño de las regiones del heap en tiempo de ejecución
    // según los patrones de uso observados — sin configuración manual

    Tensor Primitives: operaciones SIMD de alto nivel

    Introducido en .NET 9 y expandido en .NET 10, System.Numerics.TensorPrimitives expone operaciones vectorizadas (SIMD) con una API de alto nivel. Útil para procesamiento de señales, ML, operaciones financieras en bulk.

    using System.Numerics.Tensors;
    
    float[] precios = [100f, 200f, 150f, 300f, 250f];
    float[] descuentos = [0.1f, 0.2f, 0.15f, 0.05f, 0.25f];
    float[] preciosFinales = new float[precios.Length];
    
    // Multiplicación vectorizada — usa SIMD automáticamente
    TensorPrimitives.Multiply(precios, descuentos, preciosFinales);
    
    // Suma de todos los elementos — también vectorizada
    float sumaTotal = TensorPrimitives.Sum(precios);
    
    // Operaciones estadísticas
    float maximo = TensorPrimitives.Max(precios);
    float minimo = TensorPrimitives.Min(precios);

    Números reales de mis APIs

    Métrica.NET 8.NET 10Mejora
    Memoria RSS (pod idle)~85 MB~65 MB~24% menos
    P99 latencia (API simple)~12 ms~9 ms~25% menos
    Throughput (req/s)~45.000~58.000~29% más
    Tiempo arranque cold~280 ms~200 ms~28% menos

    Estos números son de una API de gestión interna con carga moderada. En workloads más intensivos los delta son mayores. En workloads livianos, menores. Pero la dirección es siempre la misma: .NET 10 hace más con menos.


    ← LINQ en .NET 9 y .NET 10: CountBy, AggregateBy, Index() y las mejoras que cambian cómo consultás datos | Serie .NET 8 → .NET 10 | Próximo: dotnet run script.cs y las nuevas herramientas de .NET 10: OpenAPI, HttpClient y más →

  • LINQ en .NET 9 y .NET 10: CountBy, AggregateBy, Index() y las mejoras que cambian cómo consultás datos

    LINQ es una de las features de C# que más uso cada día. Y con .NET 9 y .NET 10 recibió adiciones concretas que resuelven patrones que antes requerían GroupBy + Select + ToDictionary en cadena. CountBy, AggregateBy e Index son pequeños pero cambian bastante el código de consulta.

    Index(): el iterador con índice que debería haber existido siempre

    Cuántas veces escribiste .Select((item, i) => (i, item)) solo para tener el índice en un foreach? Index() hace exactamente eso con una API limpia.

    var productos = new[] { "Monitor", "Teclado", "Mouse", "Auriculares" };
    
    // ❌ Antes: Select con tupla o variable externa
    int i = 0;
    foreach (var p in productos)
        Console.WriteLine($"{i++}. {p}");
    
    // O la versión LINQ verbose:
    foreach (var (indice, producto) in productos.Select((p, i) => (i, p)))
        Console.WriteLine($"{indice}. {producto}");
    
    // ✅ .NET 9: Index() — limpio y declarativo
    foreach (var (indice, producto) in productos.Index())
        Console.WriteLine($"{indice}. {producto}");
    
    // También útil en proyecciones
    var conIndice = productos.Index()
        .Where(x => x.Index % 2 == 0)   // solo los pares
        .Select(x => $"[{x.Index}] {x.Item}");
    

    CountBy(): contar por clave sin GroupBy

    var pedidos = new[]
    {
        new { Cliente = "Ana",   Estado = "Pagado",    Monto = 100m },
        new { Cliente = "Juan",  Estado = "Pendiente", Monto = 200m },
        new { Cliente = "Ana",   Estado = "Pagado",    Monto = 150m },
        new { Cliente = "Pedro", Estado = "Pendiente", Monto = 300m },
        new { Cliente = "Juan",  Estado = "Cancelado", Monto = 50m  },
    };
    
    // ❌ Antes: GroupBy + Count — verboso
    var conteoAntes = pedidos
        .GroupBy(p => p.Cliente)
        .Select(g => new { Cliente = g.Key, Cantidad = g.Count() })
        .ToDictionary(x => x.Cliente, x => x.Cantidad);
    
    // ✅ .NET 9: CountBy — directo al punto
    var conteoPorCliente = pedidos.CountBy(p => p.Cliente);
    // { "Ana": 2, "Juan": 2, "Pedro": 1 }
    
    // CountBy devuelve IEnumerable<KeyValuePair<TKey, int>>
    foreach (var (cliente, cantidad) in conteoPorCliente)
        Console.WriteLine($"{cliente}: {cantidad} pedidos");
    
    // Contar por múltiples criterios con tipo anónimo
    var conteoPorEstado = pedidos.CountBy(p => p.Estado);
    // { "Pagado": 2, "Pendiente": 2, "Cancelado": 1 }

    AggregateBy(): acumulación agrupada sin GroupBy

    // ❌ Antes: GroupBy + Sum — varios pasos
    var totalesAntes = pedidos
        .GroupBy(p => p.Cliente)
        .ToDictionary(g => g.Key, g => g.Sum(p => p.Monto));
    
    // ✅ .NET 9: AggregateBy — acumulación directa
    var totalPorCliente = pedidos.AggregateBy(
        keySelector: p => p.Cliente,
        seed: 0m,
        func: (acumulado, pedido) => acumulado + pedido.Monto
    );
    // { "Ana": 250, "Juan": 250, "Pedro": 300 }
    
    // Caso más complejo: acumular un objeto personalizado
    var resumenPorCliente = pedidos.AggregateBy(
        keySelector: p => p.Cliente,
        seed: (Cantidad: 0, Total: 0m, UltimoPedido: ""),
        func: (acc, p) => (
            Cantidad: acc.Cantidad + 1,
            Total: acc.Total + p.Monto,
            UltimoPedido: p.Estado
        )
    );
    
    foreach (var (cliente, resumen) in resumenPorCliente)
        Console.WriteLine($"{cliente}: {resumen.Cantidad} pedidos, ${resumen.Total} total");

    Mejoras de rendimiento en LINQ (.NET 9 y .NET 10)

    Más allá de las nuevas APIs, .NET 9 y .NET 10 mejoran el rendimiento interno de operaciones LINQ existentes. Algunas que noté en benchmarks reales:

    • Order() / OrderDescending(): versiones sin selector — más rápidas que OrderBy(x => x)
    • GroupBy con span: reducción de allocations en agrupaciones de tipos por valor
    • Where + Select fusionados: el JIT puede combinar estas operaciones en algunos casos
    • ToFrozenDictionary() / ToFrozenSet(): colecciones inmutables optimizadas para lectura frecuente
    // ToFrozenDictionary: para lookups frecuentes de datos que no cambian
    // Más rápido que Dictionary en lecturas; más lento en construcción
    using System.Collections.Frozen;
    
    var codigosPais = new Dictionary<string, string>
    {
        { "AR", "Argentina" }, { "BR", "Brasil" }, { "CL", "Chile" }
    };
    
    // Convertir a FrozenDictionary — operación costosa una sola vez
    FrozenDictionary<string, string> lookup = codigosPais.ToFrozenDictionary();
    
    // Lookups subsiguientes son más rápidos que Dictionary estándar
    string pais = lookup["AR"];  // Acceso optimizado

    Mi cheat sheet de LINQ moderno

    NecesidadAntesAhora (.NET 9+)
    Iterar con índice.Select((x,i)=>(i,x)).Index()
    Contar por clave.GroupBy(k).Select(count).CountBy(k)
    Acumular por clave.GroupBy(k).ToDictionary(sum).AggregateBy(k, seed, func)
    Ordenar elementos.OrderBy(x => x).Order()
    Lookup inmutablenew Dictionary<>().ToFrozenDictionary()

    ← Extension Members en C# 14/15: propiedades y miembros estáticos de extensión como nunca antes | Serie .NET 8 → .NET 10 | Próximo: Rendimiento extremo en .NET 10: Stack allocation, Native AOT y el GC que trabaja menos →

  • La palabra clave field en C# 14: adiós para siempre a los backing fields repetitivos

    Hay un patrón que repito en cada clase de dominio: propiedad con validación en el setter, campo privado de respaldo, tres líneas de código para lo que debería ser una. Con la palabra clave field en C# 14, eso terminó. El compilador genera el backing field y yo solo escribo la lógica.

    El problema que resuelve

    En C# siempre hubo una tensión: las auto-properties son concisas pero no permiten lógica en get/set. Si necesitás validar, transformar o notificar en un setter, tenés que sacrificar la sintaxis limpia y declarar un campo privado manualmente.

    // ❌ Antes de C# 14: el campo de respaldo manual es inevitable
    public class Usuario
    {
        private string _nombre = string.Empty;  // campo privado repetitivo
        private int _edad;
        private string _email = string.Empty;
    
        public string Nombre
        {
            get => _nombre;
            set => _nombre = value?.Trim() ?? throw new ArgumentNullException(nameof(value));
        }
    
        public int Edad
        {
            get => _edad;
            set => _edad = value is >= 0 and <= 150
                ? value
                : throw new ArgumentOutOfRangeException(nameof(value));
        }
    
        public string Email
        {
            get => _email;
            set => _email = value?.ToLowerInvariant() ?? throw new ArgumentNullException(nameof(value));
        }
    }
    
    // ✅ C# 14: field — el compilador genera el backing field
    public class Usuario
    {
        public string Nombre
        {
            get => field;
            set => field = value?.Trim() ?? throw new ArgumentNullException(nameof(value));
        }
    
        public int Edad
        {
            get => field;
            set => field = value is >= 0 and <= 150
                ? value
                : throw new ArgumentOutOfRangeException(nameof(value));
        }
    
        public string Email
        {
            get => field;
            set => field = value?.ToLowerInvariant() ?? throw new ArgumentNullException(nameof(value));
        }
    }

    Casos de uso reales

    Valor con lazy initialization

    // Antes: campo privado para lazy init
    public class Configuracion
    {
        private string? _cadenaConexion;
    
        public string CadenaConexion
        {
            get => _cadenaConexion ??= CargarDesdeConfiguracion();
        }
    }
    
    // C# 14: mucho más limpio
    public class Configuracion
    {
        public string CadenaConexion
        {
            get => field ??= CargarDesdeConfiguracion();
        }
    }

    INotifyPropertyChanged sin campos privados

    // Muy útil en ViewModels de WPF/MAUI/Blazor
    public class ProductoViewModel : INotifyPropertyChanged
    {
        public event PropertyChangedEventHandler? PropertyChanged;
    
        public string Nombre
        {
            get => field;
            set
            {
                if (field == value) return;
                field = value;
                PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Nombre)));
            }
        }
    
        public decimal Precio
        {
            get => field;
            set
            {
                if (field == value) return;
                field = value > 0 ? value : throw new ArgumentException("Precio debe ser positivo");
                PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Precio)));
            }
        }
    }

    Propiedades de solo init con validación

    // init-only properties con validación — imposible antes sin campo privado
    public class Pedido
    {
        public required string NumeroPedido
        {
            get => field;
            init => field = string.IsNullOrWhiteSpace(value)
                ? throw new ArgumentException("Número de pedido no puede estar vacío")
                : value.ToUpperInvariant();
        }
    
        public DateTime FechaCreacion
        {
            get => field;
            init => field = value == default ? DateTime.UtcNow : value;
        }
    }
    
    // Uso con object initializer — sin cambios en el lado del consumidor
    var pedido = new Pedido { NumeroPedido = "ord-123" };
    Console.WriteLine(pedido.NumeroPedido); // "ORD-123" (uppercase automático)

    field es contextual: no rompe código existente

    field es una palabra clave contextual: solo tiene el significado especial dentro de un getter o setter de propiedad. Si tenés una variable local, parámetro o campo llamado field en otro contexto, no hay conflicto. El compilador sabe por el contexto qué significa.

    // field fuera de una propiedad: variable normal
    var field = "esto es una variable";
    Console.WriteLine(field);  // sin problema
    
    // field en parámetro de método: sin conflicto
    void Procesar(string field) => Console.WriteLine(field);
    
    // Solo dentro de get/set de una propiedad tiene el significado especial
    public string Nombre { get => field; set => field = value; }

    El resultado: código más limpio, misma semántica

    En un modelo de dominio con 12 entidades, la adopción de field eliminó 47 campos privados de respaldo. El código resultante es más fácil de leer, más fácil de mantener y genera exactamente el mismo IL que la versión con campos explícitos. Zero costo en runtime, máximo beneficio en legibilidad.


    ← C# 13: params modernos, Lock de primera clase y Task.WhenEach | Serie .NET 8 → .NET 10 | Próximo: Extension Members en C# 14/15: propiedades y miembros estáticos de extensión como nunca antes →

  • C# 13: params modernos, Lock de primera clase y Task.WhenEach

    C# 13 con .NET 9 llegó con mejoras que parecen pequeñas pero cambian patrones que usamos hace años. Los params con Span eliminan allocations que ni sabíamos que estábamos haciendo. El Lock de primera clase hace que el threading sea más seguro. Y Task.WhenEach resuelve un patrón asíncrono que antes requería código contorsionado.

    params ReadOnlySpan<T>: cero allocations en métodos variádicos

    Antes de C# 13, params solo funcionaba con arrays. Cada vez que llamabas a un método con params int[], el compilador creaba un array en el heap aunque los datos pudieran vivir perfectamente en el stack. C# 13 cambia eso.

    // ❌ C# 12 y anteriores: params siempre crea un array en el heap
    public static int Sumar(params int[] numeros)
    {
        int total = 0;
        foreach (var n in numeros) total += n;
        return total;
    }
    
    // Cada llamada alloca un int[] en el heap:
    int r1 = Sumar(1, 2, 3);      // new int[] { 1, 2, 3 }
    int r2 = Sumar(10, 20);        // new int[] { 10, 20 }
    
    // ✅ C# 13: params con ReadOnlySpan — cero allocations
    public static int Sumar(params ReadOnlySpan<int> numeros)
    {
        int total = 0;
        foreach (var n in numeros) total += n;
        return total;
    }
    
    // El compilador puede poner los valores directamente en el stack
    int r1 = Sumar(1, 2, 3);   // Sin heap allocation
    int r2 = Sumar(10, 20);     // Sin heap allocation
    
    // También funciona con otros tipos de colección
    public static void LogTodos(params IEnumerable<string> mensajes) { ... }
    public static T Primero<T>(params ReadOnlySpan<T> items) => items[0];

    System.Threading.Lock: el tipo de primera clase para sincronización

    Durante años, el patrón estándar para sincronizar hilos en C# era lock (new object()). Funcionaba, pero tenía un problema: object no comunica la intención. Cualquier referencia podía usarse accidentalmente como lock. System.Threading.Lock resuelve eso con un tipo dedicado que el compilador trata de forma especial.

    // ❌ Antes: object genérico como lock
    public class Cache
    {
        private readonly object _lock = new object();
        private Dictionary<string, object> _datos = new();
    
        public void Agregar(string clave, object valor)
        {
            lock (_lock)
            {
                _datos[clave] = valor;
            }
        }
    
        public bool TryGet(string clave, out object? valor)
        {
            lock (_lock)
            {
                return _datos.TryGetValue(clave, out valor);
            }
        }
    }
    
    // ✅ C# 13 / .NET 9: System.Threading.Lock
    public class Cache
    {
        // Semánticamente claro: esto ES un lock, no cualquier objeto
        private readonly Lock _lock = new();
        private Dictionary<string, object> _datos = new();
    
        public void Agregar(string clave, object valor)
        {
            // El compilador emite código optimizado para Lock
            // Usa el patrón Dispose internamente (EnterScope)
            lock (_lock)
            {
                _datos[clave] = valor;
            }
        }
    
        // También disponible: API explícita para casos avanzados
        public bool TryAgregar(string clave, object valor)
        {
            using (_lock.EnterScope())
            {
                if (_datos.ContainsKey(clave)) return false;
                _datos[clave] = valor;
                return true;
            }
        }
    }

    El compilador emite una advertencia si intentás pasar un Lock como object a otro método — protección contra el antipatrón de usar el mismo lock para múltiples propósitos.

    Task.WhenEach: procesar tareas a medida que terminan

    Tenía un patrón que repetía en muchos proyectos: lanzar varias tareas en paralelo y procesar cada resultado apenas llegaba, sin esperar a que todas terminen. Antes requería Task.WhenAny en un loop con manejo manual. Task.WhenEach lo vuelve trivial.

    // ❌ Antes: patrón verbose con Task.WhenAny
    var tareas = new List<Task<Reporte>> { 
        ObtenerReporteAsync("ventas"), 
        ObtenerReporteAsync("stock"), 
        ObtenerReporteAsync("clientes") 
    };
    
    var pendientes = new HashSet<Task<Reporte>>(tareas);
    while (pendientes.Count > 0)
    {
        var completada = await Task.WhenAny(pendientes);
        pendientes.Remove(completada);
        var reporte = await completada;
        Console.WriteLine($"Reporte listo: {reporte.Nombre}");
    }
    
    // ✅ C# 13 / .NET 9: Task.WhenEach — elegante y eficiente
    var tareas = new List<Task<Reporte>> { 
        ObtenerReporteAsync("ventas"), 
        ObtenerReporteAsync("stock"), 
        ObtenerReporteAsync("clientes") 
    };
    
    await foreach (var completada in Task.WhenEach(tareas))
    {
        var reporte = await completada;
        Console.WriteLine($"Reporte listo: {reporte.Nombre}");
        // Procesa cada reporte apenas llega, sin bloquear a los demás
    }

    LINQ: CountBy, AggregateBy e Index

    var pedidos = new[] {
        new { Cliente = "Ana",  Monto = 100m },
        new { Cliente = "Juan", Monto = 200m },
        new { Cliente = "Ana",  Monto = 150m },
        new { Cliente = "Juan", Monto = 300m },
    };
    
    // CountBy: contar elementos agrupados por clave
    var pedidosPorCliente = pedidos.CountBy(p => p.Cliente);
    // { "Ana": 2, "Juan": 2 }
    
    // AggregateBy: acumular valores agrupados por clave
    var totalPorCliente = pedidos.AggregateBy(
        p => p.Cliente,
        seed: 0m,
        (acum, p) => acum + p.Monto
    );
    // { "Ana": 250, "Juan": 500 }
    
    // Index(): obtener índice sin usar Select((x, i) => ...)
    foreach (var (indice, pedido) in pedidos.Index())
    {
        Console.WriteLine($"Pedido #{indice}: {pedido.Cliente} - ${pedido.Monto}");
    }

    Nuevos miembros partial (C# 13)

    // C# 13: propiedades y constructores pueden ser partial
    // Muy útil con source generators (EF Core, Roslyn generators)
    
    public partial class Entidad
    {
        // Declaración en el archivo principal
        public partial string Nombre { get; set; }
        public partial void OnNombreCambiado();
    }
    
    public partial class Entidad
    {
        // Implementación en el archivo generado (o viceversa)
        public partial string Nombre
        {
            get => field;
            set { field = value; OnNombreCambiado(); }
        }
        
        public partial void OnNombreCambiado() 
            => Console.WriteLine($"Nombre cambió a: {Nombre}");
    }

    ← C# 12: Primary Constructors, Collection Expressions y el código que debería haber existido siempre | Serie .NET 8 → .NET 10 | Próximo: La palabra clave field en C# 14: adiós para siempre a los backing fields repetitivos →

  • C# 12: Primary Constructors, Collection Expressions y el código que debería haber existido siempre

    C# 12 llegó con .NET 8 y fue la versión que más cambió mi forma de escribir código en el día a día. Los Primary Constructors me sacaron dos tercios del boilerplate de mis servicios. Las Collection Expressions me hicieron olvidar cuándo usar new List<> vs new[] vs Array.Empty(). Repaso todo lo que realmente uso.

    Primary Constructors: adiós al boilerplate de inyección de dependencias

    Antes de C# 12, cada servicio que necesitaba inyección de dependencias requería declarar campos privados, un constructor y asignaciones manuales. Con Primary Constructors, todo eso desaparece.

    // ❌ Antes (C# 11 y anteriores): mucho código repetitivo
    public class PedidoService
    {
        private readonly IRepositorio _repo;
        private readonly ILogger<PedidoService> _logger;
        private readonly IEmailService _email;
    
        public PedidoService(
            IRepositorio repo,
            ILogger<PedidoService> logger,
            IEmailService email)
        {
            _repo   = repo;
            _logger = logger;
            _email  = email;
        }
    
        public async Task ProcesarAsync(Pedido pedido)
        {
            _logger.LogInformation("Procesando pedido {Id}", pedido.Id);
            await _repo.GuardarAsync(pedido);
            await _email.NotificarAsync(pedido.ClienteEmail);
        }
    }
    
    // ✅ C# 12: Primary Constructor — el compilador genera el backing field
    public class PedidoService(
        IRepositorio repo,
        ILogger<PedidoService> logger,
        IEmailService email)
    {
        public async Task ProcesarAsync(Pedido pedido)
        {
            logger.LogInformation("Procesando pedido {Id}", pedido.Id);
            await repo.GuardarAsync(pedido);
            await email.NotificarAsync(pedido.ClienteEmail);
        }
    }

    Los parámetros del primary constructor son accesibles en todo el cuerpo de la clase. No son campos públicos — son parámetros capturados. Si necesitás exponerlos como propiedades, lo hacés explícitamente:

    // Primary constructor en record (siempre existió)
    public record Producto(string Nombre, decimal Precio, int Stock);
    
    // Primary constructor en clase con propiedad explícita
    public class Configuracion(string connectionString, int timeout)
    {
        public string ConnectionString { get; } = connectionString;
        public int TimeoutSeconds { get; } = timeout > 0 ? timeout : 30;
    }

    Collection Expressions: una sintaxis para todos los tipos de colección

    Antes de C# 12, la forma de inicializar una colección dependía de su tipo. Un array era new[] { 1, 2, 3 }, una lista era new List<int> { 1, 2, 3 }, un span era algo diferente. C# 12 unifica todo con [].

    // ✅ C# 12: misma sintaxis para todos los tipos
    int[]          array   = [1, 2, 3];
    List<int>      lista   = [4, 5, 6];
    Span<int>      span    = [7, 8, 9];
    IEnumerable<int> seq  = [10, 11, 12];
    ReadOnlySpan<int> ros  = [13, 14, 15];
    
    // Colección vacía — sin ambigüedades
    List<string> vacia = [];   // antes: new List<string>() o Array.Empty<string>()
    
    // Spread operator (..) para combinar colecciones
    int[] primeros = [1, 2, 3];
    int[] segundos = [4, 5, 6];
    int[] todos    = [..primeros, ..segundos, 7, 8];  // [1,2,3,4,5,6,7,8]
    
    // Muy útil en APIs: combinar listas de validación
    var reglas = [..reglasBase, ..reglasEspecificas, nuevaRegla];

    Using aliases para cualquier tipo

    Antes de C# 12, using solo podía crear alias para tipos nombrados. Ahora puede crear alias para cualquier tipo: tuplas, arrays, punteros, tipos genéricos.

    // C# 12: aliases para tipos complejos
    using Coordenadas   = (double Latitud, double Longitud);
    using MatrizInt     = int[][];
    using DiccionarioId = System.Collections.Generic.Dictionary<int, string>;
    using Callback      = System.Action<string, int>;
    
    // Uso en el código — mucho más legible
    Coordenadas ubicacion = (Latitud: -34.6037, Longitud: -58.3816);
    DiccionarioId usuarios = new() { { 1, "Mauro" }, { 2, "Juan" } };

    Inline Arrays: buffers de alto rendimiento sin unsafe

    Los Inline Arrays son structs con un tamaño fijo definido en compilación. Se almacenan completamente en el stack, sin asignaciones en el heap. Útiles para código de sistema o cuando necesitás buffers de tamaño conocido con rendimiento máximo.

    "[System.Runtime.CompilerServices.InlineArray(8)]
    public struct Buffer8<T>
    {
        private T _element; // El compilador genera 8 slots contiguos en memoria
    }
    
    // Uso: se comporta como un array pero sin allocations
    Buffer8<int> buffer;
    buffer[0] = 100;
    buffer[7] = 800;
    // Todo en el stack — el GC no ve nada de esto

    Lambdas con parámetros opcionales y params

    // C# 12: parámetros opcionales en lambdas
    var saludar = (string nombre, string prefijo = "Hola") => $"{prefijo}, {nombre}!";
    Console.WriteLine(saludar("Mauro"));          // "Hola, Mauro!"
    Console.WriteLine(saludar("Mauro", "Buenas")); // "Buenas, Mauro!"
    
    // params en lambdas
    var sumar = (params int[] numeros) => numeros.Sum();
    Console.WriteLine(sumar(1, 2, 3, 4, 5));  // 15

    El impacto en el código real

    En un proyecto de tamaño mediano que migré a C# 12, los Primary Constructors redujeron el código de los servicios en aproximadamente un 30%. No es solo cosmético: menos código es menos superficie de error. Las Collection Expressions eliminaron la pregunta «¿new List<> o Array.Empty<>?» de los code reviews. El spread operator simplificó docenas de patrones de concatenación de listas que antes requerían AddRange o LINQ Concat.


    ← De .NET 8 a .NET 10: qué cambió, qué mejoró y por qué conviene actualizar ya | Serie .NET 8 → .NET 10 | Próximo: C# 13: params modernos, Lock de primera clase y Task.WhenEach →

  • De .NET 8 a .NET 10: qué cambió, qué mejoró y por qué conviene actualizar ya

    Trabajo con .NET desde los tiempos de .NET Framework 4.5. Cada nueva versión traía algo interesante pero también algo que romper. El salto de .NET 8 a .NET 10 es diferente: es una consolidación. Rendimiento extremo, sintaxis más limpia, herramientas que finalmente hacen lo que uno espera. Esta serie cubre todo lo que vale la pena conocer.

    La política de versiones de .NET: qué es LTS y por qué importa

    Microsoft lanza una versión mayor de .NET cada noviembre. Las versiones pares son LTS (Long Term Support) — soporte por 3 años. Las impares son STS (Standard Term Support) — soporte por 18 meses. En producción, siempre apunto a LTS.

    VersiónTipoC#Fin de soporteEstado
    .NET 8LTSC# 12Nov 2026✅ En soporte
    .NET 9STSC# 13May 2026⚠️ Fin pronto
    .NET 10LTSC# 14Nov 2028✅ Actual recomendado

    Lo más importante de cada versión en un vistazo

    .NET 8 / C# 12 (noviembre 2023)

    • Primary constructors en clases y structs
    • Collection expressions: [1, 2, 3] para arrays, listas y spans
    • Spread operator .. para combinar colecciones
    • Inline arrays de alto rendimiento
    • Using aliases para cualquier tipo (incluso tuplas y punteros)
    • Lambdas con parámetros opcionales y params

    .NET 9 / C# 13 (noviembre 2024)

    • params ReadOnlySpan<T>: cero asignaciones en heap para métodos variádicos
    • System.Threading.Lock: tipo de primera clase para exclusión mutua
    • Task.WhenEach: procesar tareas a medida que terminan
    • LINQ: CountBy(), AggregateBy(), Index()
    • Nuevos miembros partial en clases parciales
    • OpenAPI built-in en Minimal APIs

    .NET 10 / C# 14 (noviembre 2025)

    • Palabra clave field: acceso al backing field autogenerado
    • Extension members: propiedades y miembros estáticos de extensión
    • Stack allocation inteligente para arrays pequeños
    • Native AOT mejorado: binarios más pequeños, arranque más rápido
    • dotnet run script.cs: ejecutar archivos sin proyecto
    • Mejoras profundas en el GC

    ¿Por qué actualizar desde .NET 8 a .NET 10 ahora?

    Si estás en .NET 8 LTS, no hay urgencia — tiene soporte hasta noviembre 2026. Pero .NET 10 ofrece mejoras de rendimiento concretas que se traducen en menor uso de memoria, menor latencia y binarios más chicos. En mis APIs en producción, la migración de .NET 8 a .NET 10 redujo el uso de memoria de los pods en Kubernetes entre un 15 y un 25%.

    # Verificar versión instalada
    dotnet --version
    
    # Instalar .NET 10 SDK
    # Linux/WSL:
    wget https://dot.net/v1/dotnet-install.sh
    bash dotnet-install.sh --channel 10.0
    
    # Ver SDKs disponibles
    dotnet --list-sdks
    
    # Cambiar un proyecto a .NET 10
    # En el .csproj:
    # <TargetFramework>net10.0</TargetFramework>

    El contexto: .NET unificado, multiplataforma y de alto rendimiento

    Desde .NET 5, Microsoft unificó el ecosistema: ya no hay .NET Framework, .NET Core y Xamarin como caminos separados. Todo es .NET. Corre en Windows, Linux, macOS, Android, iOS, WASM. Un solo SDK, una sola librería estándar, un solo modelo de deployment.

    Para los que venimos del mundo de .NET Framework y ASP.NET MVC clásico, este es el contexto del que partimos. Para los que arrancaron directamente con .NET Core: la dirección es la misma, pero el ritmo de mejoras se aceleró. Esta serie cubre exactamente qué cambió y cómo aprovecharlo.


    Serie .NET 8 → .NET 10 | Próximo: C# 12: Primary Constructors, Collection Expressions y el código que debería haber existido siempre →

Tags

tsql (27)mssql (26)sql (20)devops (20)dotnet (18)docker (15)performance (14)contenedores (11)dotnet10 (10)linux (9)csharp (8)microservicios (7)angular (7)angular21 (7)sql server (6)issabel (6)docker-compose (6)typescript (6)mysql (5).NET (5)