---
name: csharp-experto
description: >
  C# moderno con ASP.NET Core, EF Core async, async/await con CancellationToken,
  LINQ avanzado, Minimal APIs, Options pattern y Background Services.
  Cargar cuando se implementen APIs ASP.NET Core, entidades EF Core, migraciones,
  servicios .NET 8+ o cualquier código C# de producción.
version: "1.0.0"
herramientasPermitidas: [Read, Grep]
exclusiones:
  - "No cargar para escribir tests C# (xUnit, NSubstitute, FluentAssertions) — para testing cargar `csharp-testing`."
  - "No cargar para diseñar patrones C# (CQRS, Result<T>, MediatR) — para diseño arquitectónico cargar `csharp-patrones`."
  - "No cargar para errores de compilación C# o .csproj — para build errors cargar `build-errors-csharp`."
  - "No cargar para lógica en F#, VB.NET u otro lenguaje .NET — este skill cubre C# específicamente con ASP.NET Core."
evolvable: true  # default para skill estandar
---
# C# Experto — ASP.NET Core y .NET 8+

## Cuándo NO cargar

- La tarea es escribir tests C# con xUnit, NSubstitute o FluentAssertions — cargar `csharp-testing`.
- La pregunta es sobre diseñar patrones C# (CQRS, Result<T>, MediatR, Unit of Work) — cargar `csharp-patrones`.
- Los errores son de compilación C# o configuración de `.csproj` — cargar `build-errors-csharp`.
- El proyecto usa F#, VB.NET u otro lenguaje .NET — este skill cubre C# con ASP.NET Core específicamente.

## Estructura de proyecto recomendada

```
MiApi/
├── MiApi.csproj
├── Program.cs                   # App factory, DI, middleware pipeline
├── appsettings.json
├── Features/                    # Organizado por feature (Vertical Slice)
│   └── Facturas/
│       ├── FacturasEndpoints.cs
│       ├── FacturasService.cs
│       └── FacturaDto.cs
├── Infrastructure/
│   ├── Persistence/
│   │   ├── AppDbContext.cs
│   │   └── Migrations/
│   └── BackgroundJobs/
└── Domain/
    └── Entities/
```

---

## Minimal APIs vs Controllers

Preferir Minimal APIs para servicios nuevos en .NET 8+. Controllers solo cuando se necesita herencia o filtros de acción complejos.

```csharp
// Program.cs — App factory
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<AppDbContext>(o =>
    o.UseNpgsql(builder.Configuration.GetConnectionString("Default")));
builder.Services.AddScoped<IFacturasService, FacturasService>();
builder.Services.Configure<EmailOptions>(
    builder.Configuration.GetSection("Email"));

var app = builder.Build();

app.MapGroup("/api/v1/facturas")
   .MapFacturasEndpoints()
   .RequireAuthorization();

app.Run();

// FacturasEndpoints.cs
public static class FacturasEndpoints
{
    public static RouteGroupBuilder MapFacturasEndpoints(
        this RouteGroupBuilder group)
    {
        group.MapGet("/", ObtenerTodas);
        group.MapGet("/{id:guid}", ObtenerPorId);   // parametrica DESPUÉS de estática
        group.MapPost("/", Crear);
        return group;
    }

    static async Task<Results<Ok<FacturaDto>, NotFound>> ObtenerPorId(
        Guid id, IFacturasService svc, CancellationToken ct) =>
        await svc.ObtenerAsync(id, ct) is { } dto
            ? TypedResults.Ok(dto)
            : TypedResults.NotFound();
}
```

---

## EF Core — DbContext Lifecycle

```csharp
// REGLA: DbContext siempre Scoped. Nunca Singleton ni Transient.
builder.Services.AddDbContext<AppDbContext>(o =>
    o.UseNpgsql(connectionString)
     .EnableSensitiveDataLogging(app.Environment.IsDevelopment()));

public class AppDbContext(DbContextOptions<AppDbContext> options)
    : DbContext(options)
{
    public DbSet<Factura> Facturas => Set<Factura>();
    public DbSet<Cliente> Clientes => Set<Cliente>();

    protected override void OnModelCreating(ModelBuilder mb)
    {
        mb.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
    }
}

// Configuración de entidad separada (nunca en OnModelCreating directamente)
public class FacturaConfiguration : IEntityTypeConfiguration<Factura>
{
    public void Configure(EntityTypeBuilder<Factura> b)
    {
        b.HasKey(f => f.Id);
        b.Property(f => f.Estatus)
         .HasConversion<string>()
         .HasMaxLength(20);
        b.HasIndex(f => new { f.EmpresaId, f.Estatus });  // documentar qué query justifica el índice
    }
}
```

---

## Async/Await con CancellationToken

```csharp
// SIEMPRE propagar CancellationToken hasta la BD
public async Task<List<FacturaDto>> ObtenerTodasAsync(
    Guid empresaId, CancellationToken ct = default)
{
    return await _db.Facturas
        .Where(f => f.EmpresaId == empresaId)
        .Select(f => new FacturaDto(f.Id, f.Folio, f.Total))
        .AsNoTracking()          // SIEMPRE en queries de solo lectura
        .ToListAsync(ct);        // ct llega hasta la BD
}

// NUNCA async void — solo async Task o async ValueTask
// MAL:
async void ProcesarEnBackground() { ... }

// BIEN:
async Task ProcesarEnBackgroundAsync(CancellationToken ct) { ... }
```

---

## IHostedService y BackgroundService

```csharp
public class FacturasProcesadorService(
    IServiceScopeFactory scopeFactory,
    ILogger<FacturasProcesadorService> logger)
    : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                // SIEMPRE crear scope — BackgroundService es Singleton
                await using var scope = scopeFactory.CreateAsyncScope();
                var svc = scope.ServiceProvider.GetRequiredService<IFacturasService>();
                await svc.ProcesarPendientesAsync(stoppingToken);
            }
            catch (Exception ex) when (ex is not OperationCanceledException)
            {
                logger.LogError(ex, "Error en ciclo de procesamiento");
            }
            await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
        }
    }
}
```

---

## Options Pattern

```csharp
// Validación en tiempo de inicio — no en runtime
public class EmailOptions
{
    public const string Section = "Email";

    [Required] public string SmtpHost { get; init; } = "";
    [Range(1, 65535)] public int SmtpPort { get; init; } = 587;
    [Required] public string ApiKey { get; init; } = "";
}

// Registro con validación
builder.Services
    .AddOptions<EmailOptions>()
    .BindConfiguration(EmailOptions.Section)
    .ValidateDataAnnotations()
    .ValidateOnStart();              // Falla al arrancar si la config es inválida

// Uso en servicio
public class EmailService(IOptions<EmailOptions> opts)
{
    private readonly EmailOptions _config = opts.Value;
}
```

---

## Records e Init-Only Properties

```csharp
// Records para DTOs y Commands (inmutables por defecto)
public record FacturaDto(Guid Id, string Folio, decimal Total);

public record CrearFacturaCommand(
    Guid EmpresaId,
    string Folio,
    List<ItemDto> Items);

// Init-only para configuración mutable parcialmente
public class ConfiguracionSistema
{
    public required string Nombre { get; init; }
    public required string Version { get; init; }
    public bool ModoDebug { get; set; }  // mutable post-construcción
}
```

---

## LINQ Method Syntax avanzado

```csharp
// Proyección con join — preferir Include/ThenInclude en EF Core
var reporte = await _db.Facturas
    .Where(f => f.EmpresaId == empresaId && f.FechaEmision >= desde)
    .GroupBy(f => f.ClienteId)
    .Select(g => new ReporteClienteDto(
        ClienteId: g.Key,
        TotalFacturas: g.Count(),
        MontoTotal: g.Sum(f => f.Total),
        UltimaFactura: g.Max(f => f.FechaEmision)
    ))
    .OrderByDescending(r => r.MontoTotal)
    .Take(10)
    .ToListAsync(ct);
```

---

## Nullable Reference Types

```csharp
// <Nullable>enable</Nullable> en .csproj — OBLIGATORIO
// Expresar intención de nulabilidad en el tipo
public class Factura
{
    public Guid Id { get; set; }
    public string Folio { get; set; } = "";      // nunca null
    public string? Observaciones { get; set; }   // puede ser null
    public Cliente Cliente { get; set; } = null!; // EF lo asigna, nunca null en runtime
}
```

---

## Anti-patrones críticos

### DbContext como Singleton — NUNCA

```csharp
// MAL — DbContext no es thread-safe
builder.Services.AddSingleton<AppDbContext>(...);

// BIEN — siempre Scoped
builder.Services.AddDbContext<AppDbContext>(...);
```

### SaveChanges en repositorios — NUNCA

```csharp
// MAL — el repositorio no sabe si hay otras operaciones en la misma transacción
public async Task<Factura> CrearAsync(Factura factura)
{
    _db.Facturas.Add(factura);
    await _db.SaveChangesAsync();   // NO aquí
    return factura;
}

// BIEN — SaveChanges solo en el use case / endpoint
public async Task<Factura> CrearAsync(Factura factura)
{
    _db.Facturas.Add(factura);
    return factura;   // El llamador hace SaveChanges
}
```

---

## Gotchas / Errores comunes no obvios

**`DbContext` registrado como `Singleton` causa `InvalidOperationException` en acceso concurrente**: EF Core `DbContext` no es thread-safe — si se registra como Singleton y múltiples requests lo usan simultáneamente, las queries se mezclan. Fix: registrar siempre como `Scoped` (`services.AddDbContext<AppDbContext>(...)`); para `BackgroundService` que vive como Singleton, usar `IServiceScopeFactory` para crear un scope por operación.

**`async void` en event handlers de ASP.NET lanza excepciones que no se pueden capturar**: un método `async void` lanza su excepción en el SynchronizationContext del thread de UI (o en ThreadPool), sin que el llamador pueda atraparla con `try/catch`. En ASP.NET Core sin SynchronizationContext, la excepción mata el proceso. Fix: nunca usar `async void` excepto en event handlers de frameworks que lo requieren explícitamente; usar `async Task` siempre.

**`IOptions<T>` registrado con `AddOptions<T>().ValidateDataAnnotations()` no valida al arranque**: la validación solo ocurre cuando se resuelve el servicio, no en el startup. Una configuración inválida no se detecta hasta que el primer request llega. Fix: encadenar `.ValidateOnStart()` para que la validación falle en `builder.Build()` si la configuración es inválida: `services.AddOptions<MiConfig>().Bind(config).ValidateDataAnnotations().ValidateOnStart()`.

**LINQ `Select` sobre `IQueryable` con métodos no traducibles a SQL lanza en runtime**: `dbContext.Facturas.Select(f => new { Total = CalcularTotal(f) })` falla en runtime con `InvalidOperationException: Could not translate expression` si `CalcularTotal` no es una función que EF Core conoce. Fix: materializar primero con `AsEnumerable()` o `ToList()` para operaciones que requieren lógica C# no traducible, luego aplicar el `Select` en memoria — con cuidado de no traer más datos de los necesarios.

## Checklist antes de merge

- [ ] CancellationToken propagado hasta ToListAsync/SaveChangesAsync
- [ ] AsNoTracking() en todos los queries de solo lectura
- [ ] DbContext registrado como Scoped, nunca Singleton
- [ ] No hay async void (solo async Task)
- [ ] BackgroundService usa IServiceScopeFactory para resolver Scoped services
- [ ] Nullable Reference Types habilitado en .csproj
- [ ] Options validados con ValidateOnStart()
