1. Introduction
When username/email are already checked separately, raw password becomes passwordHash, and the initial flags are clear, registration can still look like a set of scattered pieces: somewhere DTO, somewhere existsBy..., somewhere buildAccount(...), but the whole route is not visible at a glance. What matters now is exactly this assembly: one clear path from POST /api/auth/register to the saved account, the empty profile, and the predictable response.
The main idea here is simple: registration is a pipeline. A JSON payload comes in, and then we sequentially solve four things: whether we can create an account with such identifiers at all, what UserAccount should look like, what exactly is stored in the database, and what safe result the client receives. As soon as the order is fixed, registration stops being "a pile of useful pieces" and becomes a normal scenario.
2. Registration contract: what is already fixed
The final flow already relies on a short contract: input includes username, email, password; output includes only userId and username. This is not minimalism for minimalism's sake: password, passwordHash, roles, state flags, and account lifetime dates are server-side decisions, and the client should not set them or see them in the response.
Usually, registration starts with JSON data like this:
{
"username": "alice",
"email": "alice@example.com",
"password": "S0methingStr0ng!"
}
And the response after successful registration is as "modest" as possible:
{
"userId": 42,
"username": "alice"
}
At the Java level, this is the same RegistrationResponse(Long userId, String username) — a short and boring response with no leaks.
3. Thin AuthController: accept, validate, delegate
The controller in registration is like a receptionist at the front desk: it receives the incoming request, checks that it speaks human language, and passes it along. It should not go "into the workshop" and melt steel itself. As soon as the controller starts hashing the password, touching the repository, and deciding business rules on its own, you get an all-in-one class — the classic Java version of a kitchen sink: washes dishes, brews coffee, and breaks security.
That is why the endpoint here stays as thin as possible: @Valid triggers DTO validation, the controller sets the correct HTTP status, and delegates everything to the service.
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);
}
}
There is deliberately no existsBy..., encode(...), or save(...) here. As soon as they move in here, the web layer starts solving again what the registration scenario itself should solve.
4. RegistrationService: one route with no competing versions
Inside the service, the assembled flow should read from top to bottom without jumping around the project. We do not need a second separate story about DTOs, another exception version, or yet another buildAccount(...). We need one working route.
Inside RegistrationService, that route looks like this:
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());
}
Here, ensureRegistrationDataAvailable(...) is the same step from the uniqueness block: username/email are checked before account creation, and the external conflict remains neutral.
buildAccount(...) is the same step where raw password becomes passwordHash, email is normalized, Role.USER is assigned, and enabled=true and accountNonLocked=true fix the normal initial account state.
The empty UserProfile is created after save(...), when the account already has a real id. This keeps the profile as a separate domain entity, but it does not fall out of the same scenario.
5. Neutral conflict and the last line of database defense
An early pre-check is needed so the registration flow stops before unnecessary actions. But it does not replace the unique constraint on username and email. Two parallel requests can both pass existsBy... and collide later at save(...). At that moment, the database remains the last line of defense for the invariant.
From the outside, the behavior still should not split into two versions. If the conflict is caught in the service, a neutral 409 Conflict is returned. If the duplicate appears later, for example as a DataIntegrityViolationException because of a unique constraint, the meaning at the external boundary is the same: Registration data is not available. The client should not receive a neat conflict one time and raw database text the next.
Only with this stable behavior can you later build a unified JSON contract for errors. As long as the source of failure is unstable, the REST boundary remains random too.
6. Integration and flow diagram
Rule in SecurityFilterChain
By design, registration should be accessible to anonymous users — otherwise it would be the most secure service in the world, impossible for anyone to reach. That is why in our SecurityFilterChain, the registration endpoint must be in permitAll. We are not rewriting the entire configuration; we are only checking that the needed rule exists and is not overridden by broader rules above it in the chain.
At minimum, it looks like this:
import org.springframework.http.HttpMethod;
http.authorizeHttpRequests(auth -> auth
// Allow anonymous access strictly to registration (POST), not to the entire /api/auth/**
.requestMatchers(HttpMethod.POST, "/api/auth/register").permitAll()
// Close everything else: registration is a public entry point, not an "open project"
.anyRequest().authenticated()
);
What matters here is not the syntax, but the idea: the registration endpoint is part of the public zone, but strictly in the sense of "can be called", not "can do anything". It creates an account strictly under the system's rules.
Full flow diagram: from JSON to database records
When you assemble the scenario as a whole, it is very useful to see it as a map once. This helps both explaining and debugging, and prevents unnecessary responsibilities from being dragged into the wrong places. Below is a simplified diagram of our registration flow.
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"]
And to reinforce the distribution of responsibilities not only in the diagram, but also in your head, it is convenient to keep a small reminder table:
| Participant | What it does in registration | What it should not do |
|---|---|---|
| AuthController | Receives the HTTP request, triggers @Valid, sets 201 Created, delegates to the service | Hash the password, access the database, assign roles |
| RegistrationService | Assembles the scenario: availability check, account construction, saving, profile creation, response | Return entities outward, duplicate several incompatible versions of one flow |
| UserAccountRepository | Provides ways to query and save (existsBy..., save) | Contain the business logic of the registration scenario |
| PasswordEncoder | Performs encode (and later will perform matches in the auth flow) | Store the password or "decrypt it back" |
| UserProfileService | Creates an empty profile after userId appears | Replace registration or store passwordHash |
7. Typical registration mistakes
Mistake #1: build the registration flow from different versions of the same scenario.
When one controller in the project thinks registration returns void, the service thinks it returns RegistrationResponse, and the error path lives somewhere else, the scenario stops being predictable. Registration is especially bad when different pieces of code make different decisions about the same thing. There should be one clear route, not a set of "almost identical" implementations.
Mistake #2: use a neutral conflict only in the service pre-check, but return a raw database exception on duplicate at save(...).
Such a project behaves like a person with mood swings: today it carefully responds with 409 Conflict, tomorrow it throws a unique constraint violation text out to the outside world. The client does not care where exactly the duplicate surfaced. From the outside, it is the same registration data conflict, and it should look the same.
Mistake #3: permitAll() does not match the real route or is overridden by a broader matcher.
Even a good registration flow is useless if it cannot be reached anonymously. Classic case: the controller listens on /api/auth/register, but the security config accidentally allowed a different path or placed a too-broad matcher above it. The result is strange 401/403 errors before the registration scenario even starts.
Mistake #4: create UserProfile before save(account) or move this step to a random place.
The profile depends on an already existing account and its id. If you try to create the profile "somewhere nearby" before saving the account, it is easy to end up with a half-finished scenario: the account was not saved, but the profile was already attempted; the account was saved, but the profile was forgotten; and all of this in different transactional pieces. It is simpler and safer to first save(account), then createEmptyProfile(...).
Mistake #5: return UserAccount outward in full or log RegisterRequest in full.
As soon as you start exposing the entity outward, roles, state flags, and everything you later add to the model gradually leak out with it. And if you also log RegisterRequest in full, passwords suddenly appear in the logs. The registration endpoint should be boring: accept the minimum, create the account, return the safe minimum.
GO TO FULL VERSION