.NET 8 Clean Architecture EF Core + Dapper Redis Cache xUnit Tests

API de Skills — Patrones Backend C# Producción

Implementación de la API del catálogo de automatizaciones de CULTIVA IA siguiendo patrones .NET 8 de nivel enterprise: arquitectura limpia, acceso a datos dual, caché multinivel y testing.

🌐
Endpoints API — CULTIVA IA Skills Catalog
Método Ruta Handler Caché Auth
GET /api/skills SkillService.SearchAsync() L1+L2 15min Anónimo
GET /api/skills/{slug} SkillService.GetBySlugAsync() L1+L2 15min Anónimo
POST /api/skills SkillService.CreateAsync() Invalida Admin JWT
PUT /api/skills/{id} SkillService.UpdateAsync() Invalida Admin JWT
POST /api/solicitudes SolicitudService.CreateAsync() Anónimo
GET /api/solicitudes/{id} SolicitudService.GetByIdAsync() Token cliente
⚙️
Result<T> + Dependency Injection — Application Layer
Application/Services/SkillService.cs C#
public async Task<Result<Skill>> CreateAsync(
    CreateSkillRequest request,
    CancellationToken ct = default)
{
    // 1. Validar entrada con FluentValidation
    var validation = await _validator.ValidateAsync(request, ct);
    if (!validation.IsValid)
        return Result<Skill>.Failure(
            validation.Errors[0].ErrorMessage,
            "VALIDATION_ERROR");

    // 2. Regla de negocio: slug único
    var existing = await _repo.GetBySlugAsync(request.Slug, ct);
    if (existing != null)
        return Result<Skill>.Failure(
            $"Slug '{request.Slug}' ya existe",
            "DUPLICATE_SLUG");

    // 3. Crear entidad
    var skill = new Skill {
        Id   = Guid.NewGuid().ToString("N"),
        Slug = request.Slug,
        Name = request.Name,
        // ...
    };

    var created = await _repo.CreateAsync(skill, ct);
    return Result<Skill>.Success(created);
}
Infrastructure/DependencyInjection.cs C#
public static IServiceCollection AddCultivaServices(
    this IServiceCollection services,
    IConfiguration config)
{
    // Scoped: por request
    services.AddScoped<ISkillService, SkillService>();
    services.AddScoped<ISolicitudService, SolicitudService>();

    // Singleton: Redis connection
    services.AddSingleton<IConnectionMultiplexer>(_ =>
        ConnectionMultiplexer.Connect(
            config["Redis:Connection"]!));

    // Keyed DI .NET 8: repositorio dual
    services.AddKeyedScoped<ISkillRepository,
        DapperSkillRepository>("read");
    services.AddKeyedScoped<ISkillRepository,
        EfCoreSkillRepository>("write");

    // Options
    services.Configure<SkillCatalogOptions>(
        config.GetSection(SkillCatalogOptions.Section));

    services.AddMemoryCache();
    services.AddStackExchangeRedisCache(o =>
        o.Configuration = config["Redis:Connection"]);

    return services;
}
Domain/Common/Result.cs — Tipo genérico reutilizable en todos los servicios C#
public sealed class Result<T>
{
    public bool    IsSuccess  { get; }
    public T?       Value      { get; }
    public string? Error      { get; }
    public string? ErrorCode  { get; }   // "NOT_FOUND" | "VALIDATION_ERROR" | "DUPLICATE_SLUG"...

    private Result(bool ok, T? val, string? err, string? code) { /* ... */ }

    public static Result<T> Success(T value)  => new(true,  value, null,  null);
    public static Result<T> Failure(string err, string? code = null) => new(false, default, err, code);

    // Composición funcional
    public Result<TNew> Map<TNew>(Func<T, TNew> mapper) =>
        IsSuccess ? Result<TNew>.Success(mapper(Value!)) : Result<TNew>.Failure(Error!, ErrorCode);

    public async Task<Result<TNew>> MapAsync<TNew>(Func<T, Task<TNew>> mapper) =>
        IsSuccess ? Result<TNew>.Success(await mapper(Value!)) : Result<TNew>.Failure(Error!, ErrorCode);
}

// Uso en Minimal API endpoint:
app.MapGet("/api/skills/{slug}", async (string slug, ISkillService svc, CancellationToken ct) =>
{
    var result = await svc.GetBySlugAsync(slug, ct);
    return result.IsSuccess
        ? Results.Ok(result.Value)
        : result.ErrorCode == "NOT_FOUND"
            ? Results.NotFound(new { error = result.Error })
            : Results.Problem(result.Error);
}).WithName("GetSkillBySlug").WithTags("skills");
🗄
Acceso a Datos Dual — Dapper (lectura) + EF Core (escritura)
Infrastructure/Data/DapperSkillRepo.cs C#
// Lectura: Dapper — máxima performance
public async Task<IReadOnlyList<Skill>> SearchAsync(
    SkillSearchRequest req, CancellationToken ct)
{
    var where = new List<string> { "Activo = 1" };
    var p = new DynamicParameters();

    if (req.Servicio is { }) {
        where.Add("Servicio = @Servicio");
        p.Add("Servicio", req.Servicio);
    }
    if (req.PrecioMax is { }) {
        where.Add("Precio <= @PrecioMax");
        p.Add("PrecioMax", req.PrecioMax);
    }
    if (req.Acceso is { }) {
        where.Add("Acceso = @Acceso");
        p.Add("Acceso", req.Acceso);
    }

    // Paginación en una sola consulta
    var sql = $"""
        SELECT COUNT(*) FROM Skills WHERE {where.Join(" AND ")};
        SELECT Id, Nombre, Slug, Servicio, Precio, Nivel, Acceso
        FROM Skills WHERE {where.Join(" AND ")}
        ORDER BY Nombre
        OFFSET @Skip ROWS FETCH NEXT @Take ROWS ONLY;
        """;

    p.Add("Skip", (req.Page - 1) * req.PageSize);
    p.Add("Take", req.PageSize);

    using var multi = await _conn.QueryMultipleAsync(
        new CommandDefinition(sql, p, cancellationToken: ct));

    _ = await multi.ReadSingleAsync<int>();
    return (await multi.ReadAsync<Skill>()).ToList();
}
Infrastructure/Data/EfCoreSkillRepo.cs C#
// Escritura: EF Core — change tracking + validaciones
public async Task<Skill> CreateAsync(
    Skill skill, CancellationToken ct)
{
    _context.Skills.Add(skill);
    await _context.SaveChangesAsync(ct);
    return skill;
}

// Configuración de entidad
public class SkillConfiguration
    : IEntityTypeConfiguration<Skill>
{
    public void Configure(EntityTypeBuilder<Skill> b)
    {
        b.ToTable("Skills");
        b.HasKey(s => s.Id);
        b.Property(s => s.Nombre)
            .HasMaxLength(200).IsRequired();
        b.Property(s => s.Precio)
            .HasPrecision(10, 2);
        // Índice único en slug
        b.HasIndex(s => s.Slug).IsUnique();
        // Índice compuesto búsqueda
        b.HasIndex(s => new {s.Servicio, s.Nivel});
        // Soft delete global filter
        b.HasQueryFilter(s => s.Activo);
    }
}
Caché Multinivel — MemoryCache L1 + Redis L2 + DB L3
L1 — In-Process
MemoryCache
TTL: 1 min
MISS
L2 — Distributed
Redis
TTL: 15 min
MISS
L3 — Origin
SQL Server
Source of truth
HIT
Populate L2
Redis.Set()
+15 min TTL
HIT
Populate L1
Memory.Set()
+1 min TTL
Infrastructure/Caching/CachedSkillService.cs — Decorator Pattern sobre ISkillService C#
public async Task<Skill?> GetBySlugAsync(string slug, CancellationToken ct)
{
    var key = $"cultiva:skill:{slug}";

    // ── L1: MemoryCache (sub-milisegundo, sin red) ──────────────────
    if (_mem.TryGetValue(key, out Skill? cached)) {
        _log.LogDebug("[L1 HIT] skill:{Slug}", slug);
        return cached;
    }

    // ── L2: Redis (1-2 ms, sobrevive restarts) ──────────────────────
    var raw = await _redis.GetStringAsync(key, ct);
    if (raw != null) {
        var s = JsonSerializer.Deserialize<Skill>(raw)!;
        _mem.Set(key, s, TimeSpan.FromMinutes(1));   // repoblar L1
        return s;
    }

    // ── L3: Base de datos (Dapper, ~5-20 ms) ────────────────────────
    var skill = await _inner.GetBySlugAsync(slug, ct);
    if (skill != null) {
        await _redis.SetStringAsync(key, JsonSerializer.Serialize(skill),
            new DistributedCacheEntryOptions {
                AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(15)
            }, ct);
        _mem.Set(key, skill, TimeSpan.FromMinutes(1));
    }
    return skill;
}

// Invalidación en cascada al actualizar
public async Task InvalidateSkillCacheAsync(string slug, CancellationToken ct)
{
    var key = $"cultiva:skill:{slug}";
    _mem.Remove(key);
    await _redis.RemoveAsync(key, ct);
    _log.LogInformation("Cache invalidada para skill:{Slug}", slug);
}
🧪
Tests xUnit + Moq — SkillServiceTests
Tests/SkillServiceTests.cs C#
public class SkillServiceTests
{
    private readonly Mock<ISkillRepository> _repo;
    private readonly Mock<ICacheService>       _cache;
    private readonly SkillService                   _sut;

    public SkillServiceTests()
    {
        _repo  = new Mock<ISkillRepository>();
        _cache = new Mock<ICacheService>();
        _sut   = new SkillService(_repo.Object, _cache.Object);
    }

    [Fact]
    public async Task CreateAsync_SlugDuplicado_RetornaFailure()
    {
        // Arrange
        _repo.Setup(r => r.GetBySlugAsync(
                "cold-email", It.IsAny<CancellationToken>()))
            .ReturnsAsync(new Skill { Slug = "cold-email" });

        var req = new CreateSkillRequest(
            Nombre: "Cold Email", Slug: "cold-email",
            Precio: 0m, Servicio: "Marketing", Nivel: "basico");

        // Act
        var result = await _sut.CreateAsync(req);

        // Assert
        Assert.False(result.IsSuccess);
        Assert.Equal("DUPLICATE_SLUG", result.ErrorCode);
        _repo.Verify(
            r => r.CreateAsync(
                It.IsAny<Skill>(),
                It.IsAny<CancellationToken>()),
            Times.Never);
    }

    [Theory]
    [InlineData("")]
    [InlineData(null)]
    [InlineData("  ")]
    public async Task CreateAsync_SlugInvalido_RetornaValidationError(
        string? slug)
    {
        var req = new CreateSkillRequest(
            Nombre: "Test", Slug: slug!,
            Precio: 5m, Servicio: "Web", Nivel: "avanzado");

        var result = await _sut.CreateAsync(req);

        Assert.False(result.IsSuccess);
        Assert.Equal("VALIDATION_ERROR", result.ErrorCode);
    }
}
Resultados de Tests
  • CreateAsync_SlugDuplicado_RetornaFailure SkillServiceTests 12 ms
  • CreateAsync_SlugInvalido("") RetornaValidationError SkillServiceTests 3 ms
  • CreateAsync_SlugInvalido(null) RetornaValidationError SkillServiceTests 1 ms
  • CreateAsync_DatosValidos_RetornaSuccess SkillServiceTests 8 ms
  • GetBySlugAsync_CacheHit_NoConsultaDB CachedSkillServiceTests 4 ms
  • GetBySlugAsync_CacheMiss_ConsultaYCachea CachedSkillServiceTests 15 ms
  • SearchAsync_FiltroServicio_RetornaSoloMarketing SkillServiceTests 9 ms
  • SearchAsync_FiltroPrecioMax_RetornaSoloGratis SkillServiceTests 6 ms
Passed: 8  |  Failed: 0  |  Skipped: 0  |  Total: 52 ms
Tests/Integration/SkillsApiTests.cs C#
// Test de integración con WebApplicationFactory
[Fact]
public async Task GET_Skills_ConFiltroServicio_Retorna200()
{
    // Act
    var resp = await _client.GetAsync(
        "/api/skills?servicio=Marketing");

    // Assert
    resp.EnsureSuccessStatusCode();
    var body = await resp.Content
        .ReadFromJsonAsync<PagedResult<Skill>>();
    Assert.NotEmpty(body!.Items);
    Assert.All(body.Items,
        s => Assert.Equal("Marketing", s.Servicio));
}
📋
Reglas de Calidad — DO / DON'T en .NET 8
✅ HACER
async/await hasta el final del stack — nunca mezclar
Inyectar dependencias por constructor, no new
Usar Result<T> para errores de negocio — no excepciones
Pasar CancellationToken en todos los métodos async
AsNoTracking() en todas las consultas de solo lectura
Usar IOptions<T> para configuración tipada
Dapper para read-heavy; EF Core para escritura con dominio
Record types para DTOs inmutables
Keyed DI (.NET 8) para variantes del mismo servicio
Tests unitarios + tests de integración con WebApplicationFactory
❌ NO HACER
Bloquear en async con .Result o .GetAwaiter().GetResult()
Usar async void (excepto event handlers)
Exponer entidades EF directamente en la API — usar DTOs
Hardcodear strings de conexión o config en código
Crear new HttpClient() — siempre usar IHttpClientFactory
Ignorar el CancellationToken en métodos que lo reciben
N+1 queries — usar .Include() o joins explícitos
Swallowear excepciones con catch (Exception) { }
Olvidar índices en columnas de filtro frecuente
Saltar validación en boundaries de la API
Patrones Async Correctos — Paralelismo y ValueTask
Application/Services/SkillEnrichmentService.cs — Parallel fetch + ValueTask para hot paths C#
// ✅ Fetch paralelo — obtener stats y precio al mismo tiempo
public async Task<SkillDetailDto> GetSkillDetailAsync(
    string slug, CancellationToken ct)
{
    var skillTask  = _skillRepo.GetBySlugAsync(slug, ct);
    var statsTask  = _statsService.GetAsync(slug, ct);
    var reviewTask = _reviewService.GetAsync(slug, ct);

    await Task.WhenAll(skillTask, statsTask, reviewTask);

    return new SkillDetailDto(
        Skill:   await skillTask,
        Stats:   await statsTask,
        Reviews: await reviewTask
    );
}

// ✅ ValueTask para hot path con caché — evita allocación Task si hay hit
public ValueTask<Skill?> GetBySlugCachedAsync(string slug)
{
    // Si está en caché L1 → retorno síncrono sin allocation
    if (_memCache.TryGetValue($"skill:{slug}", out Skill? skill))
        return ValueTask.FromResult(skill);

    // Solo alloca Task si hay que ir a Redis/DB
    return new ValueTask<Skill?>(GetFromDistributedAsync(slug));
}

// ✅ ConfigureAwait(false) en código de librería compartida
public async Task<T?> GetJsonAsync<T>(string url, CancellationToken ct)
{
    var resp = await _http.GetAsync(url, ct).ConfigureAwait(false);
    resp.EnsureSuccessStatusCode();
    return await resp.Content.ReadFromJsonAsync<T>(ct).ConfigureAwait(false);
}