26 KiB
| name | description |
|---|---|
| portfolio-backend | Parámetros, reglas y estándares de arquitectura backend en C# / .NET 8/9 con Clean Architecture (Domain, Application, Infrastructure, WebApi), CQRS con MediatR, FluentValidation, EF Core MySQL, RBAC para administración y endpoints interactivos de prueba. |
Estándar de Arquitectura Backend — Clean Architecture en .NET (Portafolio Interactivo)
Este documento define las reglas de arquitectura, flujo de datos por capas, convenciones de código, seguridad y persistencia con MySQL para el backend en .NET 8/9 del portafolio interactivo.
1. Mentalidad de Arquitecto Backend Senior — Reglas de Oro
El agente actúa como un Ingeniero Principal y Arquitecto Backend Senior con 10+ años de experiencia en desarrollo con .NET y arquitecturas limpias de alto rendimiento.
- Clean Architecture & Regla de Dependencia:
- Las dependencias van estrictamente hacia adentro:
WebApi──►Application──►Domain◄──Infrastructure - El núcleo (
Domain) nunca depende de EF Core, MySQL, MediatR, ASP.NET Core ni librerías externas. - La inversión de dependencias permite aislar la lógica de negocio:
Domaindeclara los contratos (IProjectRepository), eInfrastructurelos implementa.
- Las dependencias van estrictamente hacia adentro:
- Filosofía Ponytail (Prevención de Sobreingeniería):
- Prohibido Generic Repository genérico (
IRepository<T>con 30 métodos que nadie usa). Diseñar interfaces de repositorio específicas y quirúrgicas para cada raíz de agregado (Aggregate Root). - Prohibido envolver DbContext en un UnitOfWork casero adicional:
DbContextde EF Core ya es un Unit of Work y Identity Map probado en producción. - Prohibido AutoMapper: En su lugar, proyectar directamente con
.Select()de LINQ para lecturas, o constructores/métodos estáticos en records para mapeos explícitos, seguros y sin overhead de reflection. - Records posicionales: Usar
public sealed record ...para DTOs, Commands y Queries. Cero clases verbosas con getters/setters tradicionales. - Primary Constructors: Aprovechar la sintaxis moderna de C# 12+ para inyección de dependencias limpia en una sola línea.
- Prohibido Generic Repository genérico (
- Cero operaciones síncronas en I/O:
- Todo acceso a base de datos, hasher o servicios de token es estrictamente asíncrono (
async/await) recibiendo siempreCancellationToken.
- Todo acceso a base de datos, hasher o servicios de token es estrictamente asíncrono (
- Manejo Centralizado de Excepciones:
- Prohibido capturar excepciones dentro de los controladores. Se utiliza
AddProblemDetails()yIExceptionHandlersegún el estándar RFC 7807.
- Prohibido capturar excepciones dentro de los controladores. Se utiliza
2. Límites de Líneas por Archivo y Modularidad Backend
Para mantener un balance óptimo entre cohesión y separación de responsabilidades (evitando tanto las God Classes monolíticas como la sobre-fragmentación artificial en decenas de micro-archivos), se establecen los siguientes umbrales tolerables:
| Tipo de Archivo | Capa / Ubicación | Líneas Ideales | Máximo Tolerable | Acción al Exceder el Límite |
|---|---|---|---|---|
| Controlador REST | WebApi/Controllers/ |
50 – 120 | 200 | Prohibido incluir lógica de negocio. Solo validar HTTP, delegar a MediatR (ISender) y retornar ActionResult<T>. Permite agrupar las rutas CRUD completas de un recurso. |
| Command / Query Handler | Application/Features/.../ |
40 – 90 | 160 | Principio One Handler per File. Si supera 160 líneas, encapsular reglas dentro de la Entidad de Dominio o delegar a subservicios especializados. |
| Entidad de Dominio | Domain/Entities/ |
60 – 130 | 220 | Contiene propiedades, reglas de negocio y fábrica estática Create(). Extraer sub-conceptos a Value Objects si crece. |
| DTO / Command / Query | Application/.../DTOs/ |
10 – 40 | 80 | Usar public sealed record posicionales. Si se agrupan varios DTOs relacionados en un archivo (ej: ProjectDtos.cs), el archivo puede alcanzar hasta 120 líneas. |
| Validador FluentValidation | Application/.../Validators/ |
25 – 60 | 100 | Encapsular validaciones complejas o reutilizables en métodos de extensión propios. |
| Repositorio de Infraestructura | Infrastructure/Persistence/ |
50 – 100 | 180 | Implementar los métodos de consulta y persistencia requeridos por el contrato de dominio (IProjectRepository). |
| Configuración EF Core (Fluent API) | Infrastructure/Configurations/ |
30 – 70 | 120 | Una clase por entidad (IEntityTypeConfiguration<T>). |
| DbContext | Infrastructure/Context/ |
40 – 90 | 150 | Registrar los DbSet<T> y llamar a modelBuilder.ApplyConfigurationsFromAssembly(...). |
Reglas de Descomposición Modular:
- One Handler per File: Cada caso de uso (Command o Query) y su Handler viven en su propio archivo independiente para facilitar pruebas unitarias y navegación.
- División de Controladores: Un controlador puede manejar cómodamente las operaciones CRUD de una entidad (hasta 150-200 líneas). Solo se divide si mezcla recursos no relacionados o responsabilidades administrativas distintas.
3. Mapa de Capas y Responsabilidades
backend/src/
├── Portfolio.Domain/ <- Núcleo del Negocio. Cero dependencias externas.
│ ├── Entities/ <- Project, Skill, User, Role, InteractiveLog
│ ├── Enums/ <- ProjectCategory, SkillLevel, UserRole
│ ├── Exceptions/ <- DomainException, NotFoundException
│ └── Interfaces/ <- IProjectRepository, IUserRepository, IUnitOfWork (opcional)
│
├── Portfolio.Application/ <- Casos de Uso (CQRS). Solo depende de Domain.
│ ├── Common/
│ │ ├── Behaviors/ <- ValidationBehavior (MediatR Pipeline)
│ │ └── Interfaces/ <- IJwtTokenGenerator, IPasswordHasher
│ ├── Features/ <- Organización vertical por Feature (Vertical Slice inside App)
│ │ ├── Projects/
│ │ │ ├── Commands/ <- CreateProjectCommand, UpdateProjectCommand
│ │ │ ├── Queries/ <- GetProjectsQuery, GetProjectByIdQuery
│ │ │ ├── DTOs/ <- ProjectResponseDto, CreateProjectRequestDto
│ │ │ └── Validators/ <- CreateProjectCommandValidator
│ │ ├── Interactive/ <- Endpoints de prueba para visitantes
│ │ │ ├── Queries/ <- GetServerTelemetryQuery, PingServerQuery
│ │ │ └── DTOs/ <- ServerTelemetryDto, PingResponseDto
│ │ └── Auth/ <- Login, RefreshToken, RBAC
│ │ ├── Commands/ <- LoginCommand, RefreshTokenCommand
│ │ ├── DTOs/ <- AuthResponseDto, LoginRequestDto
│ │ └── Validators/ <- LoginCommandValidator
│ └── DependencyInjection.cs
│
├── Portfolio.Infrastructure/ <- Implementación Técnica. Depende de Application y Domain.
│ ├── Persistence/
│ │ ├── Context/ <- PortfolioDbContext (EF Core con MySQL)
│ │ ├── Configurations/ <- EntityTypeConfigurations (Fluent API)
│ │ └── Repositories/ <- ProjectRepository, UserRepository
│ ├── Security/
│ │ ├── JwtTokenGenerator.cs <- Generación y validación de tokens JWT
│ │ └── PasswordHasher.cs <- Hasheo de contraseñas (BCrypt / PBKDF2)
│ └── DependencyInjection.cs
│
└── Portfolio.WebApi/ <- Punto de Entrada HTTP. Depende de Application e Infrastructure.
├── Controllers/ <- ProjectsController, InteractiveController, AuthController
├── Middleware/ <- GlobalExceptionHandler (ProblemDetails RFC 7807)
├── Security/ <- HasPermissionAttribute o Authorize policies
├── Program.cs <- Pipeline HTTP, DI y configuración
└── appsettings.json
4. Flujo de Datos Obligatorio para Endpoints (Data Flow Pipeline)
Para mantener total coherencia y evitar sobreingeniería, todo endpoint debe seguir estrictamente este flujo de 7 pasos secuenciales:
[1. Request HTTP / DTO]
│
▼
[2. Command / Query (MediatR IRequest<T>)]
│
▼
[3. FluentValidation (Pipeline Behavior)]
│
▼
[4. Handler (IRequestHandler<T, TResponse>)]
│
▼
[5. Dominio & Persistencia]
├── Si es ESCRITURA: Entidad de Dominio ejecuta regla ──► IRepository.AddAsync() ──► SaveChangesAsync()
└── Si es LECTURA: DbContext.Set<T>().AsNoTracking().Select(...) ──► Directo a DTO (Sin overhead)
│
▼
[6. Response DTO (Record Inmutable)]
│
▼
[7. Controller Action] (Retorna ActionResult<TResponse> con 200, 201 o 204)
5. Implementación Paso a Paso con Código Real
Paso 1: Definir los DTOs (Records Inmutables)
Los DTOs definen los contratos externos sin exponer la estructura de base de datos.
// Application/Features/Projects/DTOs/ProjectDtos.cs
namespace Portfolio.Application.Features.Projects.DTOs;
public sealed record CreateProjectRequestDto(
string Title,
string Description,
string RepositoryUrl,
string? LiveDemoUrl,
string[] Technologies,
bool IsFeatured
);
public sealed record ProjectResponseDto(
Guid Id,
string Title,
string Description,
string RepositoryUrl,
string? LiveDemoUrl,
IReadOnlyList<string> Technologies,
bool IsFeatured,
DateTime CreatedAtUtc
);
Paso 2: Command o Query (MediatR IRequest<T>)
Representa la intención del usuario. El Command muta estado; la Query solo consulta.
// Application/Features/Projects/Commands/CreateProjectCommand.cs
using MediatR;
using Portfolio.Application.Features.Projects.DTOs;
namespace Portfolio.Application.Features.Projects.Commands;
public sealed record CreateProjectCommand(
string Title,
string Description,
string RepositoryUrl,
string? LiveDemoUrl,
string[] Technologies,
bool IsFeatured
) : IRequest<ProjectResponseDto>;
Paso 3: Validación con FluentValidation
Valida la integridad de los datos antes de que el Handler sea ejecutado.
// Application/Features/Projects/Validators/CreateProjectCommandValidator.cs
using FluentValidation;
using Portfolio.Application.Features.Projects.Commands;
namespace Portfolio.Application.Features.Projects.Validators;
public sealed class CreateProjectCommandValidator : AbstractValidator<CreateProjectCommand>
{
public CreateProjectCommandValidator()
{
RuleFor(x => x.Title)
.NotEmpty().WithMessage("El título del proyecto es obligatorio.")
.MaximumLength(150).WithMessage("El título no puede exceder 150 caracteres.");
RuleFor(x => x.Description)
.NotEmpty().WithMessage("La descripción es obligatoria.")
.MaximumLength(1000).WithMessage("La descripción no puede exceder 1000 caracteres.");
RuleFor(x => x.RepositoryUrl)
.NotEmpty().WithMessage("La URL del repositorio es obligatoria.")
.Must(uri => Uri.TryCreate(uri, UriKind.Absolute, out _))
.WithMessage("La URL del repositorio no es válida.");
RuleFor(x => x.Technologies)
.NotEmpty().WithMessage("Debe incluir al menos una tecnología.");
}
}
Pipeline de Validación Automático (ValidationBehavior)
MediatR ejecuta este pipeline antes del Handler; si la validación falla, lanza ValidationException que el GlobalExceptionHandler captura retornando HTTP 422:
// Application/Common/Behaviors/ValidationBehavior.cs
using FluentValidation;
using MediatR;
namespace Portfolio.Application.Common.Behaviors;
public sealed class ValidationBehavior<TRequest, TResponse>(IEnumerable<IValidator<TRequest>> validators)
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
if (!validators.Any()) return await next();
var context = new ValidationContext<TRequest>(request);
var validationResults = await Task.WhenAll(
validators.Select(v => v.ValidateAsync(context, cancellationToken)));
var failures = validationResults
.SelectMany(r => r.Errors)
.Where(f => f != null)
.ToList();
if (failures.Count != 0)
throw new ValidationException(failures);
return await next();
}
}
Paso 4: Handler (IRequestHandler<T, TResponse>)
Orquesta la lógica de negocio delegando la persistencia a través de la interfaz del repositorio.
// Application/Features/Projects/Commands/CreateProjectCommandHandler.cs
using MediatR;
using Portfolio.Application.Features.Projects.DTOs;
using Portfolio.Domain.Entities;
using Portfolio.Domain.Interfaces;
namespace Portfolio.Application.Features.Projects.Commands;
public sealed class CreateProjectCommandHandler(IProjectRepository projectRepository)
: IRequestHandler<CreateProjectCommand, ProjectResponseDto>
{
public async Task<ProjectResponseDto> Handle(
CreateProjectCommand request,
CancellationToken cancellationToken)
{
// Creación y encapsulación en entidad de dominio
var project = Project.Create(
request.Title,
request.Description,
request.RepositoryUrl,
request.LiveDemoUrl,
request.Technologies,
request.IsFeatured
);
await projectRepository.AddAsync(project, cancellationToken);
await projectRepository.SaveChangesAsync(cancellationToken);
return new ProjectResponseDto(
project.Id,
project.Title,
project.Description,
project.RepositoryUrl,
project.LiveDemoUrl,
project.Technologies,
project.IsFeatured,
project.CreatedAtUtc
);
}
}
Paso 5: Dominio e Inversión de Persistencia
A. Entidad de Dominio Pura (Sin EF Core)
// Domain/Entities/Project.cs
namespace Portfolio.Domain.Entities;
public sealed class Project
{
public Guid Id { get; private set; }
public string Title { get; private set; } = string.Empty;
public string Description { get; private set; } = string.Empty;
public string RepositoryUrl { get; private set; } = string.Empty;
public string? LiveDemoUrl { get; private set; }
public List<string> Technologies { get; private set; } = [];
public bool IsFeatured { get; private set; }
public DateTime CreatedAtUtc { get; private set; }
private Project() { } // Constructor privado para EF Core
public static Project Create(
string title,
string description,
string repoUrl,
string? demoUrl,
string[] technologies,
bool isFeatured)
{
return new Project
{
Id = Guid.NewGuid(),
Title = title.Trim(),
Description = description.Trim(),
RepositoryUrl = repoUrl.Trim(),
LiveDemoUrl = demoUrl?.Trim(),
Technologies = [..technologies],
IsFeatured = isFeatured,
CreatedAtUtc = DateTime.UtcNow
};
}
}
B. Contrato en Dominio (Puerto)
// Domain/Interfaces/IProjectRepository.cs
using Portfolio.Domain.Entities;
namespace Portfolio.Domain.Interfaces;
public interface IProjectRepository
{
Task<Project?> GetByIdAsync(Guid id, CancellationToken cancellationToken);
Task<IReadOnlyList<Project>> GetAllAsync(CancellationToken cancellationToken);
Task AddAsync(Project project, CancellationToken cancellationToken);
void Remove(Project project);
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
}
C. Implementación en Infraestructura (Adaptador EF Core MySQL)
// Infrastructure/Persistence/Repositories/ProjectRepository.cs
using Microsoft.EntityFrameworkCore;
using Portfolio.Domain.Entities;
using Portfolio.Domain.Interfaces;
using Portfolio.Infrastructure.Persistence.Context;
namespace Portfolio.Infrastructure.Persistence.Repositories;
public sealed class ProjectRepository(PortfolioDbContext context) : IProjectRepository
{
public async Task<Project?> GetByIdAsync(Guid id, CancellationToken cancellationToken)
=> await context.Projects.FirstOrDefaultAsync(p => p.Id == id, cancellationToken);
public async Task<IReadOnlyList<Project>> GetAllAsync(CancellationToken cancellationToken)
=> await context.Projects.AsNoTracking().ToListAsync(cancellationToken);
public async Task AddAsync(Project project, CancellationToken cancellationToken)
=> await context.Projects.AddAsync(project, cancellationToken);
public void Remove(Project project)
=> context.Projects.Remove(project);
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken)
=> await context.SaveChangesAsync(cancellationToken);
}
D. Contexto EF Core MySQL y Migraciones
// Infrastructure/Persistence/Context/PortfolioDbContext.cs
using Microsoft.EntityFrameworkCore;
using Portfolio.Domain.Entities;
namespace Portfolio.Infrastructure.Persistence.Context;
public sealed class PortfolioDbContext(DbContextOptions<PortfolioDbContext> options) : DbContext(options)
{
public DbSet<Project> Projects => Set<Project>();
public DbSet<Skill> Skills => Set<Skill>();
public DbSet<User> Users => Set<User>();
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(PortfolioDbContext).Assembly);
base.OnModelCreating(modelBuilder);
}
}
Registro de Inyección en Infrastructure/DependencyInjection.cs:
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
{
var connectionString = configuration.GetConnectionString("DefaultConnection");
services.AddDbContext<PortfolioDbContext>(options =>
options.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString)));
services.AddScoped<IProjectRepository, ProjectRepository>();
return services;
}
Comandos de Migración MySQL:
dotnet ef migrations add InitialCreate --project src/Portfolio.Infrastructure --startup-project src/Portfolio.WebApi
dotnet ef database update --project src/Portfolio.Infrastructure --startup-project src/Portfolio.WebApi
Paso 6 & 7: Controlador REST en WebApi
// WebApi/Controllers/ProjectsController.cs
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Portfolio.Application.Features.Projects.Commands;
using Portfolio.Application.Features.Projects.DTOs;
using Portfolio.Application.Features.Projects.Queries;
namespace Portfolio.WebApi.Controllers;
[ApiController]
[Route("api/v1/[controller]")]
public sealed class ProjectsController(ISender sender) : ControllerBase
{
[HttpGet]
[ProducesResponseType(typeof(IEnumerable<ProjectResponseDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<ProjectResponseDto>>> GetAll(
CancellationToken cancellationToken)
{
var result = await sender.Send(new GetProjectsQuery(), cancellationToken);
return Ok(result);
}
[HttpPost]
[Authorize(Roles = "Admin")] // Protegido por RBAC para que Bryan gestione sus proyectos
[ProducesResponseType(typeof(ProjectResponseDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<ProjectResponseDto>> Create(
[FromBody] CreateProjectRequestDto request,
CancellationToken cancellationToken)
{
var command = new CreateProjectCommand(
request.Title,
request.Description,
request.RepositoryUrl,
request.LiveDemoUrl,
request.Technologies,
request.IsFeatured
);
var created = await sender.Send(command, cancellationToken);
return CreatedAtAction(nameof(GetAll), new { id = created.Id }, created);
}
}
6. Endpoints Interactivos Públicos (Live Playground)
Para que los visitantes y reclutadores interactúen con el backend sin autenticación pero con rate-limiting nativo para evitar abusos o denegación de servicio:
// WebApi/Controllers/InteractiveController.cs
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Portfolio.Application.Features.Interactive.DTOs;
using Portfolio.Application.Features.Interactive.Queries;
namespace Portfolio.WebApi.Controllers;
[ApiController]
[Route("api/v1/[controller]")]
[EnableRateLimiting("interactive-policy")] // Límite nativo de .NET 8/9 (ej. 30 req/min por IP)
public sealed class InteractiveController(ISender sender) : ControllerBase
{
[HttpGet("ping")]
[ProducesResponseType(typeof(PingResponseDto), StatusCodes.Status200OK)]
public async Task<ActionResult<PingResponseDto>> Ping(CancellationToken cancellationToken)
{
var response = await sender.Send(new PingServerQuery(), cancellationToken);
return Ok(response);
}
[HttpGet("telemetry")]
[ProducesResponseType(typeof(ServerTelemetryDto), StatusCodes.Status200OK)]
public async Task<ActionResult<ServerTelemetryDto>> Telemetry(CancellationToken cancellationToken)
{
var response = await sender.Send(new GetServerTelemetryQuery(), cancellationToken);
return Ok(response);
}
}
Configuración en WebApi/Program.cs:
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddFixedWindowLimiter("interactive-policy", opt =>
{
opt.PermitLimit = 30; // Máximo 30 peticiones
opt.Window = TimeSpan.FromMinutes(1); // por cada minuto
opt.QueueLimit = 0;
});
});
// En el pipeline:
app.UseRateLimiter();
7. Seguridad y RBAC (Administración para Bryan)
- Autenticación con JWT:
- Tokens con tiempo de expiración corto (15 a 30 minutos).
ClockSkew = TimeSpan.Zeroobligatorio para no permitir ventanas de tokens vencidos.
- Refresh Token Rotation en MySQL:
- Cada refresh token se almacena en la tabla
auth_refresh_tokenscon estadoIsRevoked,ExpiresAtUtcyReplacedByToken. - Al renovar, el token anterior se revoca inmediatamente y se emite uno nuevo. Si un token revocado intenta usarse, se revoca toda la cadena por sospecha de compromiso.
- Cada refresh token se almacena en la tabla
// Domain/Entities/RefreshToken.cs
namespace Portfolio.Domain.Entities;
public sealed class RefreshToken
{
public Guid Id { get; private set; }
public Guid UserId { get; private set; }
public string TokenHash { get; private set; } = string.Empty;
public DateTime ExpiresAtUtc { get; private set; }
public bool IsRevoked { get; private set; }
public DateTime CreatedAtUtc { get; private set; }
public bool IsActive => !IsRevoked && DateTime.UtcNow < ExpiresAtUtc;
public void Revoke() => IsRevoked = true;
}
- RBAC Simple y Efectivo:
- Roles en Claim:
Role: Admin,Role: Visitor. - Bryan inicia sesión desde el portal o terminal (
POST /api/v1/auth/login) y recibe los tokens para realizar operaciones de escritura (POST,PUT,DELETE). - Los visitantes solo tienen acceso de lectura a los proyectos/skills y a los endpoints públicos de
/api/v1/interactive/*.
- Roles en Claim:
8. Manejo Centralizado de Errores — ProblemDetails RFC 7807
// WebApi/Middleware/GlobalExceptionHandler.cs
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using Portfolio.Domain.Exceptions;
namespace Portfolio.WebApi.Middleware;
public sealed class GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
logger.LogError(exception, "Excepción capturada: {Message}", exception.Message);
var (statusCode, title) = exception switch
{
NotFoundException => (StatusCodes.Status404NotFound, "Recurso no encontrado"),
FluentValidation.ValidationException => (StatusCodes.Status422UnprocessableEntity, "Error de validación"),
UnauthorizedAccessException => (StatusCodes.Status401Unauthorized, "No autorizado"),
_ => (StatusCodes.Status500InternalServerError, "Error interno del servidor")
};
var problemDetails = new ProblemDetails
{
Status = statusCode,
Title = title,
Detail = exception.Message,
Instance = httpContext.Request.Path
};
httpContext.Response.StatusCode = statusCode;
await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
return true;
}
}
9. Anti-Patrones Prohibidos en Backend
| Anti-patrón Prohibido | Solución Obligatoria |
|---|---|
Crear GenericRepository<T> con docenas de métodos genéricos |
Contratos de repositorio específicos por entidad con los métodos exactos necesarios |
Envolver DbContext en una clase UnitOfWork casera |
Usar PortfolioDbContext directamente como Unit of Work |
| Usar AutoMapper con perfiles complejos y mapeo por reflexión | Proyección directa .Select() en Queries o mapeo estático con Records |
| Clases DTO tradicionales con getters/setters y constructores boilerplate | public sealed record ... inmutable de C# moderno |
| Inyección de dependencias clásica con campos privados repetidos | Primary Constructors de C# 12+ |
| Rutas en camelCase o PascalCase en controladores | kebab-case con versionado explícito: [Route("api/v1/[controller]")] |
SaveChanges() síncrono en cualquier punto |
Siempre SaveChangesAsync(cancellationToken) |
| Catch de excepciones dentro de acciones del controlador | Delegar al IExceptionHandler global con RFC 7807 |
| Consultas de lectura que cargan entidades con tracking | Usar .AsNoTracking() o .Select() directo a DTO |