---
name: csharp-patrones
description: >
  Patrones C# modernos: records para CQRS, MediatR, Result<T> pattern,
  Specification pattern, Unit of Work, Channel<T>, Polly y discriminated
  unions con OneOf. Cargar cuando se diseñe arquitectura .NET, se implemente
  CQRS/Mediator, o se necesite manejo robusto de errores sin excepciones.
version: "1.0.0"
herramientasPermitidas: [Read, Glob, Grep]
exclusiones:
  - "No cargar para implementar endpoints ASP.NET Core, EF Core o Background Services — para implementación cargar `csharp-experto`."
  - "No cargar para escribir tests C# — para testing cargar `csharp-testing`."
  - "No cargar para errores de compilación C# — para build errors cargar `build-errors-csharp`."
  - "No cargar para patrones de diseño GoF genéricos sin contexto C# — este skill cubre patrones idiomáticos de C# moderno como CQRS con records, Result<T> y MediatR."
evolvable: true  # default para skill estandar
---
# C# Patrones — CQRS, Result, MediatR y Más

## Cuándo NO cargar

- La pregunta es sobre implementar endpoints ASP.NET Core, EF Core o Background Services — para implementación concreta cargar `csharp-experto`.
- La tarea es escribir tests C# — cargar `csharp-testing`.
- Los errores son de compilación C# — cargar `build-errors-csharp`.
- La discusión es sobre patrones GoF genéricos (Singleton, Observer) sin contexto C# — este skill cubre patrones idiomáticos de C# moderno como CQRS con records, Result<T> y MediatR.

## CQRS con Records y MediatR

```csharp
// Command — estado mutable del sistema
public record CrearFacturaCommand(
    Guid EmpresaId,
    string Folio,
    List<ItemCommand> Items) : IRequest<Result<FacturaDto>>;

// Query — solo lectura, sin efectos secundarios
public record ObtenerFacturasQuery(
    Guid EmpresaId,
    int Pagina = 1,
    int TamanoPagina = 20) : IRequest<Result<PaginaDto<FacturaDto>>>;

// Handler de Command
public class CrearFacturaHandler(
    IFacturasRepository repo,
    IUnitOfWork uow,
    ILogger<CrearFacturaHandler> logger)
    : IRequestHandler<CrearFacturaCommand, Result<FacturaDto>>
{
    public async Task<Result<FacturaDto>> Handle(
        CrearFacturaCommand req, CancellationToken ct)
    {
        if (await repo.ExisteFolioAsync(req.EmpresaId, req.Folio, ct))
            return Result.Failure<FacturaDto>(
                Error.Conflicto("Facturas.FolioDuplicado", $"El folio {req.Folio} ya existe"));

        var factura = Factura.Crear(req.EmpresaId, req.Folio, req.Items);
        await repo.AgregarAsync(factura, ct);
        await uow.GuardarCambiosAsync(ct);

        logger.LogInformation("Factura {Folio} creada para empresa {EmpresaId}",
            req.Folio, req.EmpresaId);
        return Result.Success(FacturaDto.Desde(factura));
    }
}
```

---

## Result<T> Pattern — Sin excepciones para flujo de control

```csharp
public class Result<T>
{
    private Result(T? valor, bool exitoso, Error error)
    {
        Valor = valor;
        EsExitoso = exitoso;
        Error = error;
    }

    public T? Valor { get; }
    public bool EsExitoso { get; }
    public bool EsFallido => !EsExitoso;
    public Error Error { get; }

    public static Result<T> Success(T valor) =>
        new(valor, true, Error.Ninguno);

    public static Result<T> Failure(Error error) =>
        new(default, false, error);
}

public static class Result
{
    public static Result<T> Success<T>(T valor) => Result<T>.Success(valor);
    public static Result<T> Failure<T>(Error error) => Result<T>.Failure(error);
}

public record Error(string Codigo, string Descripcion)
{
    public static readonly Error Ninguno = new("", "");
    public static Error NoEncontrado(string entidad, Guid id) =>
        new($"{entidad}.NoEncontrado", $"{entidad} con id {id} no existe");
    public static Error Conflicto(string codigo, string desc) => new(codigo, desc);
    public static Error Validacion(string campo, string mensaje) =>
        new($"Validacion.{campo}", mensaje);
}

// Uso en endpoint — mapear Result a HTTP
static async Task<IResult> Crear(
    CrearFacturaCommand cmd,
    ISender mediator,
    CancellationToken ct)
{
    var resultado = await mediator.Send(cmd, ct);
    return resultado switch
    {
        { EsExitoso: true } => TypedResults.Created($"/facturas/{resultado.Valor!.Id}", resultado.Valor),
        { Error.Codigo: var c } when c.StartsWith("Validacion") => TypedResults.BadRequest(resultado.Error),
        { Error.Codigo: var c } when c.EndsWith("Duplicado") => TypedResults.Conflict(resultado.Error),
        _ => TypedResults.Problem("Error interno")
    };
}
```

---

## Specification Pattern

```csharp
public abstract class Specification<T>
{
    public abstract Expression<Func<T, bool>> Criterio { get; }

    public Specification<T> And(Specification<T> otra) =>
        new SpecificacionAnd<T>(this, otra);

    public Specification<T> Or(Specification<T> otra) =>
        new SpecificacionOr<T>(this, otra);
}

public class FacturasPorEmpresaSpec : Specification<Factura>
{
    private readonly Guid _empresaId;
    public FacturasPorEmpresaSpec(Guid empresaId) => _empresaId = empresaId;

    public override Expression<Func<Factura, bool>> Criterio =>
        f => f.EmpresaId == _empresaId;
}

public class FacturasPendientesSpec : Specification<Factura>
{
    public override Expression<Func<Factura, bool>> Criterio =>
        f => f.Estatus == "pendiente";
}

// Uso compuesto
var spec = new FacturasPorEmpresaSpec(empresaId)
    .And(new FacturasPendientesSpec());

var facturas = await _db.Facturas
    .Where(spec.Criterio)
    .ToListAsync(ct);
```

---

## Unit of Work con EF Core

```csharp
public interface IUnitOfWork
{
    Task<int> GuardarCambiosAsync(CancellationToken ct = default);
}

// AppDbContext implementa IUnitOfWork directamente
public class AppDbContext : DbContext, IUnitOfWork
{
    public Task<int> GuardarCambiosAsync(CancellationToken ct = default) =>
        SaveChangesAsync(ct);
}

// Registro — mismo DbContext resuelve ambas interfaces
builder.Services.AddDbContext<AppDbContext>(...);
builder.Services.AddScoped<IUnitOfWork>(sp =>
    sp.GetRequiredService<AppDbContext>());
```

---

## Channel<T> — Pipelines async

```csharp
// Productor/consumidor sin bloqueo de thread
public class ColaProcesamiento<T>
{
    private readonly Channel<T> _canal = Channel.CreateBounded<T>(
        new BoundedChannelOptions(1000)
        {
            FullMode = BoundedChannelFullMode.Wait,
            SingleReader = false,
            SingleWriter = false
        });

    public ChannelWriter<T> Escritor => _canal.Writer;
    public ChannelReader<T> Lector => _canal.Reader;
}

// Productor
await _cola.Escritor.WriteAsync(nuevoElemento, ct);

// Consumidor en BackgroundService
await foreach (var elemento in _cola.Lector.ReadAllAsync(stoppingToken))
{
    await ProcesarAsync(elemento, stoppingToken);
}
```

---

## Polly — Resiliencia

```csharp
// Pipeline de resiliencia para llamadas HTTP externas
var pipeline = new ResiliencePipelineBuilder<HttpResponseMessage>()
    .AddRetry(new RetryStrategyOptions<HttpResponseMessage>
    {
        MaxRetryAttempts = 3,
        Delay = TimeSpan.FromSeconds(1),
        BackoffType = DelayBackoffType.Exponential,
        ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
            .Handle<HttpRequestException>()
            .HandleResult(r => r.StatusCode >= HttpStatusCode.InternalServerError)
    })
    .AddCircuitBreaker(new CircuitBreakerStrategyOptions<HttpResponseMessage>
    {
        FailureRatio = 0.5,
        MinimumThroughput = 10,
        BreakDuration = TimeSpan.FromSeconds(30)
    })
    .AddTimeout(TimeSpan.FromSeconds(10))
    .Build();

// Registro con HttpClient
builder.Services.AddHttpClient<ISatClient, SatClient>()
    .AddResilienceHandler("sat-pipeline", b => b.AddPipeline(pipeline));
```

---

## OneOf — Discriminated Unions

```csharp
// dotnet add package OneOf
public class FacturasService
{
    public async Task<OneOf<FacturaDto, NotFound, ValidationError>> ObtenerAsync(
        Guid id, CancellationToken ct)
    {
        var factura = await _repo.ObtenerAsync(id, ct);
        if (factura is null) return new NotFound();
        return FacturaDto.Desde(factura);
    }
}

// Uso exhaustivo en endpoint
var resultado = await svc.ObtenerAsync(id, ct);
return resultado.Match(
    dto => TypedResults.Ok(dto),
    _ => TypedResults.NotFound(),
    error => TypedResults.BadRequest(error.Mensajes)
);
```

---

## Anti-patrones

### Excepciones para flujo de control — NUNCA

```csharp
// MAL — las excepciones son costosas y ocultan la intención
throw new NotFoundException($"Factura {id} no encontrada");

// BIEN — Result o OneOf expresan el caso esperado
return Result.Failure<FacturaDto>(Error.NoEncontrado("Factura", id));
```

### Service Locator — NUNCA

```csharp
// MAL — oculta dependencias, imposible de testear
var svc = _serviceProvider.GetService<IFacturasService>();

// BIEN — inyectar en el constructor
public class Handler(IFacturasService svc) { }
```

---

## Gotchas / Errores comunes no obvios

**MediatR con `IPipelineBehavior` registrado después del handler no se ejecuta en el orden correcto**: el orden de registro de behaviors en el DI container determina el orden de ejecución de la pipeline. Un behavior de validación registrado después del behavior de logging ejecuta después, no antes. Fix: registrar los behaviors en orden explícito: primero logging, luego validación, luego el handler — o usar `OrderedPipelineBehavior` si el orden debe ser determinístico independiente del registro.

**`Result<T>` implementado con un `bool Success` y un `T Value` permite acceder a `Value` cuando `Success = false`**: si el tipo `Result<T>` no protege el acceso al valor cuando el resultado es error, el código que consume el resultado puede usar `result.Value` sin verificar `result.Success`. Fix: hacer que `Value` lance una excepción si se accede cuando `IsError` es true, o usar un patrón match que fuerce el manejo de ambos casos.

**`Channel<T>` sin capacidad fija (`Channel.CreateUnbounded()`) puede crecer sin límite bajo presión**: si el productor es más rápido que el consumidor, el canal acumula mensajes en memoria hasta agotar RAM. Fix: usar `Channel.CreateBounded(capacity)` con una capacidad razonable y manejar `ChannelWriter.TryWrite()` retornando `false` (canal lleno) con backpressure.

**Polly `WaitAndRetry` con la misma instancia de `HttpClient` no reintenta si el HttpMessageHandler tiene su propio timeout**: si el `HttpClient` tiene `Timeout = TimeSpan.FromSeconds(5)` y Polly tiene 3 reintentos, el `TaskCanceledException` del timeout interno del cliente se lanza antes del segundo intento y no es capturado por Polly. Fix: deshabilitar el timeout del `HttpClient` (`Timeout = InfiniteTimeSpan`) y configurar el timeout a través de Polly con `TimeoutPolicy`.

## Checklist de arquitectura

- [ ] Commands y Queries son records inmutables
- [ ] Handlers retornan Result<T>, no lanzan excepciones para casos esperados
- [ ] Unit of Work hace SaveChanges, no los repositorios
- [ ] Polly configurado para todas las llamadas HTTP externas
- [ ] Sin Service Locator (IServiceProvider en código de negocio)
