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 asyncTask <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 .Serviciois { }) {where .Add ("Servicio = @Servicio" );p .Add ("Servicio" ,req .Servicio); }if (req .PrecioMaxis { }) {where .Add ("Precio <= @PrecioMax" );p .Add ("PrecioMax" ,req .PrecioMax); }if (req .Accesois { }) {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 ); }