1. Registration as a security boundary
Up to this point, security already knew how to read a user from the database and authenticate them through DaoAuthenticationProvider. Now we need the reverse path: create such a UserAccount that the same login flow can actually work with later.
Registration looks like a regular endpoint: a JSON payload arrives, we save something. But that is misleading: registration is a public door into your system, and not only the future happy user knocks on it, but also any bot, scanner, and anyone who is just bored in the evening. That is why it is treated not as a CRUD handler, but as a scenario with strict rules.
If you think of registration as POST /users, your hand naturally reaches for a universal "save user" method where the client can send anything. That almost certainly ends in pain. First, a "user" in the domain and an "account" in security are not the same thing. Second, registration has non-negotiable security invariants: the password cannot be stored raw, the initial role must not come from the request, the enabled/locked flags cannot be left to the client, and error responses must be careful so they do not help an attacker.
In our Secure Content Platform API project, registration is the moment when a new UserAccount appears, and it must be immediately compatible with how DaoAuthenticationProvider works afterward. Otherwise you get a funny bug: "registered successfully," but login does not work because the password was stored wrong, the roles are wrong, or the account state is wrong. A comedy, of course, but usually not on a Friday evening in production.
Registration contract: what we accept
When we build a registration endpoint, it is very tempting to accept more fields "just in case": role, displayName, bio, avatar, flags, date of birth, favorite pizza. But in a security course, we stay focused: registration creates an account, not the entire profile. So the contract should be short and predictable.
The minimal input for POST /api/auth/register in our case is username, email, password. Here it is important to understand the philosophy: this is not "everything we know about the person", but only what is needed to create an account and later verify its login/password. Everything else (profile, avatar, bio) belongs to separate scenarios and separate endpoints. And yes, that sometimes feels "unfriendly." But the application stays simpler: one handler, one responsibility.
It is useful to agree right away on which fields the client should never send during registration, because that is our server-side decision. In a small table, it looks like this:
| Category | Field | Who decides |
|---|---|---|
| User input | username, email, password | client sends it |
| Security invariants | passwordHash | server computes (encode) |
| Access model | initial role (USER) | server assigns it |
| Lifecycle | enabled, accountNonLocked | server sets initial values |
| Identifiers and dates | id, createdAt | database/server creates them |
This table seems obvious... until you see a real bug where someone "quickly" added a role field to RegisterRequest, and then it turned out registration had suddenly become "admin-by-request." You can show that endpoint on Halloween instead of a ghost story.
3. Registration flow: step order
Registration is not one operation, but a sequence of steps, and in the right order. The order matters not out of love for bureaucracy, but because some errors are easier and safer to stop before we even start creating entities and writing to the database.
In the ideal form, the registration scenario can be imagined as a "pipeline" where each step either passes the request along or stops it with a clear error. Each step has its own details, but you need to see the skeleton right away so you do not end up with a 200-line controller method.
Here is a compact flowchart that gives the right "picture in your head":
flowchart TD
%% Important: registration is a scenario, not just "save an entity"
A["POST /api/auth/register
JSON: username, email, password"] --> B["Web layer: parse + validate DTO"]
B --> C["Service layer: check username/email availability"]
C --> D["Service layer: build UserAccount
passwordHash, role, initial flags"]
D --> E["Repository: save UserAccount to DB"]
E --> F["Service layer: create empty UserProfile"]
F --> G["Return a safe registration result"]
Pay attention to two things. First, UserProfile is created immediately after the account is saved, but as a separate step: the profile is not mixed with the security core of the account. Second, "build the account" is not " new UserAccount() and setters in the controller," but a separate scenario step. We do not "scatter" account creation across layers; we keep it inside the service.
4. Where the logic lives: layers
It is very easy to write registration "in the controller": read JSON, perform checks, hash the password, save through the repository, return a response. And everything works. Right up until you want to support it for longer than a week — or until you realize that half the decisions should live not in the web layer, but in the business scenario.
The controller is a translator from HTTP to your code. It should be able to accept a request, apply basic DTO validation, call the service, and return a response. The service is the director of the scenario: it knows the order of steps and makes decisions. The repository is the "hands" that touch the database. If the controller starts doing everything at once, you end up with a "fat" web layer where HTTP, security, and business logic are mixed together. Such code is hard to test, hard to change, and usually turns into "just do not touch it, it works."
If you break the registration steps down by layers, you get a calmer picture:
| Step | Where it should be |
|---|---|
| Accept JSON, convert it to DTO | Controller |
| Check basic DTO validity | Controller (through Bean Validation) + sometimes service |
| Check registration data availability | Service |
| Create UserAccount according to security rules | Service |
| Save to the database | Repository (called from the service) |
| Build a response | Service → Controller |
And this is not "clean architecture for clean architecture's sake." It is just a way to make the scenario readable from top to bottom in your code — and not have to search the whole project for where the password is actually encoded and the role is assigned.
5. Registration code skeleton
Now we will add the minimal classes that define the shape of the scenario. For now, without the "meat" (uniqueness, password encoding, initial roles) — those are the next engineering steps of registration. Our job here is different: to make registration look like a scenario, not like a pile of random lines in the controller.
Let's start with the request DTO. I will show it as a record, because it is short, and Jackson works great with it. If records are still unfamiliar to you, think of it as a compact immutable class with fields.
import jakarta.validation.constraints.*;
// Registration DTO: only the input fields we allow the client to send
public record RegisterRequest(
@NotBlank String username, // login/nickname: must not be empty
@NotBlank @Email String email, // basic email format check
@NotBlank @Size(min = 8, max = 72) String password // raw password: cannot be stored, only accepted and hashed
) {}
There is an important idea here: basic constraints (not blank, valid email, minimum password length) are not "security," but they are a good filter for garbage on input. And then the business/security logic begins, which we keep in the service.
Further on, we will expose only a safe minimum — RegistrationResponse with userId and username.
Now the controller itself. We are not building a "universal user CRUD." We are building an AuthController that handles auth scenarios specifically, rather than turning into a universal user CRUD.
The class skeleton can stay as simple as possible:
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final RegistrationService registrationService;
public AuthController(RegistrationService registrationService) {
this.registrationService = registrationService; // inject the registration scenario
}
}
And the registration method itself should be "thin." It accepts a DTO and delegates. That is exactly the habit we want to reinforce.
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
// Important: the controller should not contain registration business logic
@PostMapping("/register")
@ResponseStatus(HttpStatus.CREATED)
public RegistrationResponse register(@Valid @RequestBody RegisterRequest request) {
return registrationService.register(request); // scenario logic lives in the service
}
And finally, the service. At the start, one fact is enough: RegistrationService.register(...) is a separate scenario, not a side effect of the controller. That is where the availability checks, UserAccount construction, saving, and creation of an empty profile will live.
6. The skeleton of the register(...) method
When you write a registration service method, do not think "how can I save the entity as quickly as possible," but rather "how can I make the scenario readable." A good service method is one you can open a month later and still understand without an archaeological expedition in git blame.
That is why even at the "skeleton" level it is useful to make a method that reads like a recipe. Basic DTO validation has already happened at the controller boundary, so inside we are left with steps that determine the account's security model.
public RegistrationResponse register(RegisterRequest request) {
ensureRegistrationDataAvailable(request); // 1) username/email are still free
UserAccount account = buildAccount(request); // 2) build the account according to security rules
UserAccount savedAccount = userAccountRepository.save(account); // 3) after save we get a real id
createInitialProfile(savedAccount); // 4) profile is a separate step in the same scenario
return toResponse(savedAccount); // 5) expose only the safe minimum
}
What matters here is not that the helper methods are not shown yet, but that the code defines the shape: first check registration data availability, then build the entity, save it, perform a separate profile step, and only then return a safe response. This saves you from the classic mistake of "we created the account, then remembered the email is already taken."
Another useful thought for a beginner: a service method usually should not be "smart" in the sense that it does not have to contain all logic in one pile. Its job is to manage the scenario. Separate details are better moved into small private methods or separate components. This is exactly the case where "break it into steps" is not an academic recommendation, but real prevention of future chaos.
7. Registration and Spring Security
The most common "mental bug" for beginners sounds like this: "Registration is about security, so Spring Security should somehow 'handle registration'." In practice, Spring Security knows nothing about registration. It knows how to authenticate a user according to its rules, but creating a UserAccount is already your application logic.
Registration is needed not to "bypass security," but to prepare the data that security later works with. After registration, the user should be able to go through the standard login flow: DaoAuthenticationProvider takes the login, loads UserDetails through UserDetailsService, compares the raw password with passwordHash via PasswordEncoder, checks account states, and only then authenticates.
It is useful to keep a simple "write-path vs read-path" sequence in mind:
sequenceDiagram
%% RegistrationService writes data, Spring Security later reads and checks it
participant C as "Client (anonymous)"
participant R as RegistrationService
participant DB as DB
participant SS as "Spring Security (DaoAuthenticationProvider)"
C->>R: register(username, email, password)
R->>DB: save(UserAccount with passwordHash, roles, flags)
Note over DB: the account appeared and matches the invariants
C->>SS: login(username + password)
SS->>DB: load user by username
SS->>SS: PasswordEncoder.matches(raw, hash)
SS-->>C: success or failure
This leads to an important conclusion: registration and login are different scenarios. Registration creates an account. Login proves the account is yours. We do not mix them into one endpoint and do not try to "log in immediately" after registration. This simplifies the system and makes behavior predictable.
8. Typical registration mistakes
Mistake #1: a "fat" controller.
Almost always the first mistake in registration is a "fat controller," where the register() method contains half the application: checks, password encoding, role assignment, saving to the database, building the response, and sometimes an attempt to "log in immediately." Such code feels fast when written, but it ages very badly. You will start adding uniqueness checks, then initial flags, then profile initialization — and suddenly find that the controller has turned into a pile of logic that is hard to test and impossible to read.
Mistake #2: accepting "server-side" fields from the client.
The second common mistake is accepting "server-side" fields from the client. This especially applies to the role and account state flags. If RegisterRequest contains role, enabled, or accountNonLocked, then you are giving the client a way to influence the security model. Even if "the frontend does not send such fields right now," that is not an argument: the client is the entire internet. The server must assign the initial role and initial states itself, otherwise registration becomes an access hole.
Mistake #3: extra data in logs and responses.
The third mistake is not always obvious, but it is very harmful: logging or returning unnecessary data in the response. The saddest case is when someone writes log.info("Register request: {}", request) and the raw password goes into the logs. This is not "oops, ugly," it is a real secret leak into one of the most durable information sources in a company — logs. Even in a training project, train yourself: do not log passwords, do not return passwordHash either, and expose only the minimally safe result in the response.
Mistake #4: mixing UserAccount and UserProfile into one entity.
The fourth mistake is mixing UserAccount and UserProfile into one shapeless lump. Registration should create the account's security core, and the profile is a separate story. Yes, we create an empty profile in the same scenario, but it is a separate step that does not affect password, role, or account state storage rules. If you glue everything into one entity and one endpoint, you will quickly end up with a "god User" where passwordHash and avatarPath sit next to each other, and that is hard to clean up later.
Mistake #5: saving the account before checks and trying to "roll back."
And finally, the fifth mistake is to save the account before checks and then try to "roll back" the situation. When you first call save() and only then find out the email is taken, a theater begins: we catch database exceptions, map them to errors, try to understand exactly what conflicted, and why the behavior differs across environments. The right habit at the fundamentals level is this: first the scenario accepts the input, checks the basic conditions and registration data availability, and only then creates and saves the UserAccount.
GO TO FULL VERSION