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.
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 │
└────────────┬─────────────┘
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_userplus 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 toAppUser, 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
switchonrole(shown in §7). You must maintain the invariant "exactly one profile row per role" yourself, typically inside a single@Transactionalsignup 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/clientProfilefield here. Adding those would mean every load ofAppUser(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
AppUserminimal — resist adding role-specific fields or@OneToOneprofile references to it directly. - Always hash passwords with
BCryptPasswordEncoder(orArgon2PasswordEncoder); never store or log plaintext. - Wrap "create
AppUser+ create profile" in one@Transactionalmethod 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 withON DELETE CASCADE. - Validate uniqueness (
existsByUsername) before insert, but also keep the DBUNIQUEconstraint as the real source of truth — handle the resultingDataIntegrityViolationExceptionfor race conditions under concurrent signups. - Map entities to DTOs at the controller boundary instead of returning JPA entities directly from
@RestControllermethods (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.passwordaccessible via a public getter that ends up serialized in an API response — always DTO-map, never return the entity as-is. - Forgetting
fetch = FetchType.LAZYon the profile's@OneToOne(mappedBy/@MapsId)back-reference toAppUser, 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 theRoleenum and Spring Security'shasRole()/@PreAuthorizeconsistently.