Descripción de la imagen
Módulo 2 · 2 horas · Guía del instructor

Desarrollo de servicios y acceso a datos

Desglose pedagógico completo: APIs REST, controladores, DTOs, validaciones, EF Core, CRUD, consultas, relaciones, Swagger y pruebas. Al finalizar, el estudiante construirá una API REST completa.

1

APIs REST

15 min
Objetivo Comprender los principios REST y cómo se aplican en ASP.NET Core.

2.1.1. ¿Qué es REST?

Estilo arquitectónico (Roy Fielding, 2000) basado en:

2.1.2. Verbos HTTP y semántica

VerboPropósitoIdempotenteSeguro
GETLeer recursoSíSí
POSTCrear recursoNoNo
PUTReemplazar recursoSíNo
PATCHModificar parcialmenteNo*No
DELETEEliminar recursoSíNo

2.1.3. Códigos de estado relevantes

2.1.4. Niveles de madurez de Richardson

2.1.5. Convenciones de nomenclatura

2.1.6. Ejemplo de diseño REST

GET     /api/products              → Listar (paginado)
GET     /api/products/{id}         → Obtener por ID
POST    /api/products              → Crear
PUT     /api/products/{id}         → Reemplazar
PATCH   /api/products/{id}         → Actualizar parcial
DELETE  /api/products/{id}         → Eliminar
GET     /api/categories/{id}/products → Subrecurso
⚠ Errores comunes
  • Usar verbos en rutas (/getProducts).
  • Devolver 200 siempre, incluso en errores.
  • Mezclar RPC con REST sin criterio.
  • No versionar la API (/api/v1/...).
2

Controladores y endpoints

15 min
Objetivo Implementar controladores API con atributos y buenas prácticas.

2.2.1. Controlador base para APIs

[ApiController]
[Route("api/[controller]")]
[Produces("application/json")]
public class ProductsController : ControllerBase
{
    private readonly IProductService _service;

    public ProductsController(IProductService service) => _service = service;

    [HttpGet]
    [ProducesResponseType(typeof(IEnumerable<ProductDto>), StatusCodes.Status200OK)]
    public async Task<ActionResult<IEnumerable<ProductDto>>> GetAll(
        [FromQuery] int page = 1,
        [FromQuery] int pageSize = 10)
    {
        var result = await _service.GetPagedAsync(page, pageSize);
        return Ok(result);
    }

    [HttpGet("{id:int}", Name = nameof(GetById))]
    [ProducesResponseType(typeof(ProductDto), StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<ActionResult<ProductDto>> GetById(int id)
    {
        var product = await _service.GetByIdAsync(id);
        return product is null ? NotFound() : Ok(product);
    }

    [HttpPost]
    [ProducesResponseType(typeof(ProductDto), StatusCodes.Status201Created)]
    [ProducesResponseType(StatusCodes.Status400BadRequest)]
    public async Task<ActionResult<ProductDto>> Create([FromBody] CreateProductDto dto)
    {
        var created = await _service.CreateAsync(dto);
        return CreatedAtRoute(nameof(GetById), new { id = created.Id }, created);
    }

    [HttpPut("{id:int}")]
    public async Task<IActionResult> Update(int id, [FromBody] UpdateProductDto dto)
    {
        var updated = await _service.UpdateAsync(id, dto);
        return updated ? NoContent() : NotFound();
    }

    [HttpDelete("{id:int}")]
    public async Task<IActionResult> Delete(int id)
    {
        var deleted = await _service.DeleteAsync(id);
        return deleted ? NoContent() : NotFound();
    }
}

2.2.2. Atributos clave

AtributoFunción
[ApiController]Habilita binding automático, validación de modelo, inferencia de [FromBody], respuestas ProblemDetails
[Route]Define la plantilla de ruta
[HttpGet], [HttpPost], etc.Verbos HTTP
[FromBody], [FromQuery], [FromRoute], [FromHeader]Fuente de binding
[ProducesResponseType]Documenta códigos y tipos de respuesta (Swagger)
[Authorize]Requiere autenticación

2.2.3. ActionResult vs IActionResult vs tipado

2.2.4. Controlador delgado vs grueso

⚠ Errores comunes
  • Poner lógica de negocio en el controlador.
  • No usar async/await con I/O.
  • Ignorar CancellationToken en operaciones largas.
  • Devolver entidades directamente (ver 2.3).
3

Modelos, DTOs y validaciones

20 min
Objetivo Separar entidades de dominio, DTOs de transporte y aplicar validaciones robustas.

2.3.1. Entidad de dominio (EF Core)

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public decimal Price { get; set; }
    public int Stock { get; set; }
    public int CategoryId { get; set; }
    public Category? Category { get; set; }
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}

2.3.2. DTOs (Data Transfer Objects)

// Entrada - Create
public class CreateProductDto
{
    [Required, StringLength(100, MinimumLength = 2)]
    public string Name { get; set; } = string.Empty;

    [Range(0.01, 1_000_000)]
    public decimal Price { get; set; }

    [Range(0, int.MaxValue)]
    public int Stock { get; set; }

    [Required]
    public int CategoryId { get; set; }
}

// Entrada - Update
public class UpdateProductDto
{
    [Required, StringLength(100)]
    public string Name { get; set; } = string.Empty;

    [Range(0.01, 1_000_000)]
    public decimal Price { get; set; }

    [Range(0, int.MaxValue)]
    public int Stock { get; set; }
}

// Salida
public class ProductDto
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public decimal Price { get; set; }
    public int Stock { get; set; }
    public string CategoryName { get; set; } = string.Empty;
}

2.3.3. ¿Por qué DTOs?

2.3.4. DataAnnotations comunes

AtributoUso
[Required]Obligatorio
[StringLength(n, MinimumLength = m)]Longitud
[Range(min, max)]Rango numérico
[EmailAddress]Formato email
[RegularExpression]Patrón
[Compare]Comparar con otra propiedad
[Url], [Phone]Formatos comunes

2.3.5. Validaciones personalizadas

public class MinimumAgeAttribute : ValidationAttribute
{
    private readonly int _minAge;
    public MinimumAgeAttribute(int minAge) => _minAge = minAge;

    protected override ValidationResult? IsValid(object? value, ValidationContext ctx)
    {
        if (value is DateTime dob && dob.AddYears(_minAge) > DateTime.Today)
            return new ValidationResult($"Debe tener al menos {_minAge} años.");
        return ValidationResult.Success;
    }
}

2.3.6. FluentValidation (alternativa potente)

public class CreateProductValidator : AbstractValidator<CreateProductDto>
{
    public CreateProductValidator()
    {
        RuleFor(x => x.Name).NotEmpty().MaximumLength(100);
        RuleFor(x => x.Price).GreaterThan(0);
        RuleFor(x => x.CategoryId).GreaterThan(0)
            .MustAsync(async (id, ct) => await _repo.ExistsAsync(id))
            .WithMessage("La categoría no existe.");
    }
}

builder.Services.AddValidatorsFromAssemblyContaining<CreateProductValidator>();
builder.Services.AddFluentValidationAutoValidation();

2.3.7. AutoMapper / Mapperly (mapeo DTO ↔ entidad)

public class ProductProfile : Profile
{
    public ProductProfile()
    {
        CreateMap<Product, ProductDto>()
            .ForMember(d => d.CategoryName, o => o.MapFrom(s => s.Category!.Name));
        CreateMap<CreateProductDto, Product>();
    }
}
builder.Services.AddAutoMapper(typeof(ProductProfile));
💡 Nota moderna
Microsoft sugiere Mapperly (source generator) para evitar reflection en runtime.
⚠ Errores comunes
  • Exponer entidades EF Core directamente en el controlador.
  • Usar la misma clase para Create/Update/Read (acopla contratos).
  • Validar en múltiples capas de forma inconsistente.
  • Olvidar [ApiController] (no se dispara la validación automática).
4

Entity Framework Core

20 min
Objetivo Configurar EF Core, definir el DbContext, mapear entidades y aplicar migraciones.

2.4.1. ¿Qué es EF Core?

ORM (Object-Relational Mapper) que permite trabajar con BD usando objetos .NET. Soporta:

Proveedores: SQL Server, PostgreSQL, MySQL, SQLite, Oracle, Cosmos DB, In-Memory.

2.4.2. Instalación

dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.Design
dotnet tool install --global dotnet-ef

2.4.3. DbContext

public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }

    public DbSet<Product> Products => Set<Product>();
    public DbSet<Category> Categories => Set<Category>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Product>(entity =>
        {
            entity.HasKey(p => p.Id);
            entity.Property(p => p.Name).IsRequired().HasMaxLength(100);
            entity.Property(p => p.Price).HasPrecision(18, 2);
            entity.HasOne(p => p.Category)
                  .WithMany(c => c.Products)
                  .HasForeignKey(p => p.CategoryId)
                  .OnDelete(DeleteBehavior.Restrict);
            entity.HasIndex(p => p.Name);
        });

        modelBuilder.Entity<Category>(entity =>
        {
            entity.Property(c => c.Name).IsRequired().HasMaxLength(50);
        });

        // Aplicar configuraciones externas (IEntityTypeConfiguration)
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
    }
}

2.4.4. Configuración externa (recomendada)

public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
    public void Configure(EntityTypeBuilder<Product> builder)
    {
        builder.ToTable("Products");
        builder.HasKey(p => p.Id);
        // ...
    }
}

2.4.5. Registro en DI

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("Default"))
           .EnableSensitiveDataLogging(builder.Environment.IsDevelopment()));
⚠ Ciclo de vida
Scoped (una instancia por request). Nunca Singleton.

2.4.6. Migraciones

dotnet ef migrations add InitialCreate
dotnet ef database update
dotnet ef migrations list
dotnet ef migrations remove        # deshacer última
dotnet ef dbcontext script         # generar SQL
💡 EnsureCreated vs Migrations
  • EnsureCreated: solo prototipos, no soporta evolución.
  • Migrations: la vía profesional. ← Recomendado.

2.4.7. Seed data

modelBuilder.Entity<Category>().HasData(
    new Category { Id = 1, Name = "Electrónica" },
    new Category { Id = 2, Name = "Ropa" });

2.4.8. Auditoría automática

public override Task<int> SaveChangesAsync(CancellationToken ct = default)
{
    foreach (var entry in ChangeTracker.Entries<IAuditable>())
    {
        if (entry.State == EntityState.Added)
            entry.Entity.CreatedAt = DateTime.UtcNow;
        if (entry.State == EntityState.Modified)
            entry.Entity.UpdatedAt = DateTime.UtcNow;
    }
    return base.SaveChangesAsync(ct);
}
⚠ Errores comunes
  • Registrar DbContext como Singleton (captive dependency).
  • Usar EnsureCreated en producción.
  • No usar AsNoTracking en consultas de solo lectura.
  • Cargar entidades completas sin proyección.
  • Ignorar CancellationToken.
5

Operaciones CRUD

15 min
Objetivo Implementar CRUD completo con patrón repositorio/servicio y async/await.

2.5.1. Servicio con EF Core

public interface IProductService
{
    Task<PagedResult<ProductDto>> GetPagedAsync(int page, int size, CancellationToken ct = default);
    Task<ProductDto?> GetByIdAsync(int id, CancellationToken ct = default);
    Task<ProductDto> CreateAsync(CreateProductDto dto, CancellationToken ct = default);
    Task<bool> UpdateAsync(int id, UpdateProductDto dto, CancellationToken ct = default);
    Task<bool> DeleteAsync(int id, CancellationToken ct = default);
}

public class ProductService : IProductService
{
    private readonly AppDbContext _db;
    private readonly IMapper _mapper;
    private readonly ILogger<ProductService> _logger;

    public ProductService(AppDbContext db, IMapper mapper, ILogger<ProductService> logger)
    {
        _db = db; _mapper = mapper; _logger = logger;
    }

    public async Task<PagedResult<ProductDto>> GetPagedAsync(int page, int size, CancellationToken ct)
    {
        var query = _db.Products.AsNoTracking().Include(p => p.Category);
        var total = await query.CountAsync(ct);
        var items = await query
            .OrderBy(p => p.Name)
            .Skip((page - 1) * size)
            .Take(size)
            .ProjectTo<ProductDto>(_mapper.ConfigurationProvider)
            .ToListAsync(ct);

        return new PagedResult<ProductDto>(items, total, page, size);
    }

    public async Task<ProductDto?> GetByIdAsync(int id, CancellationToken ct)
    {
        var product = await _db.Products
            .AsNoTracking()
            .Include(p => p.Category)
            .FirstOrDefaultAsync(p => p.Id == id, ct);

        return product is null ? null : _mapper.Map<ProductDto>(product);
    }

    public async Task<ProductDto> CreateAsync(CreateProductDto dto, CancellationToken ct)
    {
        var entity = _mapper.Map<Product>(dto);
        _db.Products.Add(entity);
        await _db.SaveChangesAsync(ct);
        _logger.LogInformation("Producto {Id} creado: {Name}", entity.Id, entity.Name);
        return _mapper.Map<ProductDto>(entity);
    }

    public async Task<bool> UpdateAsync(int id, UpdateProductDto dto, CancellationToken ct)
    {
        var entity = await _db.Products.FindAsync(new object[] { id }, ct);
        if (entity is null) return false;

        _mapper.Map(dto, entity);
        await _db.SaveChangesAsync(ct);
        return true;
    }

    public async Task<bool> DeleteAsync(int id, CancellationToken ct)
    {
        var entity = await _db.Products.FindAsync(new object[] { id }, ct);
        if (entity is null) return false;

        _db.Products.Remove(entity);
        await _db.SaveChangesAsync(ct);
        return true;
    }
}

2.5.2. PagedResult<T> genérico

public record PagedResult<T>(IEnumerable<T> Items, int Total, int Page, int Size)
{
    public int TotalPages => (int)Math.Ceiling((double)Total / Size);
    public bool HasNext => Page < TotalPages;
    public bool HasPrevious => Page > 1;
}

2.5.3. Registro en DI

builder.Services.AddScoped<IProductService, ProductService>();

2.5.4. Reglas de oro CRUD

2.5.5. PATCH con JsonPatch

[HttpPatch("{id:int}")]
public async Task<IActionResult> Patch(int id, [FromBody] JsonPatchDocument<UpdateProductDto> patch)
{
    var dto = await _service.GetForPatchAsync(id);
    if (dto is null) return NotFound();

    patch.ApplyTo(dto, ModelState);
    if (!ModelState.IsValid) return ValidationProblem(ModelState);

    await _service.UpdateAsync(id, dto);
    return NoContent();
}
⚠ Errores comunes
  • No usar async (bloquea threads).
  • Llamar SaveChanges múltiples veces sin transacción.
  • Devolver la entidad con navegaciones cargadas (ciclos JSON).
  • No manejar concurrencia (ver RowVersion/Timestamp).
6

Consultas y relaciones

20 min
Objetivo Dominar LINQ, carga de relaciones, proyecciones y rendimiento.

2.6.1. Tipos de carga de relaciones

EstrategiaMétodoCuándo usar
Eager loadingInclude / ThenIncludeSe necesitan relaciones siempre
Explicit loadingEntry(...).Collection(...).LoadAsync()Condicional
Lazy loadingproxies (UseLazyLoadingProxies)❌ Evitar en APIs (N+1)
ProjectionSelect / ProjectTo✅ Preferido

2.6.2. Eager loading

var products = await _db.Products
    .Include(p => p.Category)
    .Include(p => p.Reviews)
        .ThenInclude(r => r.User)
    .ToListAsync();

2.6.3. Split queries (evita cartesian explosion)

var products = await _db.Products
    .Include(p => p.Reviews)
    .AsSplitQuery()
    .ToListAsync();

2.6.4. Proyección directa (mejor rendimiento)

var dtos = await _db.Products
    .AsNoTracking()
    .Select(p => new ProductDto
    {
        Id = p.Id,
        Name = p.Name,
        Price = p.Price,
        CategoryName = p.Category!.Name
    })
    .ToListAsync();

2.6.5. Filtros y ordenamiento dinámico

public async Task<IEnumerable<ProductDto>> SearchAsync(
    string? name, int? categoryId, decimal? minPrice, decimal? maxPrice,
    string? sortBy, bool desc, int page, int size, CancellationToken ct)
{
    var query = _db.Products.AsNoTracking().AsQueryable();

    if (!string.IsNullOrWhiteSpace(name))
        query = query.Where(p => EF.Functions.Like(p.Name, $"%{name}%"));

    if (categoryId.HasValue)
        query = query.Where(p => p.CategoryId == categoryId.Value);

    if (minPrice.HasValue) query = query.Where(p => p.Price >= minPrice.Value);
    if (maxPrice.HasValue) query = query.Where(p => p.Price <= maxPrice.Value);

    query = sortBy?.ToLower() switch
    {
        "price" => desc ? query.OrderByDescending(p => p.Price) : query.OrderBy(p => p.Price),
        "name"  => desc ? query.OrderByDescending(p => p.Name)  : query.OrderBy(p => p.Name),
        _       => query.OrderBy(p => p.Id)
    };

    return await query.Skip((page - 1) * size).Take(size)
        .Select(p => new ProductDto { /* ... */ }).ToListAsync(ct);
}

2.6.6. Relaciones

1 a N

public class Category
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public ICollection<Product> Products { get; set; } = new List<Product>();
}

N a N (EF Core 5+ sin tabla intermedia explícita)

public class Product
{
    public int Id { get; set; }
    public ICollection<Tag> Tags { get; set; } = new List<Tag>();
}

public class Tag
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public ICollection<Product> Products { get; set; } = new List<Product>();
}

// Configuración
modelBuilder.Entity<Product>()
    .HasMany(p => p.Tags)
    .WithMany(t => t.Products)
    .UsingEntity(j => j.ToTable("ProductTags"));

1 a 1

modelBuilder.Entity<Product>()
    .HasOne(p => p.Details)
    .WithOne(d => d.Product)
    .HasForeignKey<ProductDetails>(d => d.ProductId);

2.6.7. Consultas avanzadas

GroupBy

var stats = await _db.Products
    .GroupBy(p => p.Category!.Name)
    .Select(g => new { Category = g.Key, Count = g.Count(), Avg = g.Average(p => p.Price) })
    .ToListAsync();

Raw SQL (con parámetros, evita inyección)

var products = await _db.Products
    .FromSqlInterpolated($"SELECT * FROM Products WHERE Price > {minPrice}")
    .ToListAsync();

Compiled queries (rendimiento)

private static readonly Func<AppDbContext, int, Task<Product?>> _getById =
    EF.CompileAsyncQuery((AppDbContext db, int id) =>
        db.Products.FirstOrDefault(p => p.Id == id));

2.6.8. Rendimiento — checklist

✅ Buenas prácticas
  • AsNoTracking() en lecturas.
  • Proyección a DTO (no traer entidades completas).
  • Índices en columnas filtradas/ordenadas.
  • AsSplitQuery() cuando hay múltiples Include de colecciones.
  • Paginación siempre (nunca ToList() sin límite).
❌ Evitar
  • Lazy loading.
  • N+1 (consultas dentro de bucles).
⚠ Errores comunes
  • N+1 queries por lazy loading o bucles.
  • Include innecesarios (cartesian explosion).
  • No paginar listados.
  • Where después de ToList() (filtra en memoria).
  • Usar Contains con listas gigantes (usar Any o Join).
7

Prueba y documentación de APIs

15 min
Objetivo Documentar con Swagger/OpenAPI y probar endpoints con herramientas modernas.

2.7.1. Swagger / OpenAPI

Instalación

dotnet add package Swashbuckle.AspNetCore

Configuración

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
    options.SwaggerDoc("v1", new OpenApiInfo
    {
        Title = "Demo API",
        Version = "v1",
        Description = "API de productos y categorías",
        Contact = new OpenApiContact { Name = "Equipo .NET", Email = "dev@demo.com" }
    });

    // Autenticación Bearer
    options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
    {
        Type = SecuritySchemeType.Http,
        Scheme = "bearer",
        BearerFormat = "JWT",
        Description = "Introduce el token JWT"
    });

    options.AddSecurityRequirement(new OpenApiSecurityRequirement
    {
        {
            new OpenApiSecurityScheme
            {
                Reference = new OpenApiReference
                {
                    Type = ReferenceType.SecurityScheme, Id = "Bearer"
                }
            },
            Array.Empty<string>()
        }
    });

    // XML comments
    var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
    var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
    if (File.Exists(xmlPath)) options.IncludeXmlComments(xmlPath);
});

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI(c =>
    {
        c.SwaggerEndpoint("/swagger/v1/swagger.json", "Demo API v1");
        c.RoutePrefix = "swagger";
    });
}

Habilitar XML docs en .csproj

<PropertyGroup>
  <GenerateDocumentationFile>true</GenerateDocumentationFile>
  <NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup>

Comentarios XML

/// <summary>Obtiene un producto por su identificador.</summary>
/// <param name="id">ID del producto.</param>
/// <returns>El producto solicitado.</returns>
/// <response code="200">Producto encontrado.</response>
/// <response code="404">Producto no encontrado.</response>
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(ProductDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ProductDto>> GetById(int id) { /* ... */ }

2.7.2. Herramientas de prueba

Ejemplo .http

### Listar productos
GET https://localhost:5001/api/products?page=1&pageSize=10
Accept: application/json

### Crear producto
POST https://localhost:5001/api/products
Content-Type: application/json
Authorization: Bearer {{token}}

{
  "name": "Laptop",
  "price": 1299.99,
  "stock": 10,
  "categoryId": 1
}

2.7.3. Pruebas automatizadas de integración (bonus)

dotnet add package Microsoft.AspNetCore.Mvc.Testing
public class ProductsApiTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;
    public ProductsApiTests(WebApplicationFactory<Program> factory)
    {
        _client = factory.CreateClient();
    }

    [Fact]
    public async Task GET_products_returns_200()
    {
        var response = await _client.GetAsync("/api/products");
        response.StatusCode.Should().Be(HttpStatusCode.OK);
    }
}

2.7.4. Health checks

builder.Services.AddHealthChecks()
    .AddDbContextCheck<AppDbContext>();

app.MapHealthChecks("/health");

2.7.5. Versionado de API

dotnet add package Asp.Versioning.Mvc
builder.Services.AddApiVersioning(options =>
{
    options.DefaultApiVersion = new ApiVersion(1, 0);
    options.AssumeDefaultVersionWhenUnspecified = true;
    options.ReportApiVersions = true;
    options.ApiVersionReader = new UrlSegmentApiVersionReader();
});

[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/[controller]")]
public class ProductsController : ControllerBase { }
⚠ Errores comunes
  • No documentar códigos de error.
  • Dejar Swagger habilitado en producción sin protección.
  • No versionar la API antes del primer cambio disruptivo.
  • Olvidar documentar autenticación.
  • No incluir ejemplos en el schema.
🧪

Proyecto integrador del Módulo 2

🎯 Construir una API REST de Biblioteca con:
  • Entidades: Author, Book, Loan.
  • Relaciones: Author 1—N Book, Book 1—N Loan.
  • DTOs separados para Create/Update/Read.
  • Validaciones con DataAnnotations + una regla personalizada.
  • AppDbContext configurado con IEntityTypeConfiguration.
  • Migraciones aplicadas + seed data.
  • Servicios (IBookService, IAuthorService) con async/await y CancellationToken.
  • Controladores delgados con ActionResult<T> y status codes correctos.
  • Paginación, filtros y ordenamiento en el listado de libros.
  • Swagger con XML comments, autenticación Bearer y ejemplos.
  • Archivo .http con casos de prueba.
  • Health check en /health.

Rúbrica (12 puntos)

CriterioPuntos
Diseño REST correcto1
Controladores delgados1
DTOs + validaciones2
DbContext + migraciones2
CRUD funcional2
Relaciones + consultas eficientes2
Documentación Swagger1
Pruebas .http + health check1
Total: 12 puntos
📌

Buenas prácticas transversales

TemaRecomendación
DiseñoREST nivel 2, sustantivos plurales, versionado desde v1
ControladoresDelgados, ActionResult<T>, CancellationToken
DTOsSiempre separar de entidades; nunca exponer entidades
ValidaciónDataAnnotations para casos simples, FluentValidation para complejos
EF CoreAsNoTracking, proyección, paginación, split queries
RendimientoEvitar N+1, lazy loading, ToList sin filtros
ErroresProblemDetails + status codes semánticos
DocumentaciónSwagger con XML comments y ejemplos
Testing.http + integración con WebApplicationFactory