Skip to content

multi-role-auth.md — abdush — kahramonov.dev

abdush › notes › spring › multi-role-auth.md

notes / spring / multi-role-auth.md · 2026-07 · 19 min

# Multi-Role Authentication Architecture

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


## 1. Recommended Database Design

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) is linked one-to-one to admin_profile (department, access_level), manager_profile (department, team_size), driver_profile (truck_number, license_number) and client_profile (company_name, address). Each profile id is both primary key and foreign key to app_user, and each user has exactly one profile, matching their role.

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.

### Option C — Composition (separate profile entities, 1:1 association) — Recommended

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();
    }
}

Login sequence: the client sends POST /login to the SecurityFilterChain, which calls AuthenticationManager.authenticate(). That calls CustomUserDetailsService.loadUserByUsername(), which calls AppUserRepository.findByUsername(), which runs a SELECT on the database. The row comes back as an AppUser, which is wrapped in an AppUserPrincipal. The AuthenticationManager checks the password with BCrypt.matches(raw, hash) and returns a successful Authentication, the filter chain stores it in the SecurityContext, and the client gets 200 OK with a session or 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());
    }
}

GET /api/me flow: the request carries a session cookie or Authorization header. SecurityFilterChain authenticates it and fills SecurityContextHolder, then MeController.me() calls ProfileService.getCurrentUserProfile(), which gets the user from SecurityUtils.getCurrentAppUser(). A switch on role picks AdminProfileRepository, ManagerProfileRepository, DriverProfileRepository or ClientProfileRepository and calls findById(user.getId()). The profile entity is returned to the client with 200 OK.

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

AdvantagesDisadvantages
Auth pathAppUser is small — logins are fast, no accidental N+1 across profile tables—
ExtensibilityNew role = new entity + table, zero touches to existing code—
Separation of concernsAuth (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 integrityClean per-role schema (NOT NULL where it should be), no giant sparse tableSchema alone doesn't guarantee "exactly one profile per user" — enforced by app code (transactional signup service)
QueryingSimple joins when needed (app_user JOIN driver_profile)No JPA-level polymorphism — "get the profile" always needs a manual switch(role)
Migration pathProfile tables can later be split into separate services/schemas cleanlyIf 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.