core-web
Qué problema resuelve
Cuando cada proyecto inventa su propio formato de error, el frontend termina con un catch distinto por
API: unos devuelven {"error": "..."}, otros {"message": "..."}, otros el stacktrace de Spring. Lo mismo
pasa con la paginación y con el mapeo DTO ↔ entidad.
core-web fija esas tres convenciones para todas las APIs de EchoTechs: un único cuerpo de error
(ApiError) para cualquier 4xx/5xx, un único sobre de listado (PageResponse), y una configuración
compartida de MapStruct. Es un módulo sin estado y sin base de datos — sólo convenciones y un
@RestControllerAdvice.
Cómo se agrega
Viene transitivamente con core-auth. Declaralo directo sólo si querés el manejo de errores y la
paginación sin autenticación.
dependencies {
implementation("dev.echotechs.core:core-web:0.1.0-SNAPSHOT")
}
Si vas a escribir mappers, necesitás además el processor de MapStruct en tu propio build — core-web lo
declara como compileOnly, así que no se propaga:
dependencies {
implementation("dev.echotechs.core:core-web:0.1.0-SNAPSHOT")
compileOnly("org.mapstruct:mapstruct:1.6.3")
annotationProcessor("org.mapstruct:mapstruct-processor:1.6.3")
}
Trae como api: spring-boot-starter-web, spring-boot-starter-validation, spring-data-commons y
spring-security-core.
API pública
ApiError — el cuerpo de error uniforme
public record ApiError(
Instant timestamp,
int status,
String code,
String message,
String path,
List<ApiFieldError> fieldErrors
) {}
code es un identificador estable, legible por máquina ("RESOURCE_NOT_FOUND"); message es legible por
humanos y seguro de mostrarle a un usuario final. El constructor compacto normaliza fieldErrors a lista
vacía cuando llega null, así que el campo nunca es null en el JSON.
public record ApiFieldError(String field, String message, Object rejectedValue) {}
ApiException y sus subclases
public class ApiException extends RuntimeException {
public ApiException(HttpStatus status, String code, String message);
public ApiException(HttpStatus status, String code, String message, List<ApiFieldError> fieldErrors);
public HttpStatus getStatus();
public String getCode();
public List<ApiFieldError> getFieldErrors();
}
Tres subclases listas para usar:
| Clase | Status | code |
|---|---|---|
ResourceNotFoundException(String message) | 404 | RESOURCE_NOT_FOUND (fijo) |
ConflictException(String code, String message) | 409 | el que le pases |
UnauthorizedException(String code, String message) | 401 | el que le pases |
Extendé ApiException en vez de lanzar RuntimeException crudas — es lo que hace que todos los caminos de
error de todas las APIs terminen con la misma forma de respuesta.
GlobalExceptionHandler
@RestControllerAdvice con @Order(Ordered.LOWEST_PRECEDENCE), registrado automáticamente por
ErrorHandlingAutoConfiguration (no depende del component-scan de tu proyecto). Traduce:
| Excepción | Status | code |
|---|---|---|
ApiException | el suyo | el suyo |
MethodArgumentNotValidException | 400 | VALIDATION_FAILED |
ConstraintViolationException | 400 | VALIDATION_FAILED |
HttpMessageNotReadableException | 400 | MALFORMED_REQUEST_BODY |
InsufficientAuthenticationException | 401 | AUTHENTICATION_REQUIRED |
AccessDeniedException | 403 | ACCESS_DENIED |
Exception (fallback) | 500 | INTERNAL_ERROR |
Las dos variantes de validación rellenan fieldErrors. El fallback a 500 loguea el stacktrace completo con
método y URI, pero al cliente le devuelve sólo "Something went wrong. Please try again.".
:::caution Los errores del filter chain de Spring Security no pasan por acá
GlobalExceptionHandler sólo ve excepciones lanzadas dentro de un controller. Un 401 por falta de token
lo produce el filter chain antes de llegar al DispatcherServlet. core-auth resuelve eso con
ApiAuthenticationEntryPoint/ApiAccessDeniedHandler, que escriben exactamente el mismo ApiError —
ver core-auth.
:::
PageResponse<T>
public record PageResponse<T>(
List<T> content,
int page,
int size,
long totalElements,
int totalPages,
boolean last
) {
public static <T> PageResponse<T> from(Page<T> page);
public static <T, R> PageResponse<R> from(Page<T> page, List<R> mappedContent);
}
Envolver Page en vez de devolverlo directo mantiene el formato de cable estable aunque Spring Data cambie
su propia representación JSON. La segunda sobrecarga es para cuando ya mapeaste el contenido a DTOs.
Los defaults de paginación los siembra PaginationDefaultsEnvironmentPostProcessor como property source de
menor prioridad, así que cualquier valor que pongas en tu application.yml los pisa normalmente:
| Propiedad | Default |
|---|---|
spring.data.web.pageable.default-page-size | 20 |
spring.data.web.pageable.max-page-size | 100 |
spring.data.web.pageable.one-indexed-parameters | false |
CoreMapperConfig y EntityMapper<D, E>
@MapperConfig(
componentModel = MappingConstants.ComponentModel.SPRING,
unmappedTargetPolicy = ReportingPolicy.IGNORE
)
public interface CoreMapperConfig {}
public interface EntityMapper<D, E> {
E toEntity(D dto);
D toDto(E entity);
List<D> toDtoList(List<E> entities);
}
Ejemplo de uso
Un controller completo usando las tres convenciones:
@RestController
@RequestMapping("/api/purchase-requests")
public class PurchaseRequestController {
private final PurchaseRequestRepository repository;
private final PurchaseRequestMapper mapper;
public PurchaseRequestController(PurchaseRequestRepository repository, PurchaseRequestMapper mapper) {
this.repository = repository;
this.mapper = mapper;
}
@GetMapping
public PageResponse<PurchaseRequestDto> list(Pageable pageable) {
Page<PurchaseRequest> page = repository.findAll(pageable);
return PageResponse.from(page, mapper.toDtoList(page.getContent()));
}
@GetMapping("/{id}")
public PurchaseRequestDto get(@PathVariable UUID id) {
PurchaseRequest entity = repository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Purchase request " + id + " not found"));
return mapper.toDto(entity);
}
@PostMapping
public PurchaseRequestDto create(@Valid @RequestBody PurchaseRequestDto dto) {
return mapper.toDto(repository.save(mapper.toEntity(dto)));
}
}
Y el mapper:
@Mapper(config = CoreMapperConfig.class)
public interface PurchaseRequestMapper extends EntityMapper<PurchaseRequestDto, PurchaseRequest> {
}
El GET /api/purchase-requests/{id} de un id inexistente produce exactamente esto, verificado en
GlobalExceptionHandlerTest:
{
"timestamp": "2026-07-28T12:00:00Z",
"status": 404,
"code": "RESOURCE_NOT_FOUND",
"message": "Purchase request 3f2... not found",
"path": "/api/purchase-requests/3f2...",
"fieldErrors": []
}
Un POST con body inválido produce un 400 con fieldErrors poblado:
{
"status": 400,
"code": "VALIDATION_FAILED",
"message": "Request payload is invalid.",
"fieldErrors": [
{"field": "title", "message": "must not be blank", "rejectedValue": ""}
]
}
Errores comunes
Mi @ControllerAdvice propio no se aplica.
GlobalExceptionHandler está en LOWEST_PRECEDENCE, así que cualquier advice tuyo con orden default gana.
Pero si escribiste el tuyo con un @ExceptionHandler(Exception.class) sin orden explícito, el resultado
entre dos advices de la misma precedencia no está definido — dale un @Order explícito.
Espero un 403 y recibo un 401 (o al revés).
InsufficientAuthenticationException → 401, AccessDeniedException → 403. Si tirás AccessDeniedException
desde un contexto sin principal autenticado, seguís obteniendo 403, no 401.
El rejectedValue de un campo sensible aparece en la respuesta.
ApiFieldError incluye el valor rechazado tal cual. Si validás un campo de contraseña con @Size, el valor
inválido viaja en el JSON de error. Evitá anotaciones de Bean Validation con mensajes automáticos sobre
campos secretos, o filtralos en un advice propio de mayor precedencia.
MapStruct no genera nada.
core-web declara MapStruct como compileOnly/annotationProcessor — no es transitivo. Tu proyecto tiene
que declarar el annotationProcessor por su cuenta.
Esperaba que el mapper actualizara una entidad existente.
EntityMapper no tiene método de merge. Los mappers generados construyen siempre una instancia nueva y
nunca aceptan un target mutable — es deliberado, es la defensa contra el bug de "campos residuales de una
solicitud anterior". Para update, leé la entidad y asignale los campos vos.
Notas de implementación
Diferencias entre platform-core-spec.md y lo que hace el código:
No hay springdoc-openapi. La sección 1.8 del spec pide documentación de API con
org.springdoc:springdoc-openapi-starter-webmvc-ui, exponiendo /v3/api-docs y /docs, deshabilitado o
protegido en prod. No está implementado: no hay ninguna referencia a springdoc en el código, ni en
gradle/libs.versions.toml, ni en ningún build.gradle.kts. Esto también deja sin base la generación
automática de @echotechs/api-client (spec 3.1) y el contract testing (spec 4.4).
No hay i18n. El spec 1.8 pide MessageSource con messages_es.properties/messages_en.properties,
resolución por Accept-Language vía AcceptHeaderLocaleResolver, integración con Bean Validation, e ICU4J
opcional. Nada de eso existe en el código: no hay archivos messages*.properties ni configuración de
MessageSource o LocaleResolver en ningún módulo. Los mensajes de GlobalExceptionHandler están
hardcodeados en inglés ("Request payload is invalid.", "Something went wrong. Please try again.").
EnvironmentPostProcessor está deprecado para remoción. PaginationDefaultsEnvironmentPostProcessor
usa una API marcada @Deprecated(forRemoval) desde Boot 4.0, suprimida con @SuppressWarnings("removal").
El comentario en el código explica que sigue siendo el único hook que corre lo bastante temprano como para
sembrar defaults antes de que se bindeen las @ConfigurationProperties. Va a necesitar migración cuando
Spring publique un reemplazo.