← maurobernal.com.ar

Etiqueta: dotnet10

  • 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

  • dotnet run script.cs y las nuevas herramientas de .NET 10: OpenAPI, HttpClient y más

    Uno de los cambios de .NET 10 que más me llamó la atención no es del compilador ni del runtime: es de las herramientas. dotnet run script.cs permite ejecutar un archivo C# sin crear un proyecto. Y el soporte OpenAPI nativo en Minimal APIs me hizo desinstalar Swashbuckle de varias soluciones. Repaso todo el tooling nuevo.

    dotnet run script.cs: C# sin proyecto

    Antes de .NET 10, ejecutar un archivo C# requería crear un proyecto, un .csproj, restaurar paquetes… Para un script rápido o una prueba de concepto, era excesivo. Ahora podés ejecutar un archivo directamente.

    // script.cs — archivo suelto, sin .csproj
    using System.Net.Http.Json;
    
    var http = new HttpClient();
    var usuarios = await http.GetFromJsonAsync<List<Usuario>>(
        "https://jsonplaceholder.typicode.com/users"
    );
    
    foreach (var u in usuarios ?? [])
        Console.WriteLine($"{u.Id}: {u.Name} ({u.Email})");
    
    record Usuario(int Id, string Name, string Email);
    # Ejecutar directamente
    dotnet run script.cs
    
    # Con referencias a paquetes NuGet (directivas en el archivo)
    # #:package Newtonsoft.Json@13.0.3
    
    # Con argumentos
    dotnet run script.cs -- arg1 arg2
    
    # Ver el IL generado sin ejecutar
    dotnet run --no-build script.cs

    Uso esto constantemente para probar APIs externas, procesar archivos CSV, o generar datos de prueba. Antes abría LINQPad; ahora uso la terminal directamente.

    OpenAPI built-in en Minimal APIs (.NET 9+)

    Desde .NET 9, la documentación OpenAPI está integrada en el framework. No hace falta Swashbuckle ni NSwag para tener un endpoint /openapi/v1.json funcional.

    // Program.cs — OpenAPI nativo en .NET 9/10
    var builder = WebApplication.CreateBuilder(args);
    
    // Solo agregar esto:
    builder.Services.AddOpenApi();
    
    var app = builder.Build();
    
    // Endpoint para servir el documento OpenAPI
    app.MapOpenApi();  // /openapi/v1.json por defecto
    
    // Opcional: UI de Swagger (sigue siendo un paquete externo)
    // app.UseSwaggerUI(options => options.SwaggerEndpoint("/openapi/v1.json", "Mi API"));
    
    // Minimal API con metadata para OpenAPI
    app.MapGet("/api/pedidos/{id}", async (int id, IRepositorio repo) =>
    {
        var pedido = await repo.ObtenerAsync(id);
        return pedido is null ? Results.NotFound() : Results.Ok(pedido);
    })
    .WithName("ObtenerPedido")
    .WithSummary("Obtiene un pedido por ID")
    .WithDescription("Retorna el pedido completo con sus líneas de detalle")
    .WithTags("Pedidos")
    .Produces<Pedido>(200)
    .Produces(404);
    
    app.Run();

    HttpClient: mejoras en .NET 10

    // .NET 10: HttpClient con Resilience nativo (Microsoft.Extensions.Http.Resilience)
    builder.Services.AddHttpClient<IMiApiClient, MiApiClient>(client =>
    {
        client.BaseAddress = new Uri("https://api.externa.com");
        client.Timeout = TimeSpan.FromSeconds(30);
    })
    .AddStandardResilienceHandler();  // retry + circuit breaker + timeout automáticos
    
    // Configuración personalizada del pipeline de resiliencia
    builder.Services.AddHttpClient<IMiApiClient, MiApiClient>()
        .AddResilienceHandler("mi-pipeline", builder =>
        {
            builder.AddRetry(new HttpRetryStrategyOptions
            {
                MaxRetryAttempts = 3,
                Delay = TimeSpan.FromMilliseconds(500),
                BackoffType = DelayBackoffType.Exponential
            });
            
            builder.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions
            {
                FailureRatio = 0.5,
                SamplingDuration = TimeSpan.FromSeconds(10),
                MinimumThroughput = 5
            });
            
            builder.AddTimeout(TimeSpan.FromSeconds(10));
        });

    Mejoras en Minimal APIs

    // .NET 9/10: TypedResults para respuestas con tipos bien definidos
    app.MapPost("/api/usuarios", async (CrearUsuarioRequest req, IServicio svc) =>
    {
        if (string.IsNullOrEmpty(req.Email))
            return TypedResults.ValidationProblem(new Dictionary<string, string[]>
            {
                ["email"] = ["El email es requerido"]
            });
    
        var usuario = await svc.CrearAsync(req);
        return TypedResults.Created($"/api/usuarios/{usuario.Id}", usuario);
    });
    
    // Route groups para organizar endpoints
    var pedidosGroup = app.MapGroup("/api/pedidos")
        .WithTags("Pedidos")
        .RequireAuthorization();
    
    pedidosGroup.MapGet("/", ObtenerTodos);
    pedidosGroup.MapGet("/{id}", ObtenerPorId);
    pedidosGroup.MapPost("/", Crear);
    pedidosGroup.MapPut("/{id}", Actualizar);
    pedidosGroup.MapDelete("/{id}", Eliminar);

    Blazor en .NET 10: render modes y streaming

    // .NET 10: componentes Blazor con render modes explícitos
    @page "/dashboard"
    @rendermode InteractiveServer
    
    <h1>Dashboard</h1>
    
    @if (datos is null)
    {
        <p>Cargando...</p>
    }
    else
    {
        @foreach (var item in datos)
        {
            <div>@item.Nombre: @item.Valor</div>
        }
    }
    
    @code {
        private List<Dato>? datos;
    
        protected override async Task OnInitializedAsync()
        {
            // Streaming: el componente renderiza mientras carga
            await foreach (var dato in servicio.GetStreamAsync())
            {
                datos ??= [];
                datos.Add(dato);
                StateHasChanged();  // actualiza el DOM incremental
            }
        }
    }

    ← Rendimiento extremo en .NET 10: Stack allocation, Native AOT y el GC que trabaja menos | Serie .NET 8 → .NET 10 | Próximo: Guía práctica: cómo migré mis proyectos de .NET 8 a .NET 10 sin romper producción →

  • 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 →

  • Extension Members en C# 14/15: propiedades y miembros estáticos de extensión como nunca antes

    Los métodos de extensión de C# siempre fueron una de las features que más me gustaron. Pero siempre tuvieron una limitación molesta: solo podías agregar métodos, no propiedades. Si querías que lista.IsEmpty funcionara como propiedad (sin paréntesis), no había forma elegante. C# 14 lo soluciona con los bloques de extensión.

    El límite de los métodos de extensión clásicos

    // ❌ Métodos de extensión clásicos: solo métodos, sintaxis dispersa
    public static class EnumerableExtensions
    {
        // No hay forma de hacer esto una propiedad — requiere paréntesis
        public static bool IsEmpty<T>(this IEnumerable<T> source) 
            => !source.Any();
    
        public static IEnumerable<T> WhereNot<T>(
            this IEnumerable<T> source, 
            Func<T, bool> predicate) 
            => source.Where(x => !predicate(x));
    }
    
    // Uso: requiere paréntesis aunque semánticamente sea una propiedad
    var lista = new List<int>();
    bool vacia = lista.IsEmpty();  // paréntesis obligatorios

    Extension members: la nueva sintaxis de bloques

    // ✅ C# 14: bloques de extensión con propiedades reales
    public extension EnumerableExtensions<T>(IEnumerable<T> source)
    {
        // Propiedad de extensión: acceso sin paréntesis
        public bool IsEmpty => !source.Any();
        
        public int Count => source.Count();
    
        // Método de extensión en el mismo bloque
        public IEnumerable<T> WhereNot(Func<T, bool> predicate) 
            => source.Where(x => !predicate(x));
    
        public IEnumerable<T> Shuffle()
        {
            var lista = source.ToList();
            var rng = Random.Shared;
            for (int i = lista.Count - 1; i > 0; i--)
            {
                int j = rng.Next(i + 1);
                (lista[i], lista[j]) = (lista[j], lista[i]);
            }
            return lista;
        }
    }
    
    // Uso: propiedades sin paréntesis
    var numeros = new List<int>();
    bool vacia = numeros.IsEmpty;   // sin paréntesis
    int cant   = numeros.Count;     // sin paréntesis
    
    var pares = Enumerable.Range(1, 10).WhereNot(n => n % 2 == 0);

    Miembros estáticos de extensión

    Una de las capacidades nuevas más potentes: agregar métodos de fábrica estáticos a tipos que no controlamos.

    // Extender DateTime con métodos de fábrica estáticos
    public extension DateTimeExtensions(DateTime _)
    {
        // Miembro estático: se llama como DateTime.HoyArgentina()
        public static DateTime HoyArgentina()
        {
            var tz = TimeZoneInfo.FindSystemTimeZoneById("Argentina Standard Time");
            return TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, tz);
        }
    
        // Propiedad de instancia: sin paréntesis
        public bool EsFinDeSemana => DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday;
    
        public string FormatoArgentino() => ToString("dd/MM/yyyy HH:mm");
    }
    
    // Uso
    var hoy = DateTime.HoyArgentina();      // método estático
    bool finde = DateTime.Now.EsFinDeSemana; // propiedad de instancia
    string fmt = DateTime.Now.FormatoArgentino(); // método de instancia

    Extensiones para tipos del framework que usamos todos los días

    // Extensiones útiles para string
    public extension StringExtensions(string source)
    {
        public bool IsNullOrEmpty => string.IsNullOrEmpty(source);
        public bool IsNullOrWhiteSpace => string.IsNullOrWhiteSpace(source);
        public string Truncar(int maxLength) 
            => source.Length <= maxLength ? source : source[..maxLength] + "...";
        public string ToCamelCase()
        {
            if (source.IsNullOrEmpty) return source;
            return char.ToLowerInvariant(source[0]) + source[1..];
        }
    }
    
    // Extensiones para HttpClient
    public extension HttpClientExtensions(HttpClient client)
    {
        public async Task<T?> GetJsonAsync<T>(string url)
        {
            var response = await client.GetAsync(url);
            response.EnsureSuccessStatusCode();
            return await response.Content.ReadFromJsonAsync<T>();
        }
    }
    
    // Uso
    string nombre = "MiServicio";
    Console.WriteLine(nombre.ToCamelCase());  // "miServicio"
    Console.WriteLine(nombre.Truncar(5));     // "MiSer..."
    
    var usuario = await httpClient.GetJsonAsync<Usuario>("/api/usuarios/1");

    Diferencias clave con los métodos de extensión clásicos

    CaracterísticaMétodos de extensión (clásico)Extension members (C# 14)
    Métodos de instancia
    Propiedades de instancia
    Miembros estáticos
    Agrupación en bloque❌ (clase estática separada)
    Compatibilidad hacia atrás✅ C# 3+C# 14+ únicamente

    ← La palabra clave field en C# 14: adiós para siempre a los backing fields repetitivos | Serie .NET 8 → .NET 10 | Próximo: LINQ en .NET 9 y .NET 10: CountBy, AggregateBy, Index() y las mejoras que cambian cómo consultás datos →

  • 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 →

  • 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)