arquitectura
Convenciones de arquitectura de las APIs .NET de la plantilla base — capas, dónde va cada archivo, patrón Interactor/Validator/InputPort, validators genéricos, reglas puras, servicios, repositorios ge
SKILL.md.claude/skills/arquitectura/SKILL.md
GitHubArquitectura
Proyecto único con Clean Architecture por convención de carpetas (no hay ensamblados que impidan cruzar capas, así que la disciplina es de quien escribe). El patrón es vertical slice sobre Interactor / InputPort: cada endpoint es una carpeta con su caso de uso completo.
Regla de oro
Controller → Interactor → Validator → InputPort → Service / Rules → Repository → EF
- Los controllers no orquestan. Resuelven el Interactor con
[FromServices]y devuelvenBaseResponse<T>. Lo único permitido sobre el input es pasar ruta o query al DTO (input.Id = id;). Unif, una elección entre dos casos de uso o una transformación van en un caso de uso, aunque sea glue code. - Los Services devuelven entidades, no DTOs. El mapeo a
OutputDTOse hace en el InputPort. - Las Rules son estáticas y puras. Las usan tanto el Validator como el InputPort.
Crear un caso de uso
1. Carpeta
Business/UseCases/<Área>/[<Subárea>/]<Nombre>/
<Nombre> en imperativo e inglés (CreateEmployee, GetEmployeeById, ApproveRequest). Todos
los archivos llevan el nombre como prefijo.
2. Archivos
// <Nombre>InputDTO.cs — omitir si no recibe nada (usar EmptyInputDTO<TOut>)
public class XInputDTO : IInputDTO, IRequest<XOutputDTO> { }
// <Nombre>OutputDTO.cs — los DTOs hijos van en ESTE MISMO archivo
public class XOutputDTO : IOutputDTO { }
// <Nombre>Validator.cs — solo si hay algo que validar (ver "Validators")
public class XValidator : IRequestValidator<XInputDTO, XOutputDTO>
{
public async Task<List<string>> ValidateAsync(XInputDTO input, CancellationToken cancellationToken = default)
{
var errors = new List<string>();
// 1. Campos básicos, sin base de datos.
if (errors.Count > 0) return errors; // cortar antes de consultar
// 2. Reglas que consultan la base.
return errors;
}
}
// <Nombre>InputPort.cs — interfaz e implementación EN EL MISMO ARCHIVO
public interface IXInputPort : IInputPort<XInputDTO, XOutputDTO> { }
public class XInputPort : IXInputPort
{
public async Task<XOutputDTO> ExecuteAsync(XInputDTO input, CancellationToken cancellationToken = default) { }
}
// <Nombre>Interactor.cs
public class XInteractor : Interactor<XInputDTO, XOutputDTO>
{
public XInteractor(IRequestValidator<XInputDTO, XOutputDTO> validator, IXInputPort inputPort)
: base(validator, inputPort) { }
public static IServiceCollection AddDependency(IServiceCollection services)
{
services.AddScoped<IRequestValidator<XInputDTO, XOutputDTO>, XValidator>();
services.AddScoped<IXInputPort, XInputPort>();
services.AddScoped<XInteractor>();
return services;
}
}
| Situación | Dónde |
|---|---|
| Helper que usa solo este caso de uso | En su carpeta |
| DTO compartido entre casos de uso del área | <Área>/Shared/ |
| Sub-caso de uso (simulación, vista previa) | Anidado: <Nombre>/Preview<Nombre>/ |
| Listado paginado | Ver "Listados y búsqueda" |
3. Registrar
Una línea en Business/UseCases/UseCasesDependencyInjection.cs:
XInteractor.AddDependency(services);
4. Endpoint
Antes de escribir el [HasPermission], consultar al usuario qué permiso lleva (skill
permisos). Al terminar, escribir los tests del Validator y del InputPort (skill
pruebas-endpoint). El hook .claude/hooks/endpoint-nuevo.js lo recuerda.
[HasPermission(Permissions.<Pantalla>.<Operación>)]
[HttpPost("ruta")]
public async Task<ActionResult<BaseResponse<XOutputDTO>>> Accion(
[FromBody] XInputDTO input,
[FromServices] XInteractor interactor,
CancellationToken cancellationToken)
{
var output = await interactor.HandleAsync(input, cancellationToken);
return Ok(BaseResponse<XOutputDTO>.Ok(output));
}
Si un endpoint tiene dos modos (por ejemplo dryRun), un solo caso de uso decide internamente.
Dos casos de uso elegidos desde el controller terminan con la misma regla escrita dos veces.
Validators: propio o genérico
En Business/Contracts/ hay tres genéricos con sus DTOs:
| Genérico | DTO | Cuándo |
|---|---|---|
EmptyRequestValidator<TIn,TOut> |
EmptyInputDTO<TOut> |
No hay nada sintáctico que validar |
ByIdValidator<TIn,TOut> |
ByIdInputDTO<TOut> |
Solo verificar Id > 0 |
ByCodeValidator<TIn,TOut> |
ByCodeInputDTO<TOut> |
Solo verificar Code no vacío |
services.AddScoped<
IRequestValidator<EmptyInputDTO<XOutputDTO>, XOutputDTO>,
EmptyRequestValidator<EmptyInputDTO<XOutputDTO>, XOutputDTO>>();
Escribir un validator propio en cuanto se cumpla cualquiera de estas condiciones:
- hay más de un campo que validar;
- hay reglas de formato, rango o multiplicidad;
- hay que consultar la base (existencia, duplicados, solapes, límites disponibles);
- hay autorización por recurso (¿este usuario puede actuar sobre este registro?).
- Validator = "¿es admisible?". Devuelve una
List<string>acumulada, con mensajes en español, frases completas y dirigidos al usuario final. Cada mensaje dice qué está mal y, si se puede, cómo corregirlo ("La cantidad supera el máximo permitido: el máximo es 30."). - InputPort = ejecuta asumiendo que es admisible. Lo que no se pudo saber antes se informa con
BusinessException(ErrorCodes.X, "..."). - Que ambos repitan la carga de una entidad es normal y deliberado: así el Validator da un mensaje preciso.
- Si el Validator necesita el mismo cálculo que hará el InputPort (por ejemplo, un máximo permitido), usar el mismo método o regla, nunca una copia: el número que informa el error debe ser exactamente el que la ejecución aceptaría.
Dónde va la lógica nueva
¿Cálculo puro, sin base de datos? → Business/Rules/<Área>/ (clase estática, sin DI, con tests)
¿Consulta o persistencia reutilizable? → Business/Services/<Área>/ (método en un Service NUEVO)
¿Secuencia de pasos de ESTE endpoint? → el InputPort
¿Texto de cara al usuario repetido? → una clase estática de mensajes en Rules (un solo lugar)
- Los Services existentes no se modifican para una funcionalidad nueva: ni refactorizar, ni extraer métodos, ni agregar métodos a su interfaz. Ya los usan endpoints en producción, y cambiarlos arriesga romper el front aunque el contrato HTTP no cambie. Se crea un Service nuevo con su interfaz, aunque duplique lógica. Leer métodos existentes está bien. Si reutilizar exige cambiar algo existente, preguntar antes.
- Repositories: solo los tres genéricos (
IBaseRepository<T>,IReadonlyRepository<T>,IUnitOfWork). Nunca crear un repositorio por entidad.Query()ya trae el filtro de borrado lógico;QueryUnfiltered()lo omite. - Services: reciben
IBaseRepository<TEntity>+IUnitOfWork, componen consultas de EF y persisten. - Transacciones: el InputPort arma el grafo completo y se lo pasa al Service en una sola
llamada (
CreateAsync(entity, children, ct)). Nunca variosSaveChangesAsyncdesde el InputPort: si el segundo falla, el primero queda guardado a medias.
Efectos secundarios (correo, avisos, bitácora)
- Van después de persistir: nunca se avisa de algo que todavía puede fallar.
- Nunca propagan. Un correo que falla no revierte ni hace fallar la operación ya guardada: se
envuelve en un método
TrySend…Asynccon try/catch y log. - Las operaciones sensibles (cargas masivas, borrados, cambios de permisos) dejan registro en la bitácora de auditoría si el proyecto tiene una, con quién, cuándo y el detalle de lo que falló.
Errores y códigos HTTP
ExceptionMiddleware traduce las excepciones; no hay try/catch en Controllers ni Interactors.
| Situación | Excepción | HTTP |
|---|---|---|
| Validación fallida | (la lanza el Interactor) BusinessException(ErrorCodes.ValidationError) |
400 |
| Regla de negocio en el InputPort | BusinessException(ErrorCodes.X, msg) |
400 |
| No existe | NotFoundException |
404 |
| Autenticado y con permiso, pero no sobre ESTE recurso | ForbiddenException(ErrorCodes.X, msg) |
403 |
| Sin sesión o token inválido | lo resuelve la autenticación | 401 |
Nunca 401 por falta de permiso: el front cierra la sesión ante un 401. Si el proyecto no trae
ForbiddenException, agregarla en Common/Exceptions/ (con Code y Message, igual que
BusinessException) y mapearla a 403 en ExceptionMiddleware, tanto en el status como en el
código de error.
Los códigos de error son constantes en Common/Errors/ErrorCodes.cs, en UPPER_SNAKE
(EMPLOYEE_ACCESS_DENIED). El front decide por el código, nunca por el texto.
Listados y búsqueda
- Los listados se paginan en SQL (
Skip/Takeen la consulta, nunca en memoria). Si el proyecto tieneFindExpression+PaginatedOutputDTO, el InputDTO recibeFindExpression Filtery el OutputDTO heredaPaginatedOutputDTO. El tamaño de página tiene tope (por ejemplo, 100). - Búsqueda por texto libre:
- escapar los comodines de
LIKE(%,_,[) antes de armar el patrón; - buscar por palabra: AND entre palabras y OR entre columnas, para que "juan perez" encuentre nombre y apellido aunque estén en columnas distintas;
- poner tope al largo del texto (por ejemplo, 100 caracteres) y a la cantidad de palabras (por ejemplo, 5), validados en el Validator antes de consultar.
- escapar los comodines de
- Los filtros de alcance (organización, equipo, lo que el usuario puede ver) se aplican siempre en la consulta, no después de traer los datos.
Procesos en segundo plano
BackgroundServicecon susOptions(Enabled, hora de corrida).Enabled = falseenappsettings.Development.jsonpara no correr contra bases compartidas.- Idempotentes por datos: antes de procesar, revisar si ya se hizo (por ejemplo, si el mes ya tiene su movimiento). Así una corrida repetida o atrasada no duplica nada y se pone al día sola.
- Una transacción por elemento; un fallo se registra y no aborta el resto de la corrida.
- Además del worker, un endpoint de administración para correrlo a mano, que devuelva el resumen de la corrida.
Registro en el contenedor: tres lugares
| Qué | Dónde |
|---|---|
Servicios, DbContext, repositorios, seguridad, Configure<TOptions>, AddHostedService |
Extensions/DependencyInjection.cs |
| Una línea por caso de uso | Business/UseCases/UseCasesDependencyInjection.cs |
| Validator + InputPort + Interactor (solo esos tres) | El AddDependency estático del Interactor |
- Un servicio nuevo va SIEMPRE en
DependencyInjection.cs, nunca dentro de unAddDependency. - Registrar siempre por interfaz:
services.AddScoped<IXService, XService>();. - Todo
Scopedpor defecto.Singletonsolo para lo que no tiene estado por request.
Entidad nueva
AppDomain/Entities/<Área>/XEntity.cs: heredaAuditableEntity(auditoría automática + borrado lógico) oBaseEntitypara catálogos inmutables.Persistence/ExtraConfiguration/<Área>/XConfiguration.csconIEntityTypeConfiguration<XEntity>. Se descubre sola conApplyConfigurationsFromAssembly. Declarar el esquema:builder.ToTable("Tabla", "Esquema"). Precisión explícita en tododecimal.DbSet<XEntity>enPersistence/Context/AppDbContext.cs.- Migración (skill
migraciones).
Al guardar, AuditableEntityInterceptor (Persistence/Interceptors/) llena CreatedAt, CreatedBy,
UpdatedAt y UpdatedBy, y convierte un Delete en borrado lógico: no asignarlos a mano. Para
reactivar algo borrado (en lugar de insertarlo y chocar con un índice único), leerlo con
QueryUnfiltered().
Convenciones transversales
- Idioma: comentarios de código nuevos, mensajes al usuario y documentación, en español. Nombres de clases, métodos y propiedades, en inglés.
- Todo método asíncrono recibe y propaga
CancellationToken, y se llama…Async. - Booleanos con prefijo
Is/Has/Can(IsActive,HasPendingRequest,CanEdit). - Las bajas no son
DELETE: dar de baja un registro con historia (un colaborador, un usuario) es unPATCHque lo desactiva.DELETEqueda para catálogos y borrados de administración. - Fecha y hora: en código nuevo, inyectar
TimeProvider(registrado comoservices.AddSingleton(TimeProvider.System)) en lugar deDateTime.Now. Así los tests pueden fijar "hoy" conFakeTimeProvider(paqueteMicrosoft.Extensions.TimeProvider.Testing). - Comentar el porqué, no el qué: la regla de negocio, el caso que motivó una validación, la trampa que evita. Un comentario que repite el código sobra.
- Formato:
.editorconfigen la raíz (UTF-8, CRLF, 4 espacios en C#).
Skills relacionadas
| Tarea | Skill |
|---|---|
| Permiso de un endpoint, pantalla u operación nueva; alcance por recurso; diagnosticar un 403 | permisos |
| Tests de Validators, InputPorts y Rules | pruebas-endpoint |
| Migraciones de esquema y de datos | migraciones |
| Documentar un cambio para el front y versionarlo | contrato-api |