Basado en .NET 8+ con controladores (Controller-based APIs)
1. Estructura del Proyecto
src/
├── MyApi/
│ ├── Controllers/ # Lógica HTTP (fina, delega a servicios)
│ ├── Models/
│ │ ├── Domain/ # Entidades de negocio / EF Core
│ │ └── Dtos/ # Objetos de transferencia (Requests/Responses)
│ ├── Services/ # Lógica de negocio
│ ├── Data/ # DbContext, migraciones, repositorios
│ ├── Middleware/ # Middleware personalizado
│ ├── Filters/ # Filtros de acción/excepción
│ ├── Validators/ # FluentValidation
│ ├── Mapping/ # AutoMapper profiles
│ ├── Extensions/ # Métodos de extensión para IServiceCollection
│ ├── Options/ # Clases de configuración (IOptions<T>)
│ └── Program.cs
└── tests/
└── MyApi.Tests/
2. Program.cs – Configuración Mínima y Ordenada
var builder = WebApplication.CreateBuilder(args);
// ── Servicios ──────────────────────────────────────────
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// Extensiones propias (métodos estáticos que extienden IServiceCollection)
builder.Services
.AddDatabase(builder.Configuration)
.AddApplicationServices()
.AddJwtAuthentication(builder.Configuration)
.AddApiVersioningConfig()
.AddCorsPolicy()
.AddFluentValidation()
.AddAutoMapper();
var app = builder.Build();
// ── Middleware (orden importa) ─────────────────────────
app.UseExceptionHandler(); // 1. Manejo global de errores
app.UseHttpsRedirection(); // 2. Redirigir HTTP → HTTPS
app.UseCors("AllowSpecificOrigins"); // 3. CORS antes de Auth
app.UseAuthentication(); // 4. Autenticación
app.UseAuthorization(); // 5. Autorización
app.MapControllers(); // 6. Endpoints
app.Run();
Regla de oro: Cada builder.Services.AddXxx() debe ser un método de extensión en Extensions/. No llenar Program.cs de ruido.
3. RESTful – Nombrado de Endpoints
| Método | URL | Acción |
|---|---|---|
| GET | /api/v1/products |
Listar productos |
| GET | /api/v1/products/{id} |
Obtener uno |
| POST | /api/v1/products |
Crear |
| PUT | /api/v1/products/{id} |
Reemplazar completo |
| PATCH | /api/v1/products/{id} |
Actualizar parcial |
| DELETE | /api/v1/products/{id} |
Eliminar |
Principios clave:
- Sustantivos en plural, jamás verbos (
/products, no/getProducts). - Usa sub-recursos para relaciones:
/api/v1/orders/{id}/items. - Evita más de 2 niveles de anidamiento.
- Prefiere query strings para filtros:
/products?category=electronics&page=1&size=10.
4. Controller – Delgado, Delega al Servicio
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[Produces("application/json")]
public class ProductsController : ControllerBase
{
private readonly IProductService _service;
public ProductsController(IProductService service)
=> _service = service;
/// <summary>Obtiene una lista paginada de productos.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<ProductDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> Get([FromQuery] ProductFilterParams filter,
CancellationToken ct)
{
var result = await _service.GetAllAsync(filter, ct);
return Ok(result);
}
/// <summary>Obtiene un producto por su ID.</summary>
[HttpGet("{id:guid}")]
[ProducesResponseType(typeof(ProductDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetById(Guid id, CancellationToken ct)
{
var product = await _service.GetByIdAsync(id, ct);
return product is null ? NotFound() : Ok(product);
}
/// <summary>Crea un nuevo producto.</summary>
[HttpPost]
[ProducesResponseType(typeof(ProductDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> Create([FromBody] CreateProductDto dto,
CancellationToken ct)
{
var created = await _service.CreateAsync(dto, ct);
return CreatedAtAction(nameof(GetById), new { id = created.Id, version = "1" }, created);
}
}
Prácticas esenciales:
[ApiController]activa validación automática de modelos y binding.- Usa
IAsyncEnumerablepara streaming:[HttpGet("stream")] async IAsyncEnumerable<ProductDto> Stream(...). - Inyecta
CancellationTokenen cada acción y propágalo a EF Core. - Siempre documenta responses con
[ProducesResponseType]para Swagger.
5. Respuesta Estandarizada
public class ApiResponse<T>
{
public bool Success { get; set; }
public T? Data { get; set; }
public string? Message { get; set; }
public IEnumerable<string>? Errors { get; set; }
public static ApiResponse<T> Ok(T data, string? message = null)
=> new() { Success = true, Data = data, Message = message };
public static ApiResponse<T> Fail(string message, IEnumerable<string>? errors = null)
=> new() { Success = false, Message = message, Errors = errors };
}
Para paginación:
public class PagedResponse<T>
{
public int Page { get; set; }
public int PageSize { get; set; }
public int TotalCount { get; set; }
public int TotalPages { get; set; }
public bool HasPrevious => Page > 1;
public bool HasNext => Page < TotalPages;
public IEnumerable<T> Data { get; set; } = [];
}
6. DTOs – Nunca Expongas Entidades
// ── Solicitud ─────────────────────
public record CreateProductDto(
string Name,
decimal Price,
Guid CategoryId
);
// ── Respuesta ─────────────────────
public record ProductDto(
Guid Id,
string Name,
decimal Price,
string CategoryName,
DateTime CreatedAt
);
- Usa
recordpara DTOs (inmutabilidad, comparación por valor, sintaxis concisa). - Los DTOs de solicitud llevan solo campos necesarios para la operación.
- Los DTOs de respuesta pueden aplanar datos de relaciones (ej.
CategoryNameen vez deCategoryentera).
7. Validación con FluentValidation
⚠️ Ojo: el paquete
FluentValidation.AspNetCoreestá deprecado y sin mantenimiento (lo dice la documentación oficial de FluentValidation). UsaFluentValidation.DependencyInjectionExtensionspara el registro de validadores y gestiona la validación con un filtro propio o manualmente.
dotnet add package FluentValidation.DependencyInjectionExtensions
public class CreateProductValidator : AbstractValidator<CreateProductDto>
{
public CreateProductValidator()
{
RuleFor(x => x.Name)
.NotEmpty().WithMessage("El nombre es obligatorio")
.MaximumLength(150);
RuleFor(x => x.Price)
.GreaterThan(0).WithMessage("El precio debe ser mayor a 0");
RuleFor(x => x.CategoryId)
.NotEmpty();
}
}
Registro:
// Extensions/FluentValidationExtensions.cs
public static IServiceCollection AddFluentValidation(this IServiceCollection services)
{
services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
return services;
}
IMPORTANTE: Deshabilita la validación por defecto de ASP.NET Core para evitar doble respuesta:
builder.Services.Configure<ApiBehaviorOptions>(opt =>
{
opt.SuppressModelStateInvalidFilter = true;
});
Y usa un filtro de validación personalizado que retorne ApiResponse<T>.
8. Manejo Global de Errores
// Middleware/ExceptionMiddleware.cs
public class ExceptionMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionMiddleware> _logger;
public ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (NotFoundException ex)
{
context.Response.StatusCode = 404;
await WriteProblem(context, ex.Message);
}
catch (ValidationException ex)
{
context.Response.StatusCode = 400;
var errors = ex.Errors.Select(e => e.ErrorMessage);
await context.Response.WriteAsJsonAsync(ApiResponse<object>.Fail("Error de validación", errors));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error no controlado: {Path}", context.Request.Path);
context.Response.StatusCode = 500;
await WriteProblem(context, "Error interno del servidor.");
}
}
private static async Task WriteProblem(HttpContext context, string detail)
{
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = context.Response.StatusCode,
Title = "Error",
Detail = detail,
Instance = context.Request.Path
});
}
}
Registro:
// Program.cs
app.UseMiddleware<ExceptionMiddleware>();
Excepciones personalizadas: Crea NotFoundException, BadRequestException, ForbiddenException, etc.
9. Autenticación JWT
// Extensions/JwtExtensions.cs
public static IServiceCollection AddJwtAuthentication(
this IServiceCollection services, IConfiguration config)
{
var jwtSettings = config.GetSection("Jwt").Get<JwtOptions>()
?? throw new InvalidOperationException("JWT config faltante");
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(opt =>
{
opt.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtSettings.Issuer,
ValidAudience = jwtSettings.Audience,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(jwtSettings.Secret))
};
});
services.AddAuthorization();
return services;
}
// appsettings.json
{
"Jwt": {
"Secret": "SuperSecretKey-AtLeast-32-Chars-Long-!",
"Issuer": "MyApi",
"Audience": "MyApp",
"ExpirationMinutes": 60
}
}
NUNCA hardcodees el secret. Usa dotnet user-secrets en desarrollo y variables de entorno en producción.
10. Versionado de API
dotnet add package Asp.Versioning.Mvc
dotnet add package Asp.Versioning.Mvc.ApiExplorer
// Extensions/ApiVersioningExtensions.cs
public static IServiceCollection AddApiVersioningConfig(this IServiceCollection services)
{
services.AddApiVersioning(opt =>
{
opt.DefaultApiVersion = new ApiVersion(1, 0);
opt.AssumeDefaultVersionWhenUnspecified = true;
opt.ReportApiVersions = true;
opt.ApiVersionReader = ApiVersionReader.Combine(
new UrlSegmentApiVersionReader(), // /api/v1/products
new HeaderApiVersionReader("x-api-version"),
new QueryStringApiVersionReader("api-version"));
}).AddApiExplorer(opt =>
{
opt.GroupNameFormat = "'v'VVV";
opt.SubstituteApiVersionInUrl = true;
});
return services;
}
11. AutoMapper
dotnet add package AutoMapper
// Mapping/MappingProfile.cs
public class MappingProfile : Profile
{
public MappingProfile()
{
CreateMap<Product, ProductDto>()
.ForMember(d => d.CategoryName, o => o.MapFrom(s => s.Category.Name));
CreateMap<CreateProductDto, Product>();
}
}
Registro:
// Program.cs
builder.Services.AddAutoMapper(Assembly.GetExecutingAssembly());
Recomendación: Usa record y init-only properties si quieres evitar AutoMapper y hacer mapping manual (más rendimiento). Ten en cuenta además que AutoMapper es comercial desde la v13 (licencia de Lucky Penny), lo que refuerza el mapping manual en proyectos nuevos.
12. Entity Framework Core y Repositorio
// Data/AppDbContext.cs
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.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());
}
}
Configuración por entidad (no atributos, no fluida en OnModelCreating):
// Data/Configurations/ProductConfiguration.cs
public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
public void Configure(EntityTypeBuilder<Product> builder)
{
builder.HasKey(p => p.Id);
builder.Property(p => p.Name).IsRequired().HasMaxLength(150);
builder.Property(p => p.Price).HasColumnType("decimal(18,2)");
builder.HasOne(p => p.Category).WithMany(c => c.Products).HasForeignKey(p => p.CategoryId);
}
}
Repositorio genérico:
// Data/Repositories/IRepository.cs
public interface IRepository<T> where T : class
{
Task<T?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<IReadOnlyList<T>> GetAllAsync(CancellationToken ct = default);
Task<T> AddAsync(T entity, CancellationToken ct = default);
Task UpdateAsync(T entity);
Task DeleteAsync(T entity);
Task<bool> SaveChangesAsync(CancellationToken ct = default);
}
Patrón a debatir: Si usas EF Core directamente desde el servicio (sin repositorio), no hay problema. El DbContext ya implementa repositorio + unidad de trabajo. Usa repositorio solo si tienes lógica de consulta compleja reutilizable.
13. Filtros, Ordenamiento y Paginación desde Query
public class ProductFilterParams : PaginationParams
{
public string? Search { get; set; }
public string? SortBy { get; set; }
public string? SortOrder { get; set; } = "asc";
public Guid? CategoryId { get; set; }
public decimal? MinPrice { get; set; }
public decimal? MaxPrice { get; set; }
}
public abstract class PaginationParams
{
private const int MaxPageSize = 100;
private int _pageSize = 10;
public int Page { get; set; } = 1;
public int PageSize
{
get => _pageSize;
set => _pageSize = value > MaxPageSize ? MaxPageSize : value;
}
}
En el servicio:
public async Task<PagedResponse<ProductDto>> GetAllAsync(ProductFilterParams filter,
CancellationToken ct)
{
var query = _context.Products.AsNoTracking();
if (!string.IsNullOrWhiteSpace(filter.Search))
query = query.Where(p => p.Name.Contains(filter.Search));
if (filter.CategoryId.HasValue)
query = query.Where(p => p.CategoryId == filter.CategoryId.Value);
// Ordenamiento
query = filter.SortBy?.ToLower() switch
{
"name" => filter.SortOrder == "desc"
? query.OrderByDescending(p => p.Name)
: query.OrderBy(p => p.Name),
"price" => filter.SortOrder == "desc"
? query.OrderByDescending(p => p.Price)
: query.OrderBy(p => p.Price),
_ => query.OrderBy(p => p.CreatedAt)
};
var total = await query.CountAsync(ct);
var items = await query
.Skip((filter.Page - 1) * filter.PageSize)
.Take(filter.PageSize)
.ProjectTo<ProductDto>(_mapper.ConfigurationProvider)
.ToListAsync(ct);
return new PagedResponse<ProductDto>
{
Page = filter.Page,
PageSize = filter.PageSize,
TotalCount = total,
TotalPages = (int)Math.Ceiling(total / (double)filter.PageSize),
Data = items
};
}
14. Caching (Response Caching / In-Memory)
dotnet add package Microsoft.Extensions.Caching.Memory
Respuesta cacheada de 60 segundos en un endpoint:
[HttpGet("{id:guid}")]
[ResponseCache(Duration = 60, Location = ResponseCacheLocation.Any, VaryByQueryKeys = ["id"])]
public async Task<IActionResult> GetById(Guid id, CancellationToken ct)
{
// ...
}
Para caché distribuido usar IDistributedCache con Redis:
dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis
15. Rate Limiting (.NET 7+)
// Program.cs
builder.Services.AddRateLimiter(opt =>
{
opt.RejectionStatusCode = 429;
opt.AddFixedWindowLimiter("fixed", cfg =>
{
cfg.PermitLimit = 100;
cfg.Window = TimeSpan.FromMinutes(1);
cfg.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
cfg.QueueLimit = 0;
});
});
// Antes de MapControllers
app.UseRateLimiter();
Aplicar a un controlador:
[EnableRateLimiting("fixed")]
public class ProductsController : ControllerBase { ... }
16. Health Checks
dotnet add package AspNetCore.HealthChecks.UI.Client # para UIResponseWriter
// Program.cs
builder.Services
.AddHealthChecks()
.AddDbContextCheck<AppDbContext>("database")
.AddUrlGroup(new Uri("https://external-api.com/health"), "external-api");
// ...
app.MapHealthChecks("/health", new HealthCheckOptions
{
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});
17. Swagger / OpenAPI
// Program.cs
builder.Services.AddSwaggerGen(opt =>
{
opt.SwaggerDoc("v1", new OpenApiInfo { Title = "My API", Version = "v1" });
// Incluir comentarios XML
var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
opt.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, xmlFile));
// Seguridad JWT en Swagger
opt.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Description = "JWT: Bearer {token}",
Name = "Authorization",
In = ParameterLocation.Header,
Type = SecuritySchemeType.ApiKey
});
opt.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "Bearer"
}
},
Array.Empty<string>()
}
});
});
Habilita XML docs en el .csproj:
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn>
18. CORS
// Extensions/CorsExtensions.cs
public static IServiceCollection AddCorsPolicy(this IServiceCollection services)
{
services.AddCors(opt =>
{
opt.AddPolicy("AllowSpecificOrigins", builder =>
{
builder
.WithOrigins("https://miapp.com", "https://admin.miapp.com")
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials();
});
});
return services;
}
Jamás uses .AllowAnyOrigin() con .AllowCredentials() en producción.
19. Serilog – Logging Estructurado
dotnet add package Serilog.AspNetCore
// Program.cs (reemplazar builder.Host)
builder.Host.UseSerilog((ctx, cfg) =>
{
cfg.ReadFrom.Configuration(ctx.Configuration)
.Enrich.FromLogContext()
.Enrich.WithMachineName()
.WriteTo.Console();
});
// appsettings.json
{
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information",
"Microsoft.EntityFrameworkCore": "Warning"
}
}
}
}
20. Métricas y Observabilidad – OpenTelemetry
dotnet add package OpenTelemetry.Extensions.Hosting
dotnet add package OpenTelemetry.Instrumentation.AspNetCore
// Program.cs
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics => metrics
.AddAspNetCoreInstrumentation()
.AddPrometheusExporter());
21. Configuración Tipada (Options Pattern)
// Options/JwtOptions.cs
public class JwtOptions
{
public const string SectionName = "Jwt";
public string Secret { get; init; } = string.Empty;
public string Issuer { get; init; } = string.Empty;
public string Audience { get; init; } = string.Empty;
public int ExpirationMinutes { get; init; }
}
// Program.cs
builder.Services.Configure<JwtOptions>(builder.Configuration.GetSection(JwtOptions.SectionName));
Consumo vía IOptionsSnapshot<T>:
public class AuthService
{
private readonly JwtOptions _jwt;
public AuthService(IOptionsSnapshot<JwtOptions> jwtOptions)
=> _jwt = jwtOptions.Value;
}
22. Pruebas Unitarias con xUnit + Moq
public class ProductsControllerTests
{
private readonly Mock<IProductService> _serviceMock;
private readonly ProductsController _sut;
public ProductsControllerTests()
{
_serviceMock = new Mock<IProductService>();
_sut = new ProductsController(_serviceMock.Object);
}
[Fact]
public async Task GetById_WhenProductExists_ReturnsOk()
{
// Arrange
var id = Guid.NewGuid();
var dto = new ProductDto(id, "Laptop", 1200m, "Electrónica", DateTime.UtcNow);
_serviceMock.Setup(s => s.GetByIdAsync(id, It.IsAny<CancellationToken>()))
.ReturnsAsync(dto);
// Act
var result = await _sut.GetById(id, CancellationToken.None);
// Assert
var ok = result.Result as OkObjectResult;
ok.Should().NotBeNull();
ok!.Value.Should().Be(dto);
}
}
23. Checklist de Buenas Prácticas (Resumen)
| # | Práctica |
|---|---|
| 1 | Controladores delgados, lógica en servicios |
| 2 | Siempre usar DTOs, nunca exponer entidades |
| 3 | Respuesta estandarizada con ApiResponse<T> |
| 4 | Manejo global de errores con middleware |
| 5 | Validación con FluentValidation |
| 6 | Autenticación JWT + Autorización basada en roles/policies |
| 7 | Versionado desde el día 1 |
| 8 | Documentación con Swagger y XML comments |
| 9 | Paginación, filtrado y ordenamiento en todo GET de colección |
| 10 | CORS restrictivo por origen |
| 11 | Rate limiting en endpoints públicos |
| 12 | Health checks para monitoreo |
| 13 | Logging estructurado (Serilog) |
| 14 | Configuración tipada con IOptions<T> |
| 15 | CancellationToken en toda operación asíncrona |
| 16 | Usar ProblemDetails para errores HTTP estándar |
| 17 | AsNoTracking() en consultas de solo lectura de EF Core |
| 18 | NUNCA hardcodear secrets, URLs ni connection strings |
| 19 | Tests unitarios para servicios, tests de integración para endpoints |
| 20 | Medir con OpenTelemetry + Prometheus + Grafana |