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:
Recursos identificados por URI (/api/products/5).
Verbos HTTP como acciones (GET, POST, PUT, PATCH, DELETE).
Representaciones (JSON, XML, HAL).
Sin estado (stateless): cada request contiene todo lo necesario.
Interfaz uniforme y HATEOAS (opcional en la práctica).
2.1.2. Verbos HTTP y semántica
Verbo
Propósito
Idempotente
Seguro
GET
Leer recurso
Sí
Sí
POST
Crear recurso
No
No
PUT
Reemplazar recurso
Sí
No
PATCH
Modificar parcialmente
No*
No
DELETE
Eliminar recurso
Sí
No
2.1.3. Códigos de estado relevantes
2xx200 OK, 201 Created, 204 No Content
4xx400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found, 409 Conflict, 422 Unprocessable Entity
5xx500 Internal Server Error
2.1.4. Niveles de madurez de Richardson
Nivel 0: HTTP como túnel (SOAP).
Nivel 1: Recursos.
Nivel 2: Verbos HTTP + status codes. ← Meta práctica.
Nivel 3: HATEOAS.
2.1.5. Convenciones de nomenclatura
Sustantivos en plural: /api/products, no /api/getProducts.
Jerarquía: /api/categories/3/products.
Filtros como query string: /api/products?category=3&page=2.
IActionResult: cualquier respuesta, sin tipo declarado.
ActionResult<T>: tipado fuerte, ideal para APIs. ← Recomendado.
2.2.4. Controlador delgado vs grueso
✅ Delgado: delega a servicios; solo orquesta HTTP.
❌ Grueso: contiene lógica de negocio o acceso a datos.
⚠ 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 classProduct
{
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 - Createpublic classCreateProductDto
{
[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 - Updatepublic classUpdateProductDto
{
[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; }
}
// Salidapublic classProductDto
{
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?
Evitan over-posting (cliente enviando Id o IsAdmin).
Desacoplan contrato público de esquema de BD.
Permiten evolución independiente.
Optimizan payloads (no enviar campos innecesarios).
Prevención de mass assignment attacks.
2.3.4. DataAnnotations comunes
Atributo
Uso
[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 classMinimumAgeAttribute : 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;
}
}
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 interfaceIProductService
{
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 classProductService : 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 recordPagedResult<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;
}
public classCategory
{
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 classProduct
{
public int Id { get; set; }
public ICollection<Tag> Tags { get; set; } = new List<Tag>();
}
public classTag
{
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"));