Documentation Index Fetch the complete documentation index at: https://mintlify.com/ferneybaron/user-management-api/llms.txt
Use this file to discover all available pages before exploring further.
The User Management API is designed with extensibility in mind, following hexagonal architecture principles. This guide shows you how to add new features while maintaining clean separation of concerns.
Architecture Overview
The application follows a three-layer hexagonal architecture:
core : Business logic, domain models, use cases, and repository interfaces (framework-agnostic)
web : REST endpoints, DTOs, and mappers (driving adapter)
data : JPA entities and repository implementations (driven adapter)
config : Spring configuration and exception handling
Adding a New Endpoint
To add a new endpoint, you’ll work through all three layers. Let’s walk through adding a “search users by role” feature.
Step 1: Define the Use Case Interface
Create a new use case interface in core/usecase/:
src/main/java/com/fbaron/user/core/usecase/FindUsersByRoleUseCase.java
package com.fbaron.user.core.usecase;
import com.fbaron.user.core.model.User;
import com.fbaron.user.core.model.UserRole;
import java.util.List;
public interface FindUsersByRoleUseCase {
List < User > findByRole ( UserRole role );
}
Step 2: Extend the Repository Interface
Add the query method to core/repository/UserQueryRepository.java:
src/main/java/com/fbaron/user/core/repository/UserQueryRepository.java
public interface UserQueryRepository {
// Existing methods...
List < User > findAll ();
Optional < User > findById ( UUID id );
// New method
List < User > findByRole ( UserRole role );
}
Step 3: Implement Business Logic
Update core/service/UserService.java to implement the new use case:
src/main/java/com/fbaron/user/core/service/UserService.java
public class UserService implements RegisterUserUseCase , GetUserUseCase ,
EditUserUseCase , RemoveUserUseCase ,
FindUsersByRoleUseCase { // Add new interface
private final UserQueryRepository userQueryRepository ;
private final UserCommandRepository userCommandRepository ;
@ Override
public List < User > findByRole ( UserRole role ) {
log . info ( "Fetching users by role={}" , role);
return userQueryRepository . findByRole (role);
}
}
Step 4: Implement Data Adapter
Update data/jpa/repository/UserJpaRepository.java:
src/main/java/com/fbaron/user/data/jpa/repository/UserJpaRepository.java
@ Repository
public interface UserJpaRepository extends JpaRepository < UserJpaEntity , UUID > {
// Existing methods...
List < UserJpaEntity > findByRoleAndActiveTrue ( UserRole role );
}
Update data/jpa/UserJpaAdapter.java:
src/main/java/com/fbaron/user/data/jpa/UserJpaAdapter.java
@ Override
public List < User > findByRole ( UserRole role) {
return userJpaRepository . findByRoleAndActiveTrue (role)
. stream ()
. map (userJpaMapper :: toModel)
. toList ();
}
Step 5: Add REST Endpoint
Update web/rest/UserRestAdapter.java:
src/main/java/com/fbaron/user/web/rest/UserRestAdapter.java
@ RestController
@ RequestMapping ( "/api/v1/users" )
public class UserRestAdapter implements UserRestApi {
private final FindUsersByRoleUseCase findUsersByRoleUseCase ;
// Other dependencies...
@ GetMapping ( "/by-role/{role}" )
public ResponseEntity < List < UserDto >> getUsersByRole (
@ PathVariable ( "role" ) UserRole role ) {
log . info ( "GET /api/v1/users/by-role/{}" , role);
List < UserDto > users = findUsersByRoleUseCase . findByRole (role)
. stream ()
. map (userDtoMapper :: toDto)
. toList ();
return ResponseEntity . ok (users);
}
}
Step 6: Wire Dependencies
No changes needed in config/UserBeanConfiguration.java - Spring will automatically inject the new use case since UserService implements it.
Adding a New Entity
To add a new entity (e.g., Organization), follow this pattern:
1. Create Domain Model
src/main/java/com/fbaron/user/core/model/Organization.java
package com.fbaron.user.core.model;
import lombok. * ;
import java.time.LocalDateTime;
import java.util.UUID;
@ Getter
@ Setter
@ Builder
@ NoArgsConstructor
@ AllArgsConstructor
public class Organization {
private UUID id ;
private String name ;
private String description ;
private boolean active ;
private LocalDateTime createdAt ;
private LocalDateTime updatedAt ;
public void activate () {
this . active = true ;
this . updatedAt = LocalDateTime . now ();
}
}
2. Create Repository Interfaces
src/main/java/com/fbaron/user/core/repository/OrganizationQueryRepository.java
package com.fbaron.user.core.repository;
import com.fbaron.user.core.model.Organization;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface OrganizationQueryRepository {
List < Organization > findAll ();
Optional < Organization > findById ( UUID id );
boolean existsByName ( String name );
}
src/main/java/com/fbaron/user/core/repository/OrganizationCommandRepository.java
package com.fbaron.user.core.repository;
import com.fbaron.user.core.model.Organization;
public interface OrganizationCommandRepository {
Organization save ( Organization organization );
}
3. Create JPA Entity
src/main/java/com/fbaron/user/data/jpa/entity/OrganizationJpaEntity.java
package com.fbaron.user.data.jpa.entity;
import jakarta.persistence. * ;
import lombok. * ;
import java.time.LocalDateTime;
import java.util.UUID;
@ Entity
@ Table ( name = "organizations" )
@ Getter
@ Setter
@ Builder
@ NoArgsConstructor
@ AllArgsConstructor
public class OrganizationJpaEntity {
@ Id
@ GeneratedValue ( strategy = GenerationType . UUID )
private UUID id ;
@ Column ( nullable = false , unique = true )
private String name ;
private String description ;
private boolean active ;
@ Column ( name = "created_at" , nullable = false , updatable = false )
private LocalDateTime createdAt ;
@ Column ( name = "updated_at" )
private LocalDateTime updatedAt ;
@ PrePersist
protected void onCreate () {
createdAt = LocalDateTime . now ();
updatedAt = LocalDateTime . now ();
}
}
4. Create Database Migration
Add a new Flyway migration in src/main/resources/db/migration/:
src/main/resources/db/migration/V2__create_organizations_table.sql
CREATE TABLE organizations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR ( 255 ) NOT NULL UNIQUE ,
description TEXT ,
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP
);
CREATE INDEX idx_organizations_name ON organizations( name );
CREATE INDEX idx_organizations_active ON organizations(active);
5. Create DTOs and Mappers
Follow the same pattern as UserDto, RegisterUserDto, and UserDtoMapper.
Integrating External Services
To integrate with an external API (e.g., email service):
1. Define Port Interface
src/main/java/com/fbaron/user/core/port/EmailService.java
package com.fbaron.user.core.port;
import com.fbaron.user.core.model.User;
public interface EmailService {
void sendWelcomeEmail ( User user );
void sendPasswordResetEmail ( User user , String token );
}
2. Create Adapter Implementation
src/main/java/com/fbaron/user/infrastructure/email/SmtpEmailAdapter.java
package com.fbaron.user.infrastructure.email;
import com.fbaron.user.core.model.User;
import com.fbaron.user.core.port.EmailService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.mail.javamail.JavaMailSender;
@ Slf4j
@ RequiredArgsConstructor
public class SmtpEmailAdapter implements EmailService {
private final JavaMailSender mailSender ;
@ Override
public void sendWelcomeEmail ( User user ) {
log . info ( "Sending welcome email to {}" , user . getEmail ());
// Implementation...
}
}
3. Register in Configuration
src/main/java/com/fbaron/user/config/UserBeanConfiguration.java
@ Bean
public EmailService emailService ( JavaMailSender mailSender) {
return new SmtpEmailAdapter (mailSender);
}
4. Use in Service
src/main/java/com/fbaron/user/core/service/UserService.java
public class UserService implements RegisterUserUseCase {
private final EmailService emailService ;
@ Override
public User register ( User user ) {
// Validation and save logic...
User saved = userCommandRepository . save (user);
// Send welcome email
emailService . sendWelcomeEmail (saved);
return saved;
}
}
Adding Custom Validation
Create custom validation annotations for business rules:
src/main/java/com/fbaron/user/web/validation/ValidUsername.java
package com.fbaron.user.web.validation;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation. * ;
@ Target ({ ElementType . FIELD , ElementType . PARAMETER })
@ Retention ( RetentionPolicy . RUNTIME )
@ Constraint ( validatedBy = UsernameValidator . class )
public @ interface ValidUsername {
String message () default "Username must contain only letters, numbers, and underscores" ;
Class < ? > [] groups () default {};
Class < ? extends Payload > [] payload () default {};
}
src/main/java/com/fbaron/user/web/validation/UsernameValidator.java
package com.fbaron.user.web.validation;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
public class UsernameValidator implements ConstraintValidator < ValidUsername , String > {
@ Override
public boolean isValid ( String username , ConstraintValidatorContext context ) {
if (username == null ) return false ;
return username . matches ( "^[a-zA-Z0-9_]+$" );
}
}
Use in DTOs:
public record RegisterUserDto (
@ ValidUsername
@ NotBlank
String username,
// Other fields...
) {}
Adding Custom Exceptions
Create domain-specific exceptions:
src/main/java/com/fbaron/user/core/exception/InvalidUserStateException.java
package com.fbaron.user.core.exception;
import lombok.Getter;
import java.util.UUID;
@ Getter
public class InvalidUserStateException extends RuntimeException {
private final UUID userId ;
public InvalidUserStateException ( UUID userId , String message ) {
super (message);
this . userId = userId;
}
}
Handle in GlobalExceptionHandler:
src/main/java/com/fbaron/user/config/exception/GlobalExceptionHandler.java
@ ExceptionHandler ( InvalidUserStateException . class )
public ProblemDetail handleInvalidUserState ( InvalidUserStateException ex) {
ProblemDetail problem = ProblemDetail . forStatusAndDetail (
HttpStatus . BAD_REQUEST , ex . getMessage ());
problem . setTitle ( "Invalid User State" );
problem . setProperty ( "userId" , ex . getUserId ());
problem . setProperty ( "timestamp" , LocalDateTime . now ());
return problem;
}
Adding Security with Spring Security
To add authentication and authorization:
1. Add Dependencies
Update build.gradle:
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3'
2. Create Security Configuration
src/main/java/com/fbaron/user/config/SecurityConfig.java
package com.fbaron.user.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@ Configuration
public class SecurityConfig {
@ Bean
public SecurityFilterChain filterChain ( HttpSecurity http ) throws Exception {
http
. authorizeHttpRequests (auth -> auth
. requestMatchers ( "/api/v1/users/**" ). hasRole ( "ADMIN" )
. requestMatchers ( "/actuator/health" ). permitAll ()
. anyRequest (). authenticated ()
)
. csrf (csrf -> csrf . disable ());
return http . build ();
}
}
Best Practices
Keep Core Framework-Agnostic
The core package should never import Spring, JPA, or web framework classes. This ensures your business logic is testable and portable.
Always define interfaces for repositories and external services. This allows easy mocking in tests and swapping implementations.
Separate read operations (UserQueryRepository) from write operations (UserCommandRepository) for better scalability and clarity.
Always create new Flyway migrations with sequential version numbers (V1, V2, V3). Never modify existing migrations.
Use MapStruct for Mapping
Leverage MapStruct for DTO/Entity mappings - it’s compile-time safe and generates efficient code without reflection.
Next Steps
Testing Learn how to write unit and integration tests
API Reference Explore the complete API documentation
Configuration Configure the application for different environments
Troubleshooting Common issues and solutions