Skip to main content
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

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.
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

Build docs developers (and LLMs) love