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

Buenas prácticas, seguridad y rendimiento

Desglose pedagógico completo: arquitectura, SOLID, autenticación JWT, OWASP Top 10, optimización de consultas, caching y observabilidad. Al finalizar, el estudiante podrá endurecer, optimizar y monitorear una API REST profesional.

1

Arquitectura y separación de responsabilidades

15 min
Objetivo Comprender patrones arquitectónicos y aplicarlos para lograr bajo acoplamiento y alta cohesión.

3.1.1. Capas clásicas de una API .NET

┌─────────────────────────────────────┐ │ API (Controllers / Minimal APIs) │ ← Presentación ├─────────────────────────────────────┤ │ Application (Services, DTOs, │ ← Casos de uso │ Validators, Mapping) │ ├─────────────────────────────────────┤ │ Domain (Entities, Value Objects, │ ← Reglas de negocio │ Interfaces, Domain Events) │ ├─────────────────────────────────────┤ │ Infrastructure (EF Core, │ ← Persistencia, externos │ Repositories, External APIs) │ └─────────────────────────────────────┘

3.1.2. Patrones arquitectónicos comunes

PatrónCuándo usarComplejidad
N-Capas tradicionalApps CRUD simplesBaja
Clean ArchitectureDominio complejo, testabilidadMedia-Alta
Hexagonal (Ports & Adapters)Múltiples fuentes de datosMedia
Vertical SliceEquipos pequeños, features por carpetaBaja-Media
CQRSLecturas/escrituras muy distintasAlta
MicroserviciosEscala independiente por dominioMuy Alta

3.1.3. Clean Architecture en la práctica

src/ ├── Demo.Domain/ ← Entidades, interfaces, sin dependencias ├── Demo.Application/ ← Casos de uso, DTOs, validadores │ (depende de Domain) ├── Demo.Infrastructure/ ← EF Core, repositorios, servicios externos │ (depende de Application) └── Demo.Api/ ← Controllers, DI, middleware (depende de Infrastructure)
💡 Regla de dependencia
Las capas internas no conocen las externas. Se comunican vía interfaces (inversión de dependencias).

Ejemplo de interfaz en Domain

// Demo.Domain/Repositories/IBookRepository.cs
public interface IBookRepository
{
    Task<Book?> GetByIdAsync(int id, CancellationToken ct = default);
    Task<IReadOnlyList<Book>> SearchAsync(string? title, CancellationToken ct = default);
    Task AddAsync(Book book, CancellationToken ct = default);
    Task UpdateAsync(Book book, CancellationToken ct = default);
    Task DeleteAsync(Book book, CancellationToken ct = default);
}

Implementación en Infrastructure

// Demo.Infrastructure/Repositories/BookRepository.cs
public class BookRepository : IBookRepository
{
    private readonly AppDbContext _db;
    public BookRepository(AppDbContext db) => _db = db;

    public Task<Book?> GetByIdAsync(int id, CancellationToken ct) =>
        _db.Books.Include(b => b.Author).FirstOrDefaultAsync(b => b.Id == id, ct);
    // ...
}

Casos de uso (Application)

// Demo.Application/Books/CreateBookHandler.cs
public class CreateBookHandler
{
    private readonly IBookRepository _repo;
    private readonly IUnitOfWork _uow;
    private readonly IValidator<CreateBookDto> _validator;

    public CreateBookHandler(IBookRepository repo, IUnitOfWork uow, IValidator<CreateBookDto> validator)
    {
        _repo = repo; _uow = uow; _validator = validator;
    }

    public async Task<BookDto> HandleAsync(CreateBookDto dto, CancellationToken ct)
    {
        await _validator.ValidateAndThrowAsync(dto, ct);
        var book = new Book(dto.Title, dto.AuthorId, dto.Isbn);
        await _repo.AddAsync(book, ct);
        await _uow.SaveChangesAsync(ct);
        return BookDto.FromEntity(book);
    }
}

3.1.4. Principios SOLID aplicados

PrincipioAplicación
SRPController solo HTTP, Service solo lógica, Repo solo datos
OCPExtender vía interfaces/nuevas implementaciones, no modificar existentes
LSPSubtipos sustituibles (evitar NotImplementedException)
ISPInterfaces pequeñas y específicas (IReader, IWriter)
DIPDepender de abstracciones, no de concretos
⚠ Errores comunes
  • Fat controllers con lógica de negocio.
  • Anemic domain model: entidades sin comportamiento.
  • Servicios que dependen directamente de DbContext.
  • Capas cruzadas (API → EF Core directo, saltando Application).
  • Sobre-arquitectura: Clean Architecture para un CRUD trivial.
2

Buenas prácticas de desarrollo

15 min
Objetivo Adoptar convenciones, patrones y herramientas que eleven la calidad del código.

3.2.1. Convenciones de código C#

3.2.2. .editorconfig (aplicar estilo automático)

root = true

[*.cs]
indent_style = space
indent_size = 4
end_of_line = lf
insert_final_newline = true
csharp_style_namespace_declarations = file_scoped:suggestion
dotnet_sort_system_directives_first = true
csharp_prefer_braces = true:warning

3.2.3. Analizadores y linters

<PropertyGroup>
  <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
  <Nullable>enable</Nullable>
  <AnalysisLevel>latest-recommended</AnalysisLevel>
  <EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
</PropertyGroup>
dotnet add package StyleCop.Analyzers
dotnet add package Roslynator.Analyzers

3.2.4. Async/await — reglas de oro

// ✅ async hasta el final (async all the way)
public async Task<BookDto> GetAsync(int id, CancellationToken ct)
{
    var book = await _repo.GetByIdAsync(id, ct);
    return book is null ? throw new NotFoundException() : BookDto.FromEntity(book);
}

// ❌ async void (excepto event handlers)
public async void DoWork() { }

// ❌ .Result / .Wait() (deadlocks)
var book = _repo.GetByIdAsync(id).Result;

// ✅ ConfigureAwait(false) en librerías (no en ASP.NET Core)
await _repo.GetByIdAsync(id, ct).ConfigureAwait(false);

3.2.5. Inmutabilidad y records

public record BookDto(int Id, string Title, string AuthorName);

public record CreateBookDto(string Title, int AuthorId, string Isbn);

3.2.6. Null safety

// Nullable reference types
public string? MiddleName { get; set; }  // puede ser null
public string FirstName { get; set; } = string.Empty;  // nunca null

// Operadores
var length = name?.Length ?? 0;
ArgumentNullException.ThrowIfNull(service);

3.2.7. Guard clauses y early returns

public async Task<BookDto> UpdateAsync(int id, UpdateBookDto dto, CancellationToken ct)
{
    ArgumentNullException.ThrowIfNull(dto);
    if (id <= 0) throw new ArgumentOutOfRangeException(nameof(id));

    var book = await _repo.GetByIdAsync(id, ct)
        ?? throw new NotFoundException($"Book {id} no existe");

    book.Rename(dto.Title);
    await _uow.SaveChangesAsync(ct);
    return BookDto.FromEntity(book);
}

3.2.8. Patrones de diseño útiles

PatrónAplicación en .NET
RepositoryAbstraer persistencia
Unit of WorkTransacción atómica (SaveChanges)
OptionsConfiguración tipada
FactoryCreación de objetos complejos
DecoratorAñadir logging/caché a servicios
StrategyAlgoritmos intercambiables
ResultEvitar excepciones para flujo normal

Result pattern

public record Result<T>(bool Success, T? Value, string? Error)
{
    public static Result<T> Ok(T value) => new(true, value, null);
    public static Result<T> Fail(string error) => new(false, default, error);
}

var result = await _service.CreateAsync(dto, ct);
return result.Success
    ? CreatedAtRoute(nameof(GetById), new { id = result.Value!.Id }, result.Value)
    : BadRequest(new { error = result.Error });

3.2.9. Testing (xUnit + FluentAssertions + Moq)

public class BookServiceTests
{
    [Fact]
    public async Task CreateAsync_InvalidDto_Throws()
    {
        var repo = Substitute.For<IBookRepository>();  // NSubstitute
        var uow = Substitute.For<IUnitOfWork>();
        var validator = new CreateBookValidator();
        var sut = new CreateBookHandler(repo, uow, validator);

        var dto = new CreateBookDto("", 0, "");

        await Assert.ThrowsAsync<ValidationException>(() => sut.HandleAsync(dto, default));
    }
}

3.2.10. Git y flujo de trabajo

CI/CD mínimo (GitHub Actions)

name: CI
on: [push, pull_request]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with: { dotnet-version: '8.0.x' }
      - run: dotnet restore
      - run: dotnet build --no-restore -c Release
      - run: dotnet test --no-build -c Release
⚠ Errores comunes
  • Ignorar warnings del compilador.
  • Mezclar responsabilidades (ver 3.1).
  • No escribir tests unitarios para lógica de negocio.
  • async void, .Result, .Wait().
  • Commitear secretos o bin/, obj/.
3

Autenticación y autorización

25 min
Objetivo Implementar autenticación basada en JWT y autorización por roles/policies.

3.3.1. Autenticación vs Autorización

ConceptoPreguntaEjemplo
Autenticación¿Quién eres?Login con usuario/contraseña
Autorización¿Qué puedes hacer?Solo admin puede borrar

3.3.2. Mecanismos en .NET

MecanismoCuándo usar
CookieApps MVC tradicionales, mismo dominio
JWT BearerAPIs, SPAs, móviles, microservicios
OAuth 2.0 / OIDCDelegación a proveedor (Azure AD, Google, Auth0)
API KeyServicio a servicio simple
CertificateAlta seguridad, M2M

3.3.3. JWT — estructura

HEADER.PAYLOAD.SIGNATURE eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9. eyJzdWIiOiIxMjMiLCJuYW1lIjoiSnVhbiIsInJvbGUiOiJBZG1pbiIsImV4cCI6MTcwMDAwMDAwMH0. SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
⚠️ Nunca guardar datos sensibles en el payload
Es solo Base64, no cifrado.

3.3.4. Instalación y configuración

dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
dotnet add package System.IdentityModel.Tokens.Jwt
// appsettings.json
"Jwt": {
  "Issuer": "https://demo.com",
  "Audience": "https://demo.com",
  "Key": "CLAVE_SUPER_SECRETA_MIN_32_CHARS_AQUI",
  "ExpirationMinutes": 60,
  "RefreshExpirationDays": 7
}
public class JwtSettings
{
    public string Issuer { get; set; } = string.Empty;
    public string Audience { get; set; } = string.Empty;
    public string Key { get; set; } = string.Empty;
    public int ExpirationMinutes { get; set; }
    public int RefreshExpirationDays { get; set; }
}

3.3.5. Generación de tokens

public interface ITokenService
{
    (string token, DateTime expiresAt) GenerateAccessToken(User user);
    string GenerateRefreshToken();
}

public class JwtTokenService : ITokenService
{
    private readonly JwtSettings _settings;
    public JwtTokenService(IOptions<JwtSettings> options) => _settings = options.Value;

    public (string, DateTime) GenerateAccessToken(User user)
    {
        var claims = new List<Claim>
        {
            new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
            new(JwtRegisteredClaimNames.Email, user.Email),
            new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
            new(ClaimTypes.Name, user.UserName),
            new(ClaimTypes.Role, user.Role)
        };

        var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_settings.Key));
        var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
        var expires = DateTime.UtcNow.AddMinutes(_settings.ExpirationMinutes);

        var token = new JwtSecurityToken(
            issuer: _settings.Issuer,
            audience: _settings.Audience,
            claims: claims,
            expires: expires,
            signingCredentials: creds);

        return (new JwtSecurityTokenHandler().WriteToken(token), expires);
    }

    public string GenerateRefreshToken() =>
        Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
}

3.3.6. Registro de autenticación

builder.Services.Configure<JwtSettings>(builder.Configuration.GetSection("Jwt"));
builder.Services.AddScoped<ITokenService, JwtTokenService>();

var jwtSettings = builder.Configuration.GetSection("Jwt").Get<JwtSettings>()!;

builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer = jwtSettings.Issuer,
            ValidAudience = jwtSettings.Audience,
            IssuerSigningKey = new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes(jwtSettings.Key)),
            ClockSkew = TimeSpan.Zero
        };

        options.Events = new JwtBearerEvents
        {
            OnAuthenticationFailed = ctx =>
            {
                if (ctx.Exception is SecurityTokenExpiredException)
                    ctx.Response.Headers.Append("Token-Expired", "true");
                return Task.CompletedTask;
            }
        };
    });

builder.Services.AddAuthorization();

3.3.7. Pipeline (orden crítico)

app.UseAuthentication();   // 1. ¿Quién eres?
app.UseAuthorization();    // 2. ¿Puedes?
app.MapControllers();

3.3.8. Endpoint de login

[HttpPost("login")]
[AllowAnonymous]
public async Task<IActionResult> Login([FromBody] LoginDto dto, IAuthService auth)
{
    var user = await auth.ValidateCredentialsAsync(dto.Email, dto.Password);
    if (user is null) return Unauthorized(new { error = "Credenciales inválidas" });

    var (token, expires) = _tokenService.GenerateAccessToken(user);
    var refresh = _tokenService.GenerateRefreshToken();
    await auth.SaveRefreshTokenAsync(user.Id, refresh);

    return Ok(new { accessToken = token, expiresAt = expires, refreshToken = refresh });
}

3.3.9. Hashing de contraseñas (¡crítico!)

// ✅ Usar BCrypt o Argon2 — nunca SHA/MD5 directo
dotnet add package BCrypt.Net-Next

var hash = BCrypt.Net.BCrypt.HashPassword(password);
var isValid = BCrypt.Net.BCrypt.Verify(password, hash);

3.3.10. Autorización basada en roles

[Authorize(Roles = "Admin")]
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id) { /* ... */ }

[Authorize(Roles = "Admin,Manager")]
[HttpPut("{id:int}")]
public async Task<IActionResult> Update(int id, UpdateBookDto dto) { /* ... */ }

3.3.11. Policies (más flexible)

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("OnlyAdults", policy =>
        policy.RequireClaim("Age")
              .RequireAssertion(ctx =>
                  int.TryParse(ctx.User.FindFirst("Age")?.Value, out var age) && age >= 18));

    options.AddPolicy("CanEditBooks", policy =>
        policy.RequireRole("Admin", "Editor")
              .RequireClaim("Permission", "books:write"));
});

[Authorize(Policy = "CanEditBooks")]
public async Task<IActionResult> Edit(int id) { /* ... */ }

3.3.12. Authorization handlers (para reglas complejas)

public class ResourceOwnerRequirement : IAuthorizationRequirement { }

public class ResourceOwnerHandler : AuthorizationHandler<ResourceOwnerRequirement, Book>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        ResourceOwnerRequirement requirement,
        Book resource)
    {
        var userId = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
        if (userId == resource.OwnerId.ToString())
            context.Succeed(requirement);
        return Task.CompletedTask;
    }
}

builder.Services.AddSingleton<IAuthorizationHandler, ResourceOwnerHandler>();

[Authorize]
[HttpPut("{id:int}")]
public async Task<IActionResult> Update(int id, UpdateBookDto dto)
{
    var book = await _repo.GetByIdAsync(id);
    var auth = await _authorizationService.AuthorizeAsync(User, book, new ResourceOwnerRequirement());
    if (!auth.Succeeded) return Forbid();
    // ...
}

3.3.13. Refresh tokens (patrón seguro)

[HttpPost("refresh")]
[AllowAnonymous]
public async Task<IActionResult> Refresh([FromBody] RefreshRequest req, IAuthService auth)
{
    var user = await auth.ValidateRefreshTokenAsync(req.RefreshToken);
    if (user is null) return Unauthorized();

    // Rotación: invalidar el viejo, emitir nuevo
    await auth.RevokeRefreshTokenAsync(req.RefreshToken);
    var (token, expires) = _tokenService.GenerateAccessToken(user);
    var newRefresh = _tokenService.GenerateRefreshToken();
    await auth.SaveRefreshTokenAsync(user.Id, newRefresh);

    return Ok(new { accessToken = token, expiresAt = expires, refreshToken = newRefresh });
}
⚠ Errores comunes
  • Guardar JWT en localStorage sin protección XSS.
  • Claves secretas hardcodeadas o cortas (<32 bytes).
  • ClockSkew por defecto (5 min) permite tokens expirados.
  • No rotar refresh tokens.
  • Usar SHA256/MD5 para contraseñas.
  • Olvidar UseAuthentication antes de UseAuthorization.
4

Protección de aplicaciones y APIs

20 min
Objetivo Aplicar defensas contra OWASP Top 10 y endurecer la API.

3.4.1. OWASP Top 10 (2021) y su mitigación en .NET

RiesgoMitigación en .NET
A01 Broken Access Control[Authorize], policies, validar ownership
A02 Cryptographic FailuresHTTPS, BCrypt/Argon2, Key Vault
A03 InjectionORM parametrizado, validación de entrada
A04 Insecure DesignThreat modeling, rate limiting
A05 Security MisconfigurationHeaders, CORS estricto, sin stack traces
A06 Vulnerable Componentsdotnet list package --vulnerable
A07 Auth FailuresMFA, lockout, tokens cortos
A08 Data Integrity FailuresFirmas, validar JWT
A09 Logging FailuresLogs estructurados + alertas
A10 SSRFWhitelist de URLs, no seguir redirects

3.4.2. HTTPS obligatorio

app.UseHsts();               // Strict-Transport-Security
app.UseHttpsRedirection();   // redirige HTTP → HTTPS

3.4.3. Security headers (middleware)

dotnet add package NetEscapades.AspNetCore.SecurityHeaders

app.UseSecurityHeaders(new HeaderPolicyCollection()
    .AddDefaultSecurityHeaders()
    .AddStrictTransportSecurityMaxAgeIncludeSubDomains()
    .AddContentSecurityPolicy(b => b
        .AddDefaultSrc("'self'")
        .AddScriptSrc("'self'")
        .AddStyleSrc("'self'"))
    .AddXssProtectionBlock()
    .AddFrameOptionsDeny()
    .RemoveServerHeader());

Headers resultantes

Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Content-Security-Policy: default-src 'self'
Referrer-Policy: no-referrer

3.4.4. CORS estricto

builder.Services.AddCors(options =>
{
    options.AddPolicy("Frontend", policy =>
        policy.WithOrigins("https://app.demo.com")
              .WithMethods("GET", "POST", "PUT", "DELETE")
              .WithHeaders("Authorization", "Content-Type")
              .AllowCredentials()
              .SetPreflightMaxAge(TimeSpan.FromHours(1)));
});

app.UseCors("Frontend");
⚠️ Nunca en producción
AllowAnyOrigin() + AllowCredentials().

3.4.5. Rate limiting (.NET 7+)

builder.Services.AddRateLimiter(options =>
{
    options.RejectionStatusCode = 429;

    options.AddFixedWindowLimiter("fixed", opt =>
    {
        opt.Window = TimeSpan.FromMinutes(1);
        opt.PermitLimit = 100;
        opt.QueueLimit = 0;
    });

    options.AddSlidingWindowLimiter("sliding", opt =>
    {
        opt.Window = TimeSpan.FromSeconds(30);
        opt.SegmentsPerWindow = 3;
        opt.PermitLimit = 60;
    });

    options.AddTokenBucketLimiter("token", opt =>
    {
        opt.TokenLimit = 100;
        opt.ReplenishmentPeriod = TimeSpan.FromSeconds(10);
        opt.TokensPerPeriod = 20;
    });

    options.AddPolicy("login", ctx =>
        RateLimitPartition.GetFixedWindowLimiter(
            ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown",
            _ => new FixedWindowRateLimiterOptions
            {
                PermitLimit = 5,
                Window = TimeSpan.FromMinutes(1)
            }));
});

app.UseRateLimiter();

[EnableRateLimiting("login")]
[HttpPost("login")]
public async Task<IActionResult> Login(...) { }

3.4.6. Validación de entrada (defensa contra injection)

// ✅ EF Core parametriza automáticamente
var books = await _db.Books.Where(b => b.Title == title).ToListAsync();

// ❌ NUNCA concatenar SQL
var sql = $"SELECT * FROM Books WHERE Title = '{title}'";  // SQL Injection
_db.Books.FromSqlRaw(sql);

// ✅ Si usas SQL raw, parametriza
_db.Books.FromSqlInterpolated($"SELECT * FROM Books WHERE Title = {title}");

3.4.7. Request size limits

builder.Services.Configure<KestrelServerOptions>(options =>
{
    options.Limits.MaxRequestBodySize = 10 * 1024 * 1024;  // 10 MB
});

3.4.8. Anti-forgery (para MVC con cookies)

builder.Services.AddAntiforgery(options => options.HeaderName = "X-CSRF-TOKEN");
app.UseAntiforgery();
// [ValidateAntiForgeryToken] en controladores

3.4.9. Data Protection (cifrado de datos sensibles)

builder.Services.AddDataProtection()
    .PersistKeysToFileSystem(new DirectoryInfo("/keys"))
    .SetApplicationName("DemoApp");

3.4.10. Secretos en producción (Azure Key Vault)

if (builder.Environment.IsProduction())
{
    var vaultUri = new Uri(builder.Configuration["KeyVault:Uri"]!);
    builder.Configuration.AddAzureKeyVault(
        vaultUri, new DefaultAzureCredential());
}

3.4.11. Cifrado de campos sensibles (EF Core Value Converters)

modelBuilder.Entity<User>()
    .Property(u => u.NationalId)
    .HasConversion(
        v => Encrypt(v),
        v => Decrypt(v));

3.4.12. Actualizar dependencias vulnerables

dotnet list package --vulnerable --include-transitive
dotnet list package --outdated
dotnet add package X --version Y

3.4.13. Container hardening (Dockerfile)

FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
USER app
WORKDIR /app
EXPOSE 8080

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish -c Release -o /app/publish

FROM base AS final
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "Demo.Api.dll"]

3.4.14. Checklist de seguridad

✅ Verificar antes de producción
  • HTTPS + HSTS.
  • JWT con clave fuerte y expiración corta.
  • Contraseñas con BCrypt/Argon2.
  • CORS restrictivo.
  • Rate limiting en endpoints sensibles.
  • Validación de entrada + EF Core parametrizado.
  • Security headers.
  • Sin stack traces en producción.
  • Secretos en Key Vault/User Secrets.
  • Dependencias auditadas.
  • Contenedor con usuario no-root.
  • Logs sin datos sensibles.
⚠ Errores comunes
  • Devolver ex.ToString() en producción.
  • Confiar en validación solo del cliente.
  • CORS abierto "para que funcione".
  • Rate limiting ausente en login.
  • Secretos en appsettings.json versionado.
5

Optimización de consultas y operaciones

15 min
Objetivo Detectar y eliminar cuellos de botella en el acceso a datos.

3.5.1. Reglas de oro EF Core

ReglaMotivo
AsNoTracking() en lecturasEvita overhead del change tracker
Proyección (Select/ProjectTo)Trae solo columnas necesarias
AsSplitQuery() con múltiples IncludeEvita cartesian explosion
Paginación siempreNo cargar tablas completas
Índices en columnas filtradasBúsquedas rápidas
Compilar queries calientesReduce parsing

3.5.2. Antes y después

// ❌ Antes: carga completa + N+1 + tracking
var orders = await _db.Orders.ToListAsync();     // sin paginar
foreach (var o in orders)
{
    var customer = await _db.Customers.FindAsync(o.CustomerId);  // N+1
    Console.WriteLine(customer.Name);            // tracking innecesario
}

// ✅ Después: proyección + Include + paginación + no tracking
var result = await _db.Orders
    .AsNoTracking()
    .Include(o => o.Customer)
    .OrderByDescending(o => o.CreatedAt)
    .Skip((page - 1) * size)
    .Take(size)
    .Select(o => new OrderDto
    {
        Id = o.Id,
        CustomerName = o.Customer!.Name,
        Total = o.Total
    })
    .ToListAsync(ct);

3.5.3. Detección de N+1

// Logging de EF Core en Development
options.UseSqlServer(conn)
       .LogTo(Console.WriteLine, LogLevel.Information)
       .EnableSensitiveDataLogging();

Salida esperada: una consulta con JOIN, no N+1.

3.5.4. Índices

modelBuilder.Entity<Book>(entity =>
{
    entity.HasIndex(b => b.Isbn).IsUnique();
    entity.HasIndex(b => b.Title);
    entity.HasIndex(b => new { b.AuthorId, b.PublishedYear });  // compuesto
});

3.5.5. Consultas compiladas

private static readonly Func<AppDbContext, int, Task<Book?>> _getBookById =
    EF.CompileAsyncQuery((AppDbContext db, int id) =>
        db.Books.Include(b => b.Author).FirstOrDefault(b => b.Id == id));

var book = await _getBookById(_db, id);

3.5.6. Bulk operations

dotnet add package EFCore.BulkExtensions

await _db.BulkInsertAsync(books);       // en lugar de AddRange + SaveChanges
await _db.BulkUpdateAsync(books);
await _db.BulkDeleteAsync(books);

3.5.7. Dapper para queries read-only complejas

dotnet add package Dapper

var sql = @"
    SELECT b.Id, b.Title, a.Name AS AuthorName,
           COUNT(l.Id) AS LoanCount
    FROM Books b
    INNER JOIN Authors a ON a.Id = b.AuthorId
    LEFT JOIN Loans l ON l.BookId = b.Id
    GROUP BY b.Id, b.Title, a.Name
    ORDER BY LoanCount DESC";

var report = await _connection.QueryAsync<BookReportDto>(sql);

3.5.8. Proyección con records

var dtos = await _db.Books
    .AsNoTracking()
    .Select(b => new BookDto(b.Id, b.Title, b.Author!.Name))
    .ToListAsync(ct);

3.5.9. Evitar Contains con listas grandes

// ❌ Lento con miles de IDs
var books = await _db.Books.Where(b => ids.Contains(b.Id)).ToListAsync();

// ✅ Mejor: usar temp table o Join con TVP

3.5.10. Transacciones explícitas

await using var tx = await _db.Database.BeginTransactionAsync(ct);
try
{
    await _db.Books.AddAsync(book, ct);
    await _db.SaveChangesAsync(ct);
    await _otherService.DoWorkAsync(ct);
    await tx.CommitAsync(ct);
}
catch
{
    await tx.RollbackAsync(ct);
    throw;
}

3.5.11. Concurrencia optimista

public class Book
{
    public int Id { get; set; }
    public string Title { get; set; } = string.Empty;

    [Timestamp]
    public byte[] RowVersion { get; set; } = Array.Empty<byte>();
}

// En el controlador
try { await _db.SaveChangesAsync(ct); }
catch (DbUpdateConcurrencyException)
{
    return Conflict(new { error = "El recurso fue modificado por otro usuario." });
}

3.5.12. Checklist de rendimiento

✅ Buenas prácticas
  • AsNoTracking en GET.
  • Proyección a DTO.
  • Paginación + OrderBy indexado.
  • Índices en columnas filtradas/ordenadas.
  • Sin N+1 (Include o proyección).
  • Sin lazy loading.
  • AsSplitQuery con colecciones múltiples.
  • Bulk ops para inserciones masivas.
  • CancellationToken propagado.
  • Concurrencia optimista donde aplique.
⚠ Errores comunes
  • ToList() antes de Where.
  • Count() + ToList() (usar una sola query).
  • Include de todo "por si acaso".
  • No usar AsNoTracking.
  • Faltan índices en FK.
6

Caching y rendimiento

15 min
Objetivo Aplicar estrategias de caché para reducir latencia y carga.

3.6.1. Niveles de caché

NivelHerramientaAlcanceVelocidad
Memoria (in-process)IMemoryCacheUna instanciaMuy rápida
DistribuidaIDistributedCache (Redis, SQL)Múltiples instanciasRápida
CDN / HTTPResponse caching, ETagCliente/proxyMuy rápida
Output cachingOutputCache (.NET 7+)ServidorRápida

3.6.2. IMemoryCache

builder.Services.AddMemoryCache();

public class BookService
{
    private readonly IMemoryCache _cache;
    public BookService(IMemoryCache cache) => _cache = cache;

    public async Task<BookDto?> GetByIdAsync(int id, CancellationToken ct)
    {
        var key = $"book:{id}";
        if (_cache.TryGetValue(key, out BookDto? cached))
            return cached;

        var book = await _repo.GetByIdAsync(id, ct);
        if (book is null) return null;

        var dto = BookDto.FromEntity(book);
        _cache.Set(key, dto, TimeSpan.FromMinutes(5));
        return dto;
    }
}

3.6.3. Caché distribuida (Redis)

dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis

builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = builder.Configuration.GetConnectionString("Redis");
    options.InstanceName = "DemoApp:";
});

public class DistributedBookService
{
    private readonly IDistributedCache _cache;
    public DistributedBookService(IDistributedCache cache) => _cache = cache;

    public async Task<BookDto?> GetByIdAsync(int id, CancellationToken ct)
    {
        var key = $"book:{id}";
        var json = await _cache.GetStringAsync(key, ct);
        if (json is not null)
            return JsonSerializer.Deserialize<BookDto>(json);

        var book = await _repo.GetByIdAsync(id, ct);
        if (book is null) return null;

        var dto = BookDto.FromEntity(book);
        await _cache.SetStringAsync(key, JsonSerializer.Serialize(dto),
            new DistributedCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10)
            }, ct);
        return dto;
    }
}

3.6.4. Output caching (.NET 7+)

builder.Services.AddOutputCache(options =>
{
    options.AddBasePolicy(b => b.Expire(TimeSpan.FromSeconds(30)));
    options.AddPolicy("books", b => b
        .Expire(TimeSpan.FromMinutes(5))
        .SetVaryByQuery("page", "size")
        .Tag("books"));
});

app.UseOutputCache();

[HttpGet]
[OutputCache(PolicyName = "books")]
public async Task<IActionResult> GetAll(int page, int size) { /* ... */ }

3.6.5. Invalidación de caché

public async Task<BookDto> UpdateAsync(int id, UpdateBookDto dto, CancellationToken ct)
{
    // ... actualizar en BD
    _cache.Remove($"book:{id}");
    _cache.Remove("books:all");
    return BookDto.FromEntity(book);
}

// Con tags en OutputCache
[HttpPut("{id:int}")]
public async Task<IActionResult> Update(int id, UpdateBookDto dto)
{
    // ...
    await _outputCacheStore.EvictByTagAsync("books", ct);
    return NoContent();
}

3.6.6. Estrategias de caché

EstrategiaCuándo usar
Cache-AsideLecturas frecuentes, escrituras esporádicas
Write-ThroughConsistencia fuerte requerida
Write-BehindAlto throughput de escritura
Refresh-AheadDatos con TTL predecible

3.6.7. HTTP caching (ETag, Cache-Control)

[HttpGet("{id:int}")]
[ResponseCache(Duration = 60, Location = ResponseCacheLocation.Any)]
public async Task<IActionResult> GetById(int id)
{
    var book = await _service.GetByIdAsync(id);
    if (book is null) return NotFound();

    var etag = $"\"{book.Version}\"";
    Response.Headers.ETag = etag;
    Response.Headers.CacheControl = "public, max-age=60";

    if (Request.Headers.IfNoneMatch == etag)
        return StatusCode(304);

    return Ok(book);
}
⚠ Consideraciones
  • No cachear datos sensibles (usuarios, tokens).
  • Definir TTL según volatilidad del dato.
  • Invalidar en escrituras (o usar TTL corto).
  • Considerar cache stampede (usar lock o SemaphoreSlim).
  • Cachear respuestas de error? — generalmente no, o TTL muy corto.
⚠ Errores comunes
  • Cachear sin TTL (datos obsoletos eternos).
  • Olvidar invalidar al actualizar.
  • Cachear respuestas autenticadas en caché compartida.
  • Cachear datos que cambian constantemente.
  • Confundir caché de memoria con distribuida en multi-instancia.
7

Diagnóstico y monitoreo

15 min
Objetivo Instrumentar la aplicación con logs, métricas, tracing y health checks.

3.7.1. Los tres pilares de la observabilidad

PilarQué respondeHerramientas .NET
Logs¿Qué pasó?ILogger, Serilog, Seq
Métricas¿Cuánto / cuántos?System.Diagnostics.Metrics, Prometheus
Tracing¿Dónde se gastó el tiempo?OpenTelemetry, Jaeger, Zipkin

3.7.2. Logging estructurado con Serilog

dotnet add package Serilog.AspNetCore
dotnet add package Serilog.Sinks.Seq
dotnet add package Serilog.Enrichers.Environment

builder.Host.UseSerilog((ctx, cfg) =>
    cfg.ReadFrom.Configuration(ctx.Configuration)
       .Enrich.FromLogContext()
       .Enrich.WithMachineName()
       .Enrich.WithEnvironmentName()
       .WriteTo.Console(new CompactJsonFormatter())
       .WriteTo.Seq("http://localhost:5341")
       .WriteTo.File("logs/app-.log",
           rollingInterval: RollingInterval.Day,
           retainedFileCountLimit: 7));

appsettings.json

{
  "Serilog": {
    "MinimumLevel": {
      "Default": "Information",
      "Override": {
        "Microsoft.AspNetCore": "Warning",
        "Microsoft.EntityFrameworkCore": "Warning"
      }
    }
  }
}

3.7.3. Logging estructurado — buenas prácticas

// ✅ Placeholders → propiedades buscables
_logger.LogInformation("Libro {BookId} prestado a {UserId} hasta {DueDate}",
    book.Id, userId, dueDate);

// ❌ Concatenación → no estructurado
_logger.LogInformation($"Libro {book.Id} prestado");

// ✅ Log scopes para contexto
using (_logger.BeginScope(new Dictionary<string, object>
{
    ["CorrelationId"] = correlationId,
    ["UserId"] = userId
}))
{
    _logger.LogInformation("Procesando préstamo");
}

3.7.4. Correlation ID (trazabilidad entre servicios)

app.Use(async (ctx, next) =>
{
    var correlationId = ctx.Request.Headers["X-Correlation-Id"].FirstOrDefault()
                       ?? Activity.Current?.Id
                       ?? Guid.NewGuid().ToString();

    ctx.Response.Headers["X-Correlation-Id"] = correlationId;

    using (LogContext.PushProperty("CorrelationId", correlationId))
        await next();
});

3.7.5. Métricas con System.Diagnostics.Metrics

dotnet add package OpenTelemetry.Extensions.Hosting
dotnet add package OpenTelemetry.Exporter.Prometheus.AspNetCore

public class BookMetrics
{
    private readonly Counter<long> _booksCreated;
    private readonly Histogram<double> _queryDuration;

    public BookMetrics(IMeterFactory meterFactory)
    {
        var meter = meterFactory.Create("DemoApp.Books");
        _booksCreated = meter.CreateCounter<long>("books.created");
        _queryDuration = meter.CreateHistogram<double>("books.query.duration", "ms");
    }

    public void RecordCreated() => _booksCreated.Add(1);
    public void RecordQuery(double ms) => _queryDuration.Record(ms);
}

3.7.6. OpenTelemetry (tracing distribuido)

dotnet add package OpenTelemetry.Extensions.Hosting
dotnet add package OpenTelemetry.Instrumentation.AspNetCore
dotnet add package OpenTelemetry.Instrumentation.Http
dotnet add package OpenTelemetry.Instrumentation.EntityFrameworkCore
dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol

builder.Services.AddOpenTelemetry()
    .ConfigureResource(r => r.AddService("Demo.Api"))
    .WithTracing(t => t
        .AddAspNetCoreInstrumentation()
        .AddHttpClientInstrumentation()
        .AddEntityFrameworkCoreInstrumentation()
        .AddOtlpExporter(o => o.Endpoint = new Uri("http://localhost:4317")))
    .WithMetrics(m => m
        .AddAspNetCoreInstrumentation()
        .AddHttpClientInstrumentation()
        .AddRuntimeInstrumentation()
        .AddPrometheusExporter());

app.MapPrometheusScrapingEndpoint();

3.7.7. Health checks

dotnet add package AspNetCore.HealthChecks.SqlServer
dotnet add package AspNetCore.HealthChecks.Redis

builder.Services.AddHealthChecks()
    .AddDbContextCheck<AppDbContext>("database", tags: new[] { "ready" })
    .AddRedis(connectionString, "redis", tags: new[] { "ready" })
    .AddCheck("self", () => HealthCheckResult.Healthy(), tags: new[] { "live" });

app.MapHealthChecks("/health/live", new HealthCheckOptions
{
    Predicate = r => r.Tags.Contains("live")
});

app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
    Predicate = r => r.Tags.Contains("ready"),
    ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});

3.7.8. Health checks UI (dashboard)

dotnet add package AspNetCore.HealthChecks.UI.Client

app.MapHealthChecks("/health", new HealthCheckOptions
{
    ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});

3.7.9. Application Insights (Azure)

dotnet add package Microsoft.ApplicationInsights.AspNetCore

builder.Services.AddApplicationInsightsTelemetry(options =>
{
    options.ConnectionString = builder.Configuration["ApplicationInsights:ConnectionString"];
});

3.7.10. Exceptionless / Sentry (alternativas)

dotnet add package Sentry.AspNetCore

builder.WebHost.UseSentry(o =>
{
    o.Dsn = builder.Configuration["Sentry:Dsn"];
    o.TracesSampleRate = 0.2;
    o.SendDefaultPii = false;  // no enviar PII
});

3.7.11. Alertas y SLOs

MétricaObjetivo típicoAlerta
Latencia p95< 300 ms> 500 ms por 5 min
Tasa de errores 5xx< 0.1%> 1% por 5 min
CPU< 70%> 85% por 10 min
Memoria< 80%> 90% por 5 min
Disponibilidad> 99.9%< 99.5% en 1 h

3.7.12. Checklist de observabilidad

✅ Antes de producción
  • Logs estructurados (JSON) con CorrelationId.
  • Niveles de log apropiados por entorno.
  • Sin datos sensibles en logs (PII, tokens, contraseñas).
  • Métricas de negocio + técnicas.
  • Tracing distribuido con OpenTelemetry.
  • Health checks: /health/live y /health/ready.
  • Alertas configuradas para SLOs.
  • Dashboard con métricas clave.
  • Retención de logs según política.
⚠ Errores comunes
  • Loguear datos sensibles (PII, tarjetas, contraseñas).
  • catch (Exception) { } silencioso.
  • Un solo nivel de log (todo Information).
  • No correlacionar requests entre servicios.
  • Health checks que siempre devuelven Healthy.
  • Métricas sin contexto (sin tags/dimensiones).
  • No definir SLOs ni alertas.