Skip to content

Multi-Role Authentication Architecture — Spring Boot 3 / Spring Security 6 / JPA / PostgreSQL

A single AppUser table for login + separate per-role profile tables, wired together with Spring Security.


One narrow authentication table, and one profile table per role, linked 1:1 via a shared primary key (the profile's PK is also a FK back to app_user.id). This keeps the login path (which only ever touches app_user) fast and decoupled from how many roles or profile fields you add later.

CREATE TABLE app_user (
    id                  BIGSERIAL PRIMARY KEY,
    username            VARCHAR(100) NOT NULL UNIQUE,
    password            VARCHAR(255) NOT NULL,          -- BCrypt hash
    role                VARCHAR(20)  NOT NULL
                         CHECK (role IN ('ADMIN','MANAGER','DRIVER','CLIENT')),
    enabled             BOOLEAN NOT NULL DEFAULT TRUE,
    account_non_locked  BOOLEAN NOT NULL DEFAULT TRUE,
    created_at          TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE admin_profile (
    id            BIGINT PRIMARY KEY REFERENCES app_user(id) ON DELETE CASCADE,
    department    VARCHAR(100) NOT NULL,
    access_level  INT NOT NULL
);

CREATE TABLE manager_profile (
    id            BIGINT PRIMARY KEY REFERENCES app_user(id) ON DELETE CASCADE,
    department    VARCHAR(100) NOT NULL,
    team_size     INT
);

CREATE TABLE driver_profile (
    id              BIGINT PRIMARY KEY REFERENCES app_user(id) ON DELETE CASCADE,
    truck_number    VARCHAR(50) NOT NULL,
    license_number  VARCHAR(50) NOT NULL
);

CREATE TABLE client_profile (
    id            BIGINT PRIMARY KEY REFERENCES app_user(id) ON DELETE CASCADE,
    company_name  VARCHAR(150) NOT NULL,
    address       VARCHAR(255)
);

ER diagram:

                         ┌──────────────────────────┐
                         │         app_user          │
                         ├──────────────────────────┤
                         │ id (PK)                   │
                         │ username (UNIQUE)         │
                         │ password                  │
                         │ role  [ADMIN/MANAGER/     │
                         │        DRIVER/CLIENT]     │
                         │ enabled                   │
                         │ account_non_locked        │
                         │ created_at                │
                         └────────────┬─────────────┘
                    1:1  ┌────────────┼────────────┐  1:1
             ┌───────────┘            │            └───────────┐
             │                        │                        │
   ┌─────────┴─────────┐   ┌──────────┴─────────┐   ┌──────────┴─────────┐   ┌─────────────────────┐
   │   admin_profile    │   │  manager_profile    │   │   driver_profile    │   │   client_profile     │
   ├─────────────────────┤   ├──────────────────────┤   ├──────────────────────┤   ├───────────────────────┤
   │ id (PK, FK→app_user)│   │ id (PK, FK→app_user) │   │ id (PK, FK→app_user) │   │ id (PK, FK→app_user)  │
   │ department           │   │ department            │   │ truck_number          │   │ company_name           │
   │ access_level         │   │ team_size             │   │ license_number        │   │ address                │
   └─────────────────────┘   └──────────────────────┘   └──────────────────────┘   └───────────────────────┘

Only the row matching app_user.role exists in the corresponding profile table — a DRIVER never has a row in client_profile. This is enforced at the application layer (the signup service), not the schema, which is the normal trade-off with this pattern (more on this in §9).


2. OneToOne vs. Inheritance vs. Composition — and why

There are three realistic ways to model "one auth record, many possible profile shapes" in JPA:

Option A — Single Table Inheritance

One giant app_user table with every possible column (department, truck_number, company_name, …) and a role discriminator, all profile columns nullable.

  • ✅ No joins, fastest reads.
  • ❌ Wide, sparse, mostly-NULL table. Every new role bloats every existing row. No NOT NULL constraints possible on role-specific fields. Gets ugly fast with 4+ roles that don't share fields — not recommended here, since your profiles genuinely have nothing in common.

Option B — Joined Table Inheritance

AppUser becomes an @Entity @Inheritance(strategy = InheritanceType.JOINED) base class; AdminUser, DriverUser, ClientUser extend it directly (no separate "profile" concept — the subclass is the user).

  • ✅ Type-safe polymorphism (appUserRepository.findAll() returns real subtype instances); natural fit if a "driver" conceptually never becomes an "admin".
  • ❌ Every polymorphic query hits app_user plus a join to the subtype table (Hibernate does this even to authenticate). Changing a user's role means physically moving rows between tables. Adding a role means a schema migration + a new entity class wired into the inheritance hierarchy. Lazy-loading subclass fields through a superclass reference is a classic Hibernate footgun.

This is what's modeled above: AppUser stays deliberately thin (exactly what Spring Security needs), and each role has its own independent entity linked by a shared primary key (@MapsId).

  • ✅ The authentication path (loadUserByUsername) only ever touches one small table — no joins, no risk of a slow/failing profile fetch blocking login.
  • ✅ Adding a new role (SUPPORT, AUDITOR, …) means adding a new profile entity + table. Zero changes to AppUser, existing profiles, or the login code.
  • ✅ Clean separation of concerns: "who can log in" (auth/security bounded context) vs. "what does this person do" (domain/business context). This also happens to be the seam you'd cut along if you ever split into microservices (see §10).
  • ❌ No polymorphism "for free" — fetching "the profile" requires an explicit switch on role (shown in §7). You must maintain the invariant "exactly one profile row per role" yourself, typically inside a single @Transactional signup service (§8).

Verdict: given very different, largely non-overlapping fields per role, and a single shared AppUser login table, Option C (composition with @MapsId) is the right call. Reach for Option B only if profiles genuinely share a lot of structure/behavior and you want JPA-level polymorphism over "users"; reach for Option A only for 1–2 extra nullable columns, never for structurally distinct entities like yours.


3–4. Complete JPA Entities & Repositories

Package layout used below: com.example.fleetapp.

Role enum

package com.example.fleetapp.domain;

public enum Role {
    ADMIN,
    MANAGER,
    DRIVER,
    CLIENT
}

AppUser (the auth entity — deliberately lean)

package com.example.fleetapp.domain;

import jakarta.persistence.*;
import lombok.*;

import java.time.Instant;

@Entity
@Table(name = "app_user")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class AppUser {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true, length = 100)
    private String username;

    @Column(nullable = false, length = 255)
    private String password; // BCrypt hash — never store plaintext

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 20)
    private Role role;

    @Column(nullable = false)
    private boolean enabled = true;

    @Column(name = "account_non_locked", nullable = false)
    private boolean accountNonLocked = true;

    @Column(name = "created_at", nullable = false, updatable = false)
    private Instant createdAt;

    @PrePersist
    void onCreate() {
        this.createdAt = Instant.now();
    }
}

Note there's deliberately no @OneToOne adminProfile / driverProfile / clientProfile field here. Adding those would mean every load of AppUser (including on every authenticated request) risks pulling in — or lazily proxying — up to four unrelated relations. Keep the auth entity single-purpose; fetch profiles explicitly (§7).

AdminProfile

package com.example.fleetapp.domain;

import jakarta.persistence.*;
import lombok.*;

@Entity
@Table(name = "admin_profile")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class AdminProfile {

    @Id
    private Long id; // shared with AppUser.id — no separate sequence

    @OneToOne(fetch = FetchType.LAZY)
    @MapsId
    @JoinColumn(name = "id")
    private AppUser appUser;

    @Column(nullable = false, length = 100)
    private String department;

    @Column(name = "access_level", nullable = false)
    private Integer accessLevel;
}

ManagerProfile

package com.example.fleetapp.domain;

import jakarta.persistence.*;
import lombok.*;

@Entity
@Table(name = "manager_profile")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ManagerProfile {

    @Id
    private Long id;

    @OneToOne(fetch = FetchType.LAZY)
    @MapsId
    @JoinColumn(name = "id")
    private AppUser appUser;

    @Column(nullable = false, length = 100)
    private String department;

    @Column(name = "team_size")
    private Integer teamSize;
}

DriverProfile

package com.example.fleetapp.domain;

import jakarta.persistence.*;
import lombok.*;

@Entity
@Table(name = "driver_profile")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class DriverProfile {

    @Id
    private Long id;

    @OneToOne(fetch = FetchType.LAZY)
    @MapsId
    @JoinColumn(name = "id")
    private AppUser appUser;

    @Column(name = "truck_number", nullable = false, length = 50)
    private String truckNumber;

    @Column(name = "license_number", nullable = false, length = 50)
    private String licenseNumber;
}

ClientProfile

package com.example.fleetapp.domain;

import jakarta.persistence.*;
import lombok.*;

@Entity
@Table(name = "client_profile")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ClientProfile {

    @Id
    private Long id;

    @OneToOne(fetch = FetchType.LAZY)
    @MapsId
    @JoinColumn(name = "id")
    private AppUser appUser;

    @Column(name = "company_name", nullable = false, length = 150)
    private String companyName;

    @Column(length = 255)
    private String address;
}

Repositories

package com.example.fleetapp.repository;

import com.example.fleetapp.domain.AppUser;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface AppUserRepository extends JpaRepository<AppUser, Long> {
    Optional<AppUser> findByUsername(String username);
    boolean existsByUsername(String username);
}
package com.example.fleetapp.repository;

import com.example.fleetapp.domain.AdminProfile;
import org.springframework.data.jpa.repository.JpaRepository;

public interface AdminProfileRepository extends JpaRepository<AdminProfile, Long> {}
package com.example.fleetapp.repository;

import com.example.fleetapp.domain.ManagerProfile;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ManagerProfileRepository extends JpaRepository<ManagerProfile, Long> {}
package com.example.fleetapp.repository;

import com.example.fleetapp.domain.DriverProfile;
import org.springframework.data.jpa.repository.JpaRepository;

public interface DriverProfileRepository extends JpaRepository<DriverProfile, Long> {}
package com.example.fleetapp.repository;

import com.example.fleetapp.domain.ClientProfile;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ClientProfileRepository extends JpaRepository<ClientProfile, Long> {}

Note the profile repositories use Long (the shared id) as the key — thanks to @MapsId, findById(appUser.getId()) on DriverProfileRepository is exactly the row you want, no separate FK lookup needed.


5. How Spring Security authenticates using AppUser

Spring Security doesn't know about AppUser — it only knows about UserDetails. You write a thin adapter (AppUserPrincipal) and a UserDetailsService that loads an AppUser and wraps it.

AppUserPrincipal (adapter)

package com.example.fleetapp.security;

import com.example.fleetapp.domain.AppUser;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
import java.util.List;

public class AppUserPrincipal implements UserDetails {

    private final AppUser appUser;

    public AppUserPrincipal(AppUser appUser) {
        this.appUser = appUser;
    }

    public AppUser getAppUser() {
        return appUser;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(new SimpleGrantedAuthority("ROLE_" + appUser.getRole().name()));
    }

    @Override
    public String getPassword() {
        return appUser.getPassword();
    }

    @Override
    public String getUsername() {
        return appUser.getUsername();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return appUser.isAccountNonLocked();
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return appUser.isEnabled();
    }
}

CustomUserDetailsService

package com.example.fleetapp.security;

import com.example.fleetapp.domain.AppUser;
import com.example.fleetapp.repository.AppUserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final AppUserRepository appUserRepository;

    @Override
    @Transactional(readOnly = true)
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        AppUser appUser = appUserRepository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("No user found: " + username));
        return new AppUserPrincipal(appUser);
    }
}

SecurityConfig

package com.example.fleetapp.config;

import com.example.fleetapp.security.CustomUserDetailsService;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.AuthenticationProvider;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
@EnableMethodSecurity // enables @PreAuthorize on service/controller methods
@RequiredArgsConstructor
public class SecurityConfig {

    private final CustomUserDetailsService userDetailsService;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public AuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        provider.setUserDetailsService(userDetailsService);
        provider.setPasswordEncoder(passwordEncoder());
        return provider;
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable()) // fine for stateless APIs; re-enable for form/browser apps
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .requestMatchers("/api/manager/**").hasAnyRole("ADMIN", "MANAGER")
                .requestMatchers("/api/driver/**").hasAnyRole("ADMIN", "DRIVER")
                .requestMatchers("/api/client/**").hasAnyRole("ADMIN", "CLIENT")
                .anyRequest().authenticated()
            )
            .httpBasic(Customizer.withDefaults()) // swap for a JWT filter in production, see §10
            .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED));

        return http.build();
    }
}

Authentication flow:

Client              SecurityFilterChain      AuthenticationManager     CustomUserDetailsService     AppUserRepository       DB
  |  POST /login(u,p)     |                          |                          |                          |                |
  |----------------------->|                          |                          |                          |                |
  |                        |--- authenticate() ------>|                          |                          |                |
  |                        |                          |--- loadUserByUsername -->|                          |                |
  |                        |                          |                          |--- findByUsername() --->|                |
  |                        |                          |                          |                          |--- SELECT --->|
  |                        |                          |                          |                          |<-- row -------|
  |                        |                          |                          |<-- AppUser --------------|                |
  |                        |                          |<-- AppUserPrincipal -----|                          |                |
  |                        |                          | BCrypt.matches(raw, hash)|                          |                |
  |                        |<-- Authentication(OK) ---|                          |                          |                |
  |                        | store in SecurityContext |                          |                          |                |
  |<--- 200 OK (session/JWT)|                         |                          |                          |                |

6. Getting the current logged-in user from SecurityContextHolder

package com.example.fleetapp.security;

import com.example.fleetapp.domain.AppUser;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;

public final class SecurityUtils {

    private SecurityUtils() {}

    public static AppUser getCurrentAppUser() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication == null
                || !authentication.isAuthenticated()
                || "anonymousUser".equals(authentication.getPrincipal())) {
            throw new IllegalStateException("No authenticated user in context");
        }
        AppUserPrincipal principal = (AppUserPrincipal) authentication.getPrincipal();
        return principal.getAppUser();
    }
}

Use it anywhere in a service or controller running inside a request — it just reads the ThreadLocal-backed context that the security filter chain already populated.


7. Fetching the corresponding profile after login

Since profiles aren't polymorphic, dispatch on role once, in one place:

package com.example.fleetapp.service;

import com.example.fleetapp.domain.AppUser;
import com.example.fleetapp.repository.*;
import com.example.fleetapp.security.SecurityUtils;
import jakarta.persistence.EntityNotFoundException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class ProfileService {

    private final AdminProfileRepository adminProfileRepository;
    private final ManagerProfileRepository managerProfileRepository;
    private final DriverProfileRepository driverProfileRepository;
    private final ClientProfileRepository clientProfileRepository;

    @Transactional(readOnly = true)
    public Object getCurrentUserProfile() {
        AppUser user = SecurityUtils.getCurrentAppUser();
        return switch (user.getRole()) {
            case ADMIN -> adminProfileRepository.findById(user.getId())
                    .orElseThrow(() -> new EntityNotFoundException("Admin profile missing for user " + user.getId()));
            case MANAGER -> managerProfileRepository.findById(user.getId())
                    .orElseThrow(() -> new EntityNotFoundException("Manager profile missing for user " + user.getId()));
            case DRIVER -> driverProfileRepository.findById(user.getId())
                    .orElseThrow(() -> new EntityNotFoundException("Driver profile missing for user " + user.getId()));
            case CLIENT -> clientProfileRepository.findById(user.getId())
                    .orElseThrow(() -> new EntityNotFoundException("Client profile missing for user " + user.getId()));
        };
    }
}
package com.example.fleetapp.web;

import com.example.fleetapp.service.ProfileService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/me")
@RequiredArgsConstructor
public class MeController {

    private final ProfileService profileService;

    @GetMapping
    public ResponseEntity<Object> me() {
        return ResponseEntity.ok(profileService.getCurrentUserProfile());
    }
}

Request flow:

Client -> GET /api/me  (session cookie or Authorization header)
   -> SecurityFilterChain authenticates, populates SecurityContextHolder
   -> MeController.me()
        -> ProfileService.getCurrentUserProfile()
             -> SecurityUtils.getCurrentAppUser()   [reads SecurityContextHolder]
             -> switch(role) -> matching *ProfileRepository.findById(user.getId())
        <- profile entity
   <- 200 OK  { department / truckNumber / companyName ... }

In practice you'd map the profile entity to a role-specific DTO rather than serialize the entity directly (avoids leaking lazy-proxy fields and couples the API contract to your domain model less tightly).


8. Signup for each role

One base-user creation step, reused by role-specific signup flows, all inside a single transaction so you never end up with an AppUser that has no matching profile.

Request DTOs

public record AdminSignupRequest(String username, String password, String department, Integer accessLevel) {}
public record ManagerSignupRequest(String username, String password, String department, Integer teamSize) {}
public record DriverSignupRequest(String username, String password, String truckNumber, String licenseNumber) {}
public record ClientSignupRequest(String username, String password, String companyName, String address) {}

SignupService

package com.example.fleetapp.service;

import com.example.fleetapp.domain.*;
import com.example.fleetapp.repository.*;
import com.example.fleetapp.web.dto.*;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class SignupService {

    private final AppUserRepository appUserRepository;
    private final AdminProfileRepository adminProfileRepository;
    private final ManagerProfileRepository managerProfileRepository;
    private final DriverProfileRepository driverProfileRepository;
    private final ClientProfileRepository clientProfileRepository;
    private final PasswordEncoder passwordEncoder;

    @Transactional
    public AppUser signupAdmin(AdminSignupRequest req) {
        AppUser user = createBaseUser(req.username(), req.password(), Role.ADMIN);
        adminProfileRepository.save(AdminProfile.builder()
                .appUser(user)
                .department(req.department())
                .accessLevel(req.accessLevel())
                .build());
        return user;
    }

    @Transactional
    public AppUser signupManager(ManagerSignupRequest req) {
        AppUser user = createBaseUser(req.username(), req.password(), Role.MANAGER);
        managerProfileRepository.save(ManagerProfile.builder()
                .appUser(user)
                .department(req.department())
                .teamSize(req.teamSize())
                .build());
        return user;
    }

    @Transactional
    public AppUser signupDriver(DriverSignupRequest req) {
        AppUser user = createBaseUser(req.username(), req.password(), Role.DRIVER);
        driverProfileRepository.save(DriverProfile.builder()
                .appUser(user)
                .truckNumber(req.truckNumber())
                .licenseNumber(req.licenseNumber())
                .build());
        return user;
    }

    @Transactional
    public AppUser signupClient(ClientSignupRequest req) {
        AppUser user = createBaseUser(req.username(), req.password(), Role.CLIENT);
        clientProfileRepository.save(ClientProfile.builder()
                .appUser(user)
                .companyName(req.companyName())
                .address(req.address())
                .build());
        return user;
    }

    private AppUser createBaseUser(String username, String rawPassword, Role role) {
        if (appUserRepository.existsByUsername(username)) {
            throw new IllegalArgumentException("Username already taken: " + username);
        }
        AppUser user = AppUser.builder()
                .username(username)
                .password(passwordEncoder.encode(rawPassword))
                .role(role)
                .enabled(true)
                .accountNonLocked(true)
                .build();
        // IDENTITY generator means the INSERT (and id generation) happens immediately on save(),
        // so `user.getId()` is available right away for the @MapsId profile below.
        return appUserRepository.save(user);
    }
}

AuthController

package com.example.fleetapp.web;

import com.example.fleetapp.service.SignupService;
import com.example.fleetapp.web.dto.*;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

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

    private final SignupService signupService;

    @PostMapping("/signup/admin")
    public ResponseEntity<?> signupAdmin(@RequestBody AdminSignupRequest req) {
        signupService.signupAdmin(req);
        return ResponseEntity.status(HttpStatus.CREATED).build();
    }

    @PostMapping("/signup/manager")
    public ResponseEntity<?> signupManager(@RequestBody ManagerSignupRequest req) {
        signupService.signupManager(req);
        return ResponseEntity.status(HttpStatus.CREATED).build();
    }

    @PostMapping("/signup/driver")
    public ResponseEntity<?> signupDriver(@RequestBody DriverSignupRequest req) {
        signupService.signupDriver(req);
        return ResponseEntity.status(HttpStatus.CREATED).build();
    }

    @PostMapping("/signup/client")
    public ResponseEntity<?> signupClient(@RequestBody ClientSignupRequest req) {
        signupService.signupClient(req);
        return ResponseEntity.status(HttpStatus.CREATED).build();
    }
}

Separate endpoints per role (rather than one generic /signup with a polymorphic body) keep request validation simple — each DTO only has the fields that role actually needs, and Bean Validation annotations (@NotBlank, @Min, etc.) stay unambiguous. If you'd rather have one endpoint, you can still dispatch internally on a role field in the payload, but you lose per-role compile-time-checked request shapes.

In production, lock down /api/auth/signup/admin (and probably /manager) behind hasRole("ADMIN") rather than permitAll() — you don't want public self-service admin signup. Only driver/client self-registration (if that's your product) should stay open.


9. Advantages and Disadvantages of This Design

Advantages Disadvantages
Auth path AppUser is small — logins are fast, no accidental N+1 across profile tables
Extensibility New role = new entity + table, zero touches to existing code
Separation of concerns Auth (security-critical, rarely changes) is isolated from profile (business data, changes often) Two places to update when a user's role-defining data changes
Data integrity Clean per-role schema (NOT NULL where it should be), no giant sparse table Schema alone doesn't guarantee "exactly one profile per user" — enforced by app code (transactional signup service)
Querying Simple joins when needed (app_user JOIN driver_profile) No JPA-level polymorphism — "get the profile" always needs a manual switch(role)
Migration path Profile tables can later be split into separate services/schemas cleanly If a user could ever hold multiple roles simultaneously, this 1:1 model doesn't fit without rework (see below)
Role changes Promoting a DRIVER to MANAGER means deleting one profile row and inserting another — not just flipping an enum

Worth flagging explicitly: this model assumes one role per user. If your business ever needs a person to be both a CLIENT and a DRIVER at once, a 1:1 role column won't express that — you'd need a many-to-many user_role join table and profile tables that don't assume a single discriminator. Confirm that's genuinely out of scope before committing to this shape.


10. Alternative Enterprise-Grade Approaches

A. External Identity Provider (Keycloak / Auth0 / Okta / Cognito) Authentication (and often role/claims) is delegated entirely to an IdP; your database only stores profile data, keyed by the IdP's sub claim instead of your own AppUser table. Spring Security becomes an OAuth2 Resource Server validating JWTs, not a UserDetailsService. Common in larger orgs that need SSO, MFA, and centralized user management across many applications — but it's real infrastructure overhead if you're a single app.

B. JWT-based stateless auth (very common in Spring Boot APIs) Same AppUser/UserDetailsService design as above, but instead of httpBasic()/session cookies, you issue a signed JWT on /api/auth/login (embedding sub, role, exp) and add an OncePerRequestFilter that validates the token and populates SecurityContextHolder per request. Preferred for public APIs, mobile clients, and microservices where session affinity is a problem.

C. Identity + Profile as separate microservices AppUser lives in an "Identity" service; AdminProfile/DriverProfile/ClientProfile live in a "Profile" (or per-domain) service, kept in sync via events (UserCreated, RoleChanged) over Kafka/RabbitMQ, or simply looked up by userId via a REST call. This is exactly the seam the composition design above sets you up for — the fact that ProfileService already dispatches purely on AppUser.id + role means it's a small step to swap local repositories for HTTP/gRPC calls to another service.

D. Attribute table / EAV (user_attribute(user_id, key, value)) Maximally flexible schema-less profile storage. Generally discouraged for anything with real business fields — you lose types, constraints, and straightforward querying. Reasonable only for a handful of truly dynamic, admin-configurable fields layered on top of real columns, not as your primary profile model.

E. PostgreSQL JSONB profile column app_user.profile_data JSONB holding role-specific fields, validated in the application layer (or with CHECK constraints using jsonb operators). Gives schema flexibility without a full EAV table, and Postgres's JSONB indexing (GIN) keeps querying reasonable. Worth considering if role-specific fields change shape frequently (e.g. driven by a no-code admin panel) — but you lose FK integrity and most of the benefit of typed entities. For your case (fixed, well-known fields per role), plain relational tables are simpler and safer.


Best Practices

  • Keep AppUser minimal — resist adding role-specific fields or @OneToOne profile references to it directly.
  • Always hash passwords with BCryptPasswordEncoder (or Argon2PasswordEncoder); never store or log plaintext.
  • Wrap "create AppUser + create profile" in one @Transactional method so you can never get an orphaned auth row with no profile.
  • Use @MapsId (shared PK) over a separate FK + generated PK on profile tables — it's simpler, guarantees true 1:1, and cascades cleanly with ON DELETE CASCADE.
  • Validate uniqueness (existsByUsername) before insert, but also keep the DB UNIQUE constraint as the real source of truth — handle the resulting DataIntegrityViolationException for race conditions under concurrent signups.
  • Map entities to DTOs at the controller boundary instead of returning JPA entities directly from @RestController methods (avoids serializing lazy proxies or leaking password hashes).
  • Lock down admin/manager self-signup endpoints; don't permitAll() every /api/auth/** path uniformly.

Common Mistakes

  • Making AppUser.password accessible via a public getter that ends up serialized in an API response — always DTO-map, never return the entity as-is.
  • Forgetting fetch = FetchType.LAZY on the profile's @OneToOne(mappedBy/@MapsId) back-reference to AppUser, causing every profile fetch to eagerly pull the auth row too.
  • Doing profile creation in a separate transaction/request from user creation ("create user, then later create profile") — a failure between the two steps leaves an unusable account.
  • Trying to force JPA inheritance polymorphism (Option B) onto fields that don't actually share structure, then fighting Hibernate's lazy-loading/proxy quirks for it.
  • Hardcoding role-string checks ("ADMIN".equals(...)) scattered through the codebase instead of using the Role enum and Spring Security's hasRole()/@PreAuthorize consistently.