logo

Spring Boot User Service: Production-Ready Microservices Guide

Introduction

User management is one of the most deceptively complex capabilities in modern software systems.

At first glance, it appears straightforward: create users, authenticate them, assign roles, and store profile information.

In practice, user management quickly becomes the foundation upon which security, authorization, onboarding, password recovery, compliance, auditing, and user experience depend.

As organizations grow, these responsibilities become increasingly difficult to manage inside a single monolithic application. Security concerns evolve independently from business capabilities. User onboarding requirements change. Authentication standards mature. Regulatory requirements emerge.

For this reason, modern architecture often separate identity management into dedicated services with clearly defined responsibilities.

In this article, we’ll explore the design of a production-ready User Service built using Spring Boot. Beyond basic CRUD operations, the service supports role-based access control, stateless JWT security, event-driven password recovery, multilingual responses, and controlled database evolution through Flyway migrations.

By Bharat Chaudhari July 16, 2026

What exactly should a user service own?

One of the most common architectural mistakes in microservice ecosystems is creating a “God Service” responsible for every identity-related capability.

This often leads to services that handle:

  • User profiles
  • Authentication
  • Authorization
  • Sessions
  • Notifications
  • Audit logging

all within a single codebase.

Our platform deliberately separates these responsibilities.

The User Service owns:

  • User profiles
  • Role assignments
  • User lifecycle management
  • Password recovery workflows

The Authorization Service owns:

  • Authentication
  • Token issuance
  • Token validation infrastructure

This separation ensures that identity data and authentication infrastructure can evolve independently while maintaining clear ownership boundaries.

Why we separated user management from authentication

Many systems combine authentication and user management into a single service.

While this simplifies initial development, it often creates long-term coupling.

Authentication infrastructure tends to evolve around:

  • Security requirements
  • Token standards
  • Identity providers
  • Federation

User management evolves around:

  • Business workflows
  • Registration processes
  • Profile management
  • Role administration

These concerns change at different rates.

Separating them allows each service to focus on its own responsibility while reducing operational complexity.

The User Service manages users.

The Authorization Service manages trust.

User Service Architecture

				
					                    API Gateway  
                        │  
                        ▼  
 
                  User Service  
                        │  
 
      ┌─────────────────┼──────────────────┐  
 
      ▼                 ▼                  ▼  
 
User Management   Role Management    Password Reset  
 
      │                 │                  │  
 
      ▼                 ▼                  ▼  
 
PostgreSQL       PostgreSQL         Kafka Events  
 
                                           │  
 
                                           ▼  
 
                                 Notification Service 
				
			

What you will build

By the end of this guide, you will have a running User Service that handles the full identity lifecycle:

  • A secure RESTful API for creating, reading, updating, and deleting users.
  • Role assignment and method-level RBAC enforcement via @PreAuthorize.
  • Stateless JWT Bearer token validation against an external authorization server.
  • A three-step password reset flow: OTP generation, verification, and secure update.
  • Asynchronous email notification delivery decoupled through Apache Kafka.
  • Fully localized API responses driven by the client’s Accept-Language header.
  • Deterministic, version-controlled database migrations managed by Flyway.

Tech Stack
Here is a quick reference for every major technology used and the specific problem each one solves:

  • Java 21: Language foundation. Records, sealed classes, and virtual threads improve both expressiveness and throughput.
  • Spring Boot 4.0.3: Application framework providing auto-configuration, dependency injection, and the web layer.
  • PostgreSQL + Flyway: Persistence and migration management. Flyway owns all schema changes, while Hibernate validates them at startup.
  • Apache Kafka: Event backbone. OTP email delivery is published as an event, decoupling the password reset handler from the email transport.
  • Spring Security (OAuth2 / JWT): Stateless token validation against an authorization server’s public keys, with custom role extraction for RBAC.
  • Lombok + MapStruct: Compile-time code generation. Lombok removes boilerplate, while MapStruct generates type-safe entity-to-DTO mappers.
  • Maven: Build and dependency management.

Project Structure

The codebase follows clean architecture principles. Each package has a single, well-defined responsibility, and the dependency flow goes in one direction only—from controllers inward to services, and from services inward to repositories.

  • com.userservice.controller: Exposes REST endpoints. Controllers delegate immediately to the service layer—no business logic, no BindingResult, and no exception handling.
  • com.userservice.service.serviceimpl: Houses all business logic. UserServiceImpl and RoleServiceImpl own the rules around user lifecycle, role assignment, and OTP management.
  • com.userservice.repository: Spring Data JPA interfaces. The only layer allowed to communicate with the database directly.
  • com.userservice.model: Domain entities (User, Role, OTP) mapped to PostgreSQL via Hibernate.
  • com.userservice.exception: GlobalExceptionHandler and custom exception classes, providing centralized error handling across every endpoint.
  • com.userservice.security: OAuth2 Resource Server configuration and JWT setup.
  • src/main/resources/db/migration: Flyway SQL scripts. Every schema change is versioned and tracked here.

Note:

  • Architecture Note: This separation means business logic can be tested without starting a web server, the database layer can be swapped without touching services, and error response formatting lives in exactly one place.

Database Management with Flyway

A common mistake in Spring Boot projects is leaving Hibernate’s DDL auto-management active in production. Setting ddl-auto to update or create-drop is convenient during development but dangerous at scale: Hibernate can silently drop columns, ignore index definitions, or fail unpredictably on complex schema changes.

This service makes a deliberate choice: Flyway owns the schema and Hibernate only validates it. The configuration is explicit: spring.jpa.hibernate.ddl-auto=validate. On startup, Hibernate confirms that every entity maps correctly to the existing schema. If there is a mismatch, the application refuses to start — which is exactly the right behaviour. Flyway has already applied all pending migrations before Hibernate’s validation runs, so the two are always in sync.

What This Buys You

  • Migrations are version-controlled SQL – reviewed in pull requests just like application code.
  • Deployments are deterministic: the same migration scripts run in development, staging, and production.
  • Rollbacks are intentional: you write a rollback script rather than hoping Hibernate undoes something correctly.
  • The audit trail is clear: you can see exactly when each schema change was applied and by which deployment.

Why We Chose Flyway Over Automatic Schema Updates

Many Spring Boot applications rely on Hibernate’s automatic schema generation during development.

While convenient initially, this approach becomes increasingly risky in production environments.

Schema evolution is a critical operational concern. Database changes should be:

  • Reviewed
  • Versioned
  • Tested
  • Auditable

Flyway treats schema evolution as a first-class engineering activity.

Rather than allowing the ORM to modify production databases implicitly, every change is represented as an explicit migration script that travels through the same review and deployment process as application code.

This provides significantly greater confidence during production deployments.

Security: OAuth2 Resource Server and JWT

Token Validation Flow
  1. The client sends a request with a Authorization: Bearer <token> header.
  2. Spring Security extracts and parses the JWT without any custom code — this is handled by the auto-configured OAuth2 resource server support.
  3. The token signature is verified against public keys fetched from the ISSUER_URI (a standard JWKS endpoint).
  4. Custom authorities are extracted from the JWT claims and converted into Spring Security GrantedAuthority objects.
  5. @PreAuthorize annotations on service methods enforce role-based access control using these authorities, with no additional database queries.
Because all states live in the token, and the token is validated cryptographically, the service itself is entirely stateless. Any number of instances can validate any token independently, which makes horizontal scaling trivially simple. Why Stateless JWT Validation Matters Traditional session-based authentication requires every request to reference centralized session state. As systems scale horizontally, this often introduces additional infrastructure such as:
  • Session databases
  • Sticky sessions
  • Distributed caches
JWT validation eliminates these dependencies. Each token contains the information required for verification and authorization of decisions. This allows every User Service instance to validate requests independently without relying on centralized session infrastructure. The result is simpler scaling and reduced operational complexity. Tip: Local Development Tip: Never disable JWT signature validation, even locally. Run a Keycloak or Spring Authorization Server instance in Docker to generate real tokens. It takes ten minutes to set up and prevents an entire category of security bugs that only surface in production.

Role-Based Access Control (RBAC)

Most applications eventually require differentiated access levels.

Examples include:

  • Administrators
  • Managers
  • Standard Users
  • Auditors

The User Service uses role-based access control to manage these permissions.

Rather than hardcoding permissions throughout controllers, authorization decisions are expressed declaratively using Spring Security annotations.

Example:

@PreAuthorize("hasRole('ADMIN')")

This approach centralizes authorization rules, improves readability, and simplifies future permission changes.

As requirements evolve, additional roles can be introduced without modifying application workflows.

Centralized Exception Handling

Inconsistent error responses are one of the most common signs of a service that grew without a plan. A 400 that returns plain text, a 404 that returns an HTML error page, and a 500 that exposes a stack trace are all symptoms of the same root cause: error handling treated as an afterthought.

This service treats exception handling as a first-class architectural concern. Every failure — from a missing user ID to a constraint violation — returns a structured JSON response with a consistent shape:

				
					{ 
  "timestamp": "2026-03-13T12:00:00.000", 
  "status": 400, 
  "error": "Bad Request", 
  "message": "Validation failed", 
  "path": "/api/v1/users/create", 
  "validationErrors": { 
    "email": "Email should be valid", 
    "password": "Password must be at least 8 characters long" 
  } 
} 
				
			

The Exception Pipeline

Custom exceptions extend to a shared BaseException class. The key design rule: exceptions never resolve their own message. They store only a message key and an argument array:

				
					// Correct: pass the key and args — never the resolved string 
throw new UserNotFoundException( 
    "exception.resource.not.found", new Object[]{id} 
); 
				
			

Internationalisation (i18n)

Supporting multiple languages is often treated as a feature to add later. This service builds it in from day one, because retrofitting i18n into an existing codebase — especially one with scattered error messages — is significantly more disruptive than designing it upfront.

How the Localisation Flow Works

  1. Client sends a request with an optional Accept-Language header (e.g. Accept-Language: fr).
  2. Spring resolves the locale using AcceptHeaderLocaleResolver and exposes it via LocaleContextHolder.getLocale().
  3. Services and exception handlers call messageSource.getMessage(key, args, locale) to resolve the message in the correct language.
  4. The response is returned with message text in the requested language, falling back to English if no matching locale is found.

Adding a New Language

Adding Spanish support requires zero code changes and three configuration steps:

  1. Create messages_es.properties in src/main/resources/.
  2. Copy all keys from messages.properties and translate the values.
  3. Clients pass Accept-Language: es – no deployment required.

The Same Endpoint in Two Languages

English (Accept-Language: en or header omitted):

				
					{ 
  "status": 200, 
  "message": "User created successfully", 
  "data": { "id": 1, "firstName": "John" } 
} 
				
			
French (Accept-Language: fr):
				
					{ 
  "status": 200, 
  "message": "Utilisateur créé avec succès", 
  "data": { "id": 1, "firstName": "John" } 
} 
				
			

Note:
Naming Convention: Message keys follow a consistent dot-notation pattern: user.created, otp.send, role.notfound. This makes it easy to grep across the codebase and easy to audit for completeness when adding a new language file.

Kafka Integration: Event-Driven Password Reset

Why Password Recovery Uses Kafka

Password recovery appears to be a simple workflow:

  1. Generate OTP.
  2. Send email.
  3. Verify OTP.
  4. Update password.

Many systems implement this synchronously.

Unfortunately, this means user-facing requests become dependent on email infrastructure.

If the mail provider becomes slow or unavailable, password recovery requests become slow or unavailable as well.

Kafka removes this dependency.

The User Service generates the OTP, persists in it, publishes an event, and immediately returns a response.

Notification of delivery becomes an independent concern handled by the Notification Service.

This architecture improves:

  • Reliability
  • Latency
  • Scalability
  • Fault isolation

while maintaining a clean separation of responsibilities.

The Three-Step Reset Flow

  1. Initiate (GET /api/v1/forget-password/{email}): The User Service generates an OTP, persists it with an expiry timestamp, and publishes a NotificationMessage event to the notification-events Kafka topic. The request returns immediately.
  2. Verify (POST /api/v1/forget-password/verifying-otp): The submitted OTP is validated against the persisted value and its expiry time. Expired or incorrect OTPs return a structured error response.
  3. Update (POST /api/v1/forget-password/update-password): Once verified, the new password is hashed and persisted. The OTP record is invalidated to prevent reuse.

The Notification Service, a completely separate microservice, consumes events from the notification-events topic and sends the email asynchronously. The User Service has no knowledge of how or when the email is delivered.

Why This Architecture Wins

  • Fault isolation: an email service outage does not affect the password reset endpoint at all.
  • Latency: the API responds as soon as the OTP is persisted and the event is published — typically in single-digit milliseconds.
  • Independent scaling: the Notification Service can scale its consumer group based on email throughput without touching the User Service.
  • Retry resilience: Kafka’s consumer offset management ensures a notification is retried automatically if the Notification Service crashes mid-processing.

Note:

Setup Guide: For Kafka Docker Compose configuration, topic creation, consumer group settings, and local troubleshooting, see docs/kafka-setup.md in the repository.

Production Considerations

Password Reset Abuse

Password recovery endpoints are common targets for abuse.

Production deployments should implement:

  • Rate limiting
  • OTP expiration
  • Retry limits
  • Account lockout policies

Audit Logging

User-related actions often require auditing.

Examples include:

  • Role assignments
  • Password changes
  • Account creation
  • Account deletion

Audit trails become increasingly important in regulated environments.

Data Privacy

User information frequently contains personally identifiable information (PII).

Encryption, access controls, and data retention policies should be considered during production deployments.

Scalability

The User Service is largely stateless.

Additional instances can be deployed horizontally without complex coordination requirements.

Any service depending on the Common Module will automatically start reporting distributed traces to Zipkin without writing custom configurations in every service. 

Common Mistakes

  • Combining Authentication and User Management: Creates unnecessary coupling.
  • Storing OTPs Indefinitely: Increases security risk.
  • Sending Emails Synchronously: Introduces unnecessary latency and failure dependencies.
  • Relying On Hibernate Auto-Update: Creates deployment risk.
  • Hardcoding Roles: Reduces flexibility and complicates authorization management.

Getting Started

Prerequisites

  • Java 21 SDK installed with JAVA_HOME configured correctly.
  • Apache Maven.
  • A running PostgreSQL 15+ instance (local or remote).
  • Apache Kafka is accessible on the network (required for OTP event publishing).

Environment Variables

Set the following before starting the application — in your shell, a .env file, or your IDE’s run configuration:

  • USER_SERVICE_DATASOURCE_URL: Full JDBC connection string, e.g. jdbc:postgresql://localhost:5432/userdb.
  • DB_USERNAME: PostgreSQL username.
  • DB_PASSWORD: PostgreSQL password.
  • ISSUER_URI: Base URI on your authorization server. Spring fetches public keys from this endpoint to validate JWTs.
  • SERVER: The port the application binds to. Defaults to 8080 if unset.

Build and Run

				
					# Clone the repository 
git clone <repository-url> 
cd user-service 
 
# Build (skip tests for speed on first run) 
mvn clean install -DskipTests 
 
# Start the service 
mvn spring-boot:run 
				
			

Flyway will automatically run any pending migrations on startup before the application accepts traffic. A successful startup log will confirm the port and any migrations applied.

API Reference

User Management

  • POST /api/v1/users/create — Pre-provision a new user. Requires an Admin role.
  • GET /api/v1/users — List all users. Requires an Admin role.
  • GET /api/v1/users/{id} — Retrieve a single user with an ID.
  • PUT /api/v1/users/{id} — Update the user’s details.
  • DELETE /api/v1/users/{id} — Permanently delete a user.
  • POST /api/v1/users/{userId}/assign-role/{roleName} — Assign a named role to a user.

Password Reset

  • GET /api/v1/forget-password/{email} — Step 1: Generate and publish OTP.
  • POST /api/v1/forget-password/verifying-otp — Step 2: Validate the submitted OTP.
  • POST /api/v1/forget-password/update-password — Step 3: Set the new password after verification.

Conclusion

User management is far more than CRUD operations against a database.

It represents the foundation upon which authentication, authorization, onboarding, account recovery, and user experience are built.

By separating user lifecycle management from authentication infrastructure, leveraging stateless JWT validation, adopting event-driven password recovery, and enforcing role-based access control, the User Service provides a scalable and maintainable identity foundation for the broader platform.

The result is a service that remains focused on user ownership while integrating seamlessly with the platform’s authentication, notification, and security infrastructure.

As systems grow, this separation of responsibilities becomes increasingly valuable, allowing each capability to evolve independently without introducing unnecessary coupling.

Source Code: https://github.com/inexture-solutions/springboot-microservices/tree/main/user-service

FAQs

Why not combine User Service and Auth Service?
Although possible, separating them creates clearer ownership boundaries and allows authentication infrastructure to evolve independently.

Why use Kafka for password recovery?
Kafka decouples notification of delivery from user-facing requests, improving reliability, and reducing latency.

Why use RBAC instead of permission to check in code?
RBAC centralizes authorization decisions and simplifies long-term maintenance.

How do services adopt new Common Module features?

Through versioned releases and dependency upgrades.

This allows teams to adopt changes incrementally rather than requiring platform-wide updates.

Is Flyway really necessary for small applications?
Perhaps not initially. However, as systems mature, controlled schema evolution becomes increasingly important.

Should user data and authentication data live in the same database?
Not necessarily. Many organizations separate them to improve security and ownership boundaries.

Bharat Chaudhari is a Java Backend Engineer with 5+ years of experience building scalable microservices using Spring Boot. He specializes in distributed systems, event-driven architecture, Spring Security, Kafka, PostgreSQL, Redis, Docker, and Kubernetes, with a passion for building production-ready backend platforms.

Delivering Engineering Excellence Across Global Markets

  • India
  • USA
  • UAE
  • Europe
  • Singapore
  • Australia

Need Help Building Enterprise Spring Boot Microservices?

We specialize in designing secure, scalable, and event-driven microservices using Spring Boot, Kafka, PostgreSQL, Redis, OAuth2/JWT, Docker, Kubernetes, and cloud-native best practices for enterprise applications.