1. Introducción
Cuando username/email ya se verifican por separado, la contraseña en texto sin formato se convierte en passwordHash, y los flags iniciales están claros, el registro aún puede parecer un conjunto de piezas dispersas: por un lado DTO, por otro existsBy..., por otro buildAccount(...), y la ruta completa no se ve a simple vista. Ahora es importante este montaje: un camino claro desde POST /api/auth/register hasta la cuenta guardada, el perfil vacío y una respuesta predecible.
La idea principal aquí es simple: el registro es una tubería. Por un lado llega un JSON, luego resolvemos secuencialmente cuatro cosas: si es posible crear una cuenta con esos identificadores, cómo debe verse UserAccount, qué se guarda exactamente en la base de datos y qué resultado seguro recibe el cliente. Una vez que se fija el orden, el registro deja de ser un «montón de piezas útiles» y se convierte en un escenario normal.
2. Contrato de registro: lo que ya está fijado
El flujo final ya se basa en un contrato breve: en la entrada username, email, password; en la salida solo userId y username. Esto no es minimalismo por el minimalismo: la contraseña, passwordHash, roles, flags de estado y fechas de vida de la cuenta son decisiones del servidor; el cliente no debe proporcionarlos ni verlos en la respuesta.
Normalmente, el registro comienza con datos JSON como estos:
{
"username": "alice",
"email": "alice@example.com",
"password": "S0methingStr0ng!"
}
Y la respuesta tras un registro exitoso es lo más «modesta» posible:
{
"userId": 42,
"username": "alice"
}
A nivel de Java es el mismo RegistrationResponse(Long userId, String username) — una respuesta corta y aburrida sin fugas.
3. AuthController delgado: recibir, validar, delegar
El controlador en el registro se parece a un recepcionista: recibe a la persona entrante, comprueba que habla un lenguaje humano y la deriva a la dirección correspondiente. No debe ir al «taller» y fundir el acero él mismo. En cuanto el controlador empieza a hashear la contraseña, meterse en el repositorio y decidir reglas de negocio, obtienes una clase «todo-en-uno» — la versión clásica de Java de un fregadero de cocina: lava los platos, hace café y rompe la seguridad.
Por eso el endpoint se mantiene lo más delgado posible: @Valid inicia la validación del DTO, el controlador establece el estado HTTP correcto y delega todo al servicio.
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final RegistrationService registrationService;
public AuthController(RegistrationService registrationService) {
this.registrationService = registrationService;
}
@PostMapping("/register")
@ResponseStatus(HttpStatus.CREATED)
public RegistrationResponse register(@Valid @RequestBody RegisterRequest request) {
return registrationService.register(request);
}
}
Aquí deliberadamente no hay existsBy..., encode(...) ni save(...). En cuanto aparecen aquí, la capa web vuelve a resolver lo que debería resolver el escenario de registro.
4. RegistrationService: una única ruta sin versiones en competencia
Dentro del servicio, el flujo ensamblado debe leerse de arriba abajo sin saltos por el proyecto. No necesitamos una segunda historia separada sobre DTO, otra versión de excepción ni otro buildAccount(...). Necesitamos una única ruta de trabajo.
Dentro de RegistrationService, esta ruta se ve así:
import org.springframework.transaction.annotation.Transactional;
@Transactional
public RegistrationResponse register(RegisterRequest request) {
ensureRegistrationDataAvailable(request);
UserAccount account = buildAccount(request);
UserAccount savedAccount = userAccountRepository.save(account);
userProfileService.createEmptyProfile(savedAccount.getId());
return new RegistrationResponse(savedAccount.getId(), savedAccount.getUsername());
}
Aquí ensureRegistrationDataAvailable(...) es el mismo paso del bloque sobre unicidad: username/email se verifican antes de crear la cuenta, y el conflicto externo sigue siendo neutral.
buildAccount(...) es el mismo paso donde la contraseña en texto plano se convierte en passwordHash, el correo se normaliza, se asigna Role.USER, y enabled=true y accountNonLocked=true fijan el estado inicial normal de la cuenta.
El UserProfile vacío se crea después de save(...), cuando la cuenta ya tiene un id real. Así el perfil sigue siendo una entidad de dominio separada, pero no queda fuera del mismo escenario.
5. Conflicto neutral y última línea de defensa de la base de datos
La verificación previa temprana es necesaria para que el flujo de registro se detenga antes de acciones innecesarias. Pero no reemplaza la restricción unique en username y email. Dos solicitudes paralelas pueden pasar existsBy... al mismo tiempo y colisionar ya en save(...). En ese momento, la base de datos sigue siendo la última línea de defensa del invariante.
Desde fuera, el comportamiento no debe dividirse en dos versiones. Si el conflicto se detecta en el servicio, se envía un neutral 409 Conflict. Si el duplicado surge tarde, por ejemplo como DataIntegrityViolationException debido a la restricción unique, en el límite externo el significado es el mismo: Registration data is not available. El cliente no debe recibir una vez un conflicto ordenado y otra vez un mensaje crudo de la base de datos.
Precisamente sobre este comportamiento estable se puede construir luego un contrato JSON único de errores. Mientras la fuente del fallo fluctúe, el límite REST seguirá siendo aleatorio.
6. Integración y esquema del flujo
Regla en SecurityFilterChain
Por definición, el registro debe ser accesible para un usuario anónimo; de lo contrario, sería el servicio más protegido del mundo al que nadie puede acceder. Por eso, en nuestro SecurityFilterChain, el endpoint de registro debe estar en permitAll. No reescribimos toda la configuración, solo verificamos que la regla necesaria existe y no está bloqueada por reglas más generales más arriba en la cadena.
Mínimamente se ve así:
import org.springframework.http.HttpMethod;
http.authorizeHttpRequests(auth -> auth
// Permitir acceso anónimo estrictamente al registro (POST), no a todo /api/auth/**
.requestMatchers(HttpMethod.POST, "/api/auth/register").permitAll()
// Cerramos todo lo demás: el registro es una entrada pública, no un «proyecto abierto»
.anyRequest().authenticated()
);
Aquí lo importante no es la sintaxis, sino la idea: el endpoint de registro forma parte de la zona pública, pero estrictamente en el sentido de «se puede invocar», no de «se puede hacer cualquier cosa». Crea una cuenta estrictamente según las condiciones del sistema.
Esquema de todo el flujo: desde JSON hasta registros en la base de datos
Cuando montas el escenario completo, es muy útil verlo una vez como un «mapa». Esto ayuda tanto a explicar como a depurar, y a no arrastrar responsabilidades extra a lugares incorrectos. A continuación se muestra un esquema simplificado de nuestro flujo de registro.
flowchart TD
A["Client: POST /api/auth/register"] --> B["AuthController"]
B -->|"@Valid DTO"| C["RegistrationService.register"]
C --> D["ensureRegistrationDataAvailable"]
D -->|taken| X["409 Conflict
Registration data is not available"]
D -->|available| E["buildAccount"]
E --> F["UserAccountRepository.save"]
F -->|unique constraint violation| X
F -->|saved| G["userProfileService.createEmptyProfile"]
G --> H["RegistrationResponse { userId, username }"]
H --> I["Client receives 201 Created + safe body"]
Y para fijar la distribución de responsabilidades no solo en el esquema sino también en la mente, es útil tener una pequeña tabla recordatoria:
| Participante | Qué hace en el registro | Qué no debe hacer |
|---|---|---|
| AuthController | Recibe la solicitud HTTP, inicia @Valid, establece 201 Created, delega en el servicio | Codificar la contraseña, acceder a la base de datos, asignar roles |
| RegistrationService | Ensambla el escenario: verificación de disponibilidad, construcción de la cuenta, guardado, creación del perfil, respuesta | Devolver entidades al exterior, duplicar varias versiones incompatibles del mismo flujo |
| UserAccountRepository | Proporciona formas de consultar y guardar (existsBy..., save) | Contener lógica de negocio del escenario de registro |
| PasswordEncoder | Realiza encode (y luego hará matches en el flujo de autenticación) | Almacenar la contraseña o «descifrarla de vuelta» |
| UserProfileService | Crea un perfil vacío después de que aparece userId | Suplantar al registro o almacenar passwordHash |
7. Errores típicos en el registro
Error n.º 1: montar el flujo de registro a partir de versiones diferentes del mismo escenario.
Cuando en el proyecto un controlador piensa que el registro devuelve void, el servicio que devuelve RegistrationResponse, y la ruta de error vive en un tercer lugar, el escenario deja de ser predecible. Para el registro es especialmente perjudicial que diferentes fragmentos de código tomen decisiones diferentes sobre lo mismo. Debe haber una única ruta clara, no un conjunto de implementaciones «casi iguales».
Error n.º 2: conflicto neutral solo en la verificación previa del servicio, y en caso de duplicado en save(...) — excepción cruda de la base de datos.
Un proyecto así se comporta como una persona de humor variable: hoy responde ordenadamente con 409 Conflict, mañana suelta un texto sobre violación de restricción unique. Al cliente le da igual dónde haya surgido el duplicado. Desde fuera es el mismo conflicto de datos de registro y debe verse igual.
Error n.º 3: permitAll() no coincide con la ruta real o está bloqueado por un matcher más amplio más arriba.
Incluso un buen flujo de registro es inútil si no se puede acceder a él de forma anónima. Clásico del género: el controlador escucha en /api/auth/register, pero en la configuración de seguridad se permitió otra ruta por error o se colocó un matcher demasiado general antes. Resultado: extraños 401/403 antes incluso de entrar en el escenario de registro.
Error n.º 4: crear UserProfile antes de save(account) o sacar este paso a un lugar aleatorio.
El perfil depende de una cuenta ya existente y de su id. Si intentas crear el perfil «en algún lugar cercano» antes de guardar la cuenta, es fácil obtener un escenario a medias: la cuenta no se guardó, el perfil ya se intentó crear; la cuenta se guardó, el perfil se olvidó; todo esto además en diferentes fragmentos transaccionales. Más simple y fiable: primero save(account), luego createEmptyProfile(...).
Error n.º 5: devolver UserAccount completo al exterior o registrar RegisterRequest completo.
En cuanto empiezas a devolver la entidad al exterior, junto con ella se van filtrando roles, flags de estado y todo lo que añadas al modelo. Y si además registras RegisterRequest por completo, en los registros aparecen de repente las contraseñas. El endpoint de registro debe ser aburrido: recibe el mínimo, crea una cuenta, devuelve el mínimo seguro.
GO TO FULL VERSION