Registration, errors and dev data

A registration API with validation, one consistent error format, and demo credentials that only exist in the dev profile.

  • Part 6
  • intermediate
  • about 45 minutes

You will build

a registration endpoint, a centralised error handler, and a dev-profile initializer for the demo user and client

You will understand

why demo credentials must never be a migration, and how to scope CSRF exemptions instead of disabling them everywhere

  • one controller, one service, one error handler

A real template needs a clean way to create users and a clean failure contract for API consumers.

It also needs to stop pretending development credentials belong in production configuration.

This part adds all three.

Registration request

Use a DTO instead of binding directly to UserEntity:

public record RegisterUserRequest(

        @NotBlank
        @Size(min = 3, max = 50)
        @Pattern(regexp = "^[a-zA-Z0-9._-]+$")
        String username,

        @NotBlank
        @Email
        @Size(max = 255)
        String email,

        @NotBlank
        @Size(min = 8, max = 72)
        String password
) {}

Why cap a password at 72 here? If your configured password algorithm has implementation-specific input behavior, document and validate the boundary intentionally rather than accepting arbitrary payloads. Match the validation to the encoder you actually use.

Response:

public record UserResponse(
        UUID id,
        String username,
        String email
) {}

No password hash ever leaves the service boundary.

Registration service

Core steps:

normalize username/email
       ↓
check duplicates
       ↓
load USER role
       ↓
encode password
       ↓
initialize account state
       ↓
save

Example:

String username = request.username().trim();
String email = request.email().trim().toLowerCase(Locale.ROOT);

Reject duplicate username/email with a conflict exception.

Then:

user.setPassword(
        passwordEncoder.encode(request.password())
);

user.setEnabled(true);
user.setAccountNonExpired(true);
user.setAccountNonLocked(true);
user.setCredentialsNonExpired(true);
user.getRoles().add(userRole);

Registration controller

@RestController
@RequestMapping("/api/v1/auth")
public class AuthController {

    @PostMapping("/register")
    @ResponseStatus(HttpStatus.CREATED)
    UserResponse register(
            @Valid @RequestBody RegisterUserRequest request
    ) {
        return registrationService.register(request);
    }
}

Security rule for registration

Public registration must be explicitly permitted:

.requestMatchers(
        HttpMethod.POST,
        "/api/v1/auth/register"
)
.permitAll()

If your normal application chain uses CSRF protection for browser flows, ignore CSRF only for the intended stateless registration endpoint rather than disabling it everywhere.

That distinction becomes more important as the application grows.

Centralized API error response

Create a stable error structure:

public record ApiErrorResponse(
        Instant timestamp,
        int status,
        String error,
        String message,
        String path,
        Map<String, String> validationErrors
) {}

Then handle exceptions in @RestControllerAdvice.

Examples:

ResourceConflictException  → 409
ResourceNotFoundException  → 404
InvalidRequestException    → 400
validation failure         → 400
unexpected error           → 500 generic message

Do not return arbitrary internal exception messages from the generic Exception handler.

A safe generic response is:

An unexpected error occurred

while the server logs preserve the internal diagnostic information.

Validation errors

For MethodArgumentNotValidException, build a deterministic map:

{
  "message": "Validation failed",
  "validationErrors": {
    "email": "must be a well-formed email address",
    "password": "size must be between 8 and 72"
  }
}

This is much more useful to API clients than returning a stack trace or framework-specific exception payload.

Development seed data belongs in dev

Create an initializer:

@Component
@Profile("dev")
public class DevelopmentDataInitializer
        implements ApplicationRunner {
    // ...
}

The profile annotation is the security boundary:

flowchart TD
    Start[Application starts]
    P{Active profile}
    P -->|dev| Seed[Create demo user/client]
    P -->|prod| NoSeed[No demo credentials]
    P -->|test| Fixtures[Test setup]

The initializer should:

  1. load the USER and ADMIN roles seeded by Flyway;
  2. create/update the development user;
  3. ensure the dev user has both roles;
  4. create demo-client only when it does not already exist.

Development user:

username: user
email: user@example.com
password: password
roles: USER, ADMIN

Development client:

client_id: demo-client
client_secret: demo-secret

Those values are intentionally obvious because they are development fixtures. The real safety mechanism is that they do not run outside dev.

Start development explicitly

SPRING_PROFILES_ACTIVE=dev ./mvnw spring-boot:run

This is better than quietly setting dev as the application’s universal default.

Test registration

curl -i \
  -X POST http://localhost:9000/api/v1/auth/register \
  -H "Content-Type: application/json" \
  -d '{
    "username": "alice",
    "email": "alice@example.com",
    "password": "Password123!"
  }'

Expected:

HTTP/1.1 201

Duplicate username should return:

409 Conflict

Invalid email should return:

400 Bad Request

with validation information.

Why demo data should not be a Flyway migration

Essential domain constants such as baseline roles can reasonably be migration data.

Demo credentials are different. If user/password and demo-client/demo-secret are inserted unconditionally through schema migrations, they can accidentally appear in production.

Use:

Flyway → required domain/schema baseline
@Profile("dev") initializer → disposable development fixtures

Figure: startup responsibilities

flowchart LR
    Flyway[Flyway migrations] --> Schema[Schema + required roles]
    Dev["@Profile dev initializer"] --> Demo[Demo user + demo OAuth client]
    Schema --> App[Application]
    Demo --> App

The code

This part corresponds to these commits in the repository:

  • 21e8771 feat: add user registration
  • f28b64d feat: add centralized API exception handling
  • 56affa2 refactor: move demo OAuth client to dev seed data

Check out the last one to see the project exactly as this part leaves it:

git checkout 56affa2

References

Next: turn our application into a manageable authorization server with ADMIN-protected OAuth client and scope APIs.