Spring Boot Integration: Functional Patterns for Enterprise Applications
Bringing Type-Safe Error Handling to Your REST APIs
- How to use Either, Validated, and CompletableFuturePath as Spring controller return types
- Zero-configuration setup with the hkj-spring-boot-starter
- Automatic JSON serialisation with customisable formats
- Monitoring functional operations with Spring Boot Actuator
- Securing endpoints with functional error handling patterns
- Building production-ready applications with explicit, typed errors
- Testing functional controllers with MockMvc
A complete working example is available in the hkj-spring example module. Run it with:
./gradlew :hkj-spring:example:bootRun
Overview
Building REST APIs with Spring Boot is straightforward, but error handling often becomes a source of complexity and inconsistency. Traditional exception-based approaches scatter error handling logic across @ExceptionHandler methods, lose type safety, and make it difficult to reason about what errors a given endpoint can produce.
The hkj-spring-boot-starter solves these problems by bringing functional programming patterns seamlessly into Spring applications. Return Either<Error, Data>, Validated<Errors, Data>, or CompletableFuturePath from your controllers, and the framework automatically handles the rest: converting functional types to appropriate HTTP responses whilst preserving type safety and composability.
The key insight: Errors become explicit in your method signatures, not hidden in implementation details or exception hierarchies.
Quick Start
Step 1: Add the Dependency
Add the starter to your Spring Boot project:
// build.gradle.kts
dependencies {
implementation("io.github.higher-kinded-j:hkj-spring-boot-starter:LATEST_VERSION")
}
Or with Maven:
<dependency>
<groupId>io.github.higher-kinded-j</groupId>
<artifactId>hkj-spring-boot-starter</artifactId>
<version>LATEST_VERSION</version>
</dependency>
Step 2: Return Functional Types from Controllers
@RestController
@RequestMapping("/api/users")
public class UserController {
@Autowired
private UserService userService;
@GetMapping("/{id}")
public Either<DomainError, User> getUser(@PathVariable String id) {
return userService.findById(id);
// Right(user) → HTTP 200 with JSON body
// Left(UserNotFoundError) → HTTP 404 with error details
}
@PostMapping
public Validated<List<ValidationError>, User> createUser(@RequestBody UserRequest request) {
return userService.validateAndCreate(request);
// Valid(user) → HTTP 200 with user JSON
// Invalid(errors) → HTTP 400 with all validation errors accumulated
}
}
Step 3: Run Your Application
That's it! The starter auto-configures everything:
- Either to HTTP response conversion with automatic status code mapping
- Validated to HTTP response with error accumulation
- CompletableFuturePath support for async operations
- JSON serialisation for functional types
- Customisable error type to HTTP status code mapping
Why Use Functional Error Handling?
The Problem with Exceptions
Traditional Spring Boot error handling relies on exceptions and @ExceptionHandler methods:
@GetMapping("/{id}")
public User getUser(@PathVariable String id) {
return userService.findById(id); // What errors can this throw?
}
@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(UserNotFoundException ex) {
return ResponseEntity.status(404).body(new ErrorResponse(ex.getMessage()));
}
@ExceptionHandler(ValidationException.class)
public ResponseEntity<ErrorResponse> handleValidation(ValidationException ex) {
return ResponseEntity.status(400).body(new ErrorResponse(ex.getMessage()));
}
Problems:
- Errors are invisible in the method signature
- No compile-time guarantee that all errors are handled
- Exception handlers become a catch-all for unrelated errors
- Difficult to compose operations whilst maintaining error information
- Testing requires catching exceptions or using
@ExceptionHandlerintegration
The Functional Solution
With functional error handling, errors become explicit and composable:
@GetMapping("/{id}")
public Either<DomainError, User> getUser(@PathVariable String id) {
return userService.findById(id); // Clear: returns User or DomainError
}
@GetMapping("/{id}/orders")
public Either<DomainError, List<Order>> getUserOrders(@PathVariable String id) {
return userService.findById(id)
.flatMap(orderService::getOrdersForUser); // Compose operations naturally
}
Benefits:
- Errors are explicit in the type signature
- Compiler ensures error handling at call sites
- Functional composition with
map,flatMap,fold - Automatic HTTP response conversion
- Easy to test, no exception catching required
Core Features
1. Either: Success or Typed Error
Either<L, R> represents a computation that can succeed with a Right(value) or fail with a Left(error).
Basic Usage
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping("/{id}")
public Either<DomainError, User> getUser(@PathVariable String id) {
return userService.findById(id);
}
}
Response Mapping:
Right(user)→ HTTP 200 with JSON:{"id": "1", "email": "alice@example.com", ...}Left(UserNotFoundError)→ HTTP 404 with JSON:{"success": false, "error": {"type": "UserNotFoundError", ...}}
Error Type → HTTP Status Mapping
The framework automatically maps error types to HTTP status codes by examining class names:
public sealed interface DomainError permits
UserNotFoundError, // Contains "NotFound" → 404
ValidationError, // Contains "Validation" → 400
AuthorizationError, // Contains "Authorization" → 403
AuthenticationError {} // Contains "Authentication" → 401
// Custom errors default to 400 Bad Request
Composing Operations
@GetMapping("/{userId}/orders/{orderId}")
public Either<DomainError, Order> getUserOrder(
@PathVariable String userId,
@PathVariable String orderId) {
return userService.findById(userId)
.flatMap(user -> orderService.findById(orderId))
.flatMap(order -> orderService.verifyOwnership(order, userId));
// Short-circuits on first Left
}
See the Either Monad documentation for comprehensive usage patterns.
2. Validated: Accumulating Multiple Errors
Validated<E, A> is designed for validation scenarios where you want to accumulate all errors, not just the first one.
Basic Usage
@PostMapping
public Validated<List<ValidationError>, User> createUser(@RequestBody UserRequest request) {
return userService.validateAndCreate(request);
}
Response Mapping:
Valid(user)→ HTTP 200 with JSON:{"id": "1", "email": "alice@example.com", ...}Invalid(errors)→ HTTP 400 with JSON:{"success": false, "errors": [{"field": "email", "message": "Invalid format"}, ...]}
Validation Example
@Service
public class UserService {
public Validated<List<ValidationError>, User> validateAndCreate(UserRequest request) {
return Validated.validateAll(
validateEmail(request.email()),
validateName(request.firstName()),
validateName(request.lastName())
).map(tuple -> new User(
UUID.randomUUID().toString(),
tuple._1(), // email
tuple._2(), // firstName
tuple._3() // lastName
));
}
private Validated<ValidationError, String> validateEmail(String email) {
if (email == null || !email.contains("@")) {
return Validated.invalid(new ValidationError("email", "Invalid email format"));
}
return Validated.valid(email);
}
private Validated<ValidationError, String> validateName(String name) {
if (name == null || name.trim().isEmpty()) {
return Validated.invalid(new ValidationError("name", "Name cannot be empty"));
}
return Validated.valid(name);
}
}
Key Difference from Either:
Eithershort-circuits on first error (fail-fast)Validatedaccumulates all errors (fail-slow)
See the Validated Monad documentation for detailed usage.
3. CompletableFuturePath: Async Operations with Typed Errors
CompletableFuturePath<A> wraps asynchronous computation in the Effect Path API, allowing you to compose async operations with map, via, and recover.
Basic Usage
@GetMapping("/{id}/async")
public CompletableFuturePath<User> getUserAsync(@PathVariable String id) {
return asyncUserService.findByIdAsync(id);
// Automatically handles async to sync HTTP response conversion
}
Async Composition
@Service
public class AsyncOrderService {
public CompletableFuturePath<OrderSummary> processOrderAsync(
String userId, OrderRequest request) {
return asyncUserService.findByIdAsync(userId)
.via(user -> asyncInventoryService.checkAvailability(request.items()))
.via(availability -> asyncPaymentService.processPayment(request.payment()))
.map(payment -> new OrderSummary(userId, request, payment));
// Each step runs asynchronously
// Composes naturally with other Path types
}
}
Response Handling: The framework uses Spring's async request processing:
- Controller returns
CompletableFuturePath - Framework extracts the underlying
CompletableFuture - Spring's async mechanism handles the future
- When complete, result is converted to HTTP response
See the Effect Path API documentation for comprehensive examples.
JSON Serialisation
The starter provides flexible JSON serialisation for functional types.
Configuration
Configure serialisation format in application.yml:
hkj:
jackson:
custom-serializers-enabled: true # Enable custom serialisers (default: true)
either-format: TAGGED # TAGGED, UNWRAPPED, or DIRECT
validated-format: TAGGED # TAGGED, UNWRAPPED, or DIRECT
maybe-format: TAGGED # TAGGED, UNWRAPPED, or DIRECT
Serialisation Formats
TAGGED (Default)
Wraps the value with metadata indicating success/failure:
// Right(user)
{
"success": true,
"value": {
"id": "1",
"email": "alice@example.com"
}
}
// Left(error)
{
"success": false,
"error": {
"type": "UserNotFoundError",
"userId": "999"
}
}
UNWRAPPED
Returns just the value or error without wrapper:
// Right(user)
{
"id": "1",
"email": "alice@example.com"
}
// Left(error)
{
"type": "UserNotFoundError",
"userId": "999"
}
DIRECT
Uses Either's default toString() representation (useful for debugging):
"Right(value=User[id=1, email=alice@example.com])"
For complete serialisation details, see hkj-spring/JACKSON_SERIALIZATION.md.
Configuration
Web Configuration
hkj:
web:
either-path-enabled: true # Enable EitherPath handler (default: true)
validated-path-enabled: true # Enable ValidationPath handler (default: true)
completable-future-path-enabled: true # Enable CompletableFuturePath handler (default: true)
default-error-status: 400 # Default HTTP status for unmapped errors
Async Executor Configuration
For CompletableFuturePath operations, configure the async thread pool:
hkj:
async:
core-pool-size: 10 # Minimum threads
max-pool-size: 20 # Maximum threads
queue-capacity: 100 # Queue size before rejection
thread-name-prefix: "hkj-async-" # Thread naming pattern
For complete configuration options, see hkj-spring/CONFIGURATION.md.
Real-World Examples
Example 1: User Management API
A typical CRUD API with validation and error handling:
@RestController
@RequestMapping("/api/users")
public class UserController {
@Autowired
private UserService userService;
// Get all users (always succeeds)
@GetMapping
public List<User> getAllUsers() {
return userService.findAll();
}
// Get single user (may not exist)
@GetMapping("/{id}")
public Either<DomainError, User> getUser(@PathVariable String id) {
return userService.findById(id);
// Right(user) → 200 OK
// Left(UserNotFoundError) → 404 Not Found
}
// Create user (validate all fields)
@PostMapping
public Validated<List<ValidationError>, User> createUser(@RequestBody UserRequest request) {
return userService.validateAndCreate(request);
// Valid(user) → 200 OK
// Invalid([errors...]) → 400 Bad Request with all validation errors
}
// Update user (may not exist + validation)
@PutMapping("/{id}")
public Either<DomainError, User> updateUser(
@PathVariable String id,
@RequestBody UserRequest request) {
return userService.findById(id)
.flatMap(existingUser ->
userService.validateUpdate(request)
.toEither() // Convert Validated to Either
.map(validRequest -> userService.update(id, validRequest)));
// Combines existence check + validation
}
// Delete user (may not exist)
@DeleteMapping("/{id}")
public Either<DomainError, Void> deleteUser(@PathVariable String id) {
return userService.delete(id);
// Right(null) → 200 OK
// Left(UserNotFoundError) → 404 Not Found
}
// Get user's email (composition example)
@GetMapping("/{id}/email")
public Either<DomainError, String> getUserEmail(@PathVariable String id) {
return userService.findById(id)
.map(User::email);
// Automatic error propagation
}
}
Example 2: Async Order Processing
Processing orders asynchronously with multiple external services:
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@Autowired
private AsyncOrderService orderService;
@PostMapping
public CompletableFuturePath<Order> createOrder(@RequestBody OrderRequest request) {
return orderService.processOrder(request);
// Each step runs asynchronously:
// 1. Validate user
// 2. Check inventory
// 3. Process payment
// 4. Create order record
// Composes naturally with other Path types
// Returns 200 on success, appropriate error code on failure
}
@GetMapping("/{id}")
public CompletableFuturePath<Order> getOrder(@PathVariable String id) {
return orderService.findByIdAsync(id);
}
}
@Service
public class AsyncOrderService {
@Autowired
private AsyncUserService userService;
@Autowired
private AsyncInventoryService inventoryService;
@Autowired
private AsyncPaymentService paymentService;
@Autowired
private OrderRepository orderRepository;
public CompletableFuturePath<Order> processOrder(OrderRequest request) {
return userService.findByIdAsync(request.userId())
.via(user -> inventoryService.checkAvailabilityAsync(request.items()))
.via(availability -> {
if (!availability.allAvailable()) {
return Path.completableFuture(
CompletableFuture.failedFuture(
new OutOfStockException(availability.unavailableItems())
)
);
}
return Path.completableFuture(
CompletableFuture.completedFuture(availability)
);
})
.via(availability -> paymentService.processPaymentAsync(request.payment()))
.map(payment -> createOrderRecord(request, payment));
}
}
For complete working examples, see the hkj-spring example module.
Spring Security Integration
The hkj-spring-boot-starter provides optional Spring Security integration with functional error handling patterns.
Enabling Security Integration
hkj:
security:
enabled: true # Enable functional security (default: false)
validated-user-details: true # Use Validated for user loading
either-authentication: true # Use Either for authentication
either-authorization: true # Use Either for authorisation
Functional User Details Service
Use Validated to accumulate authentication errors:
@Service
public class CustomUserDetailsService implements ValidatedUserDetailsService {
@Override
public Validated<List<SecurityError>, UserDetails> loadUserByUsername(String username) {
return Validated.validateAll(
validateUsername(username),
validateAccountStatus(username),
validateCredentials(username)
).map(tuple -> new User(
tuple._1(), // username
tuple._2(), // password
tuple._3() // authorities
));
// Returns ALL validation errors at once
// e.g., "Username too short" + "Account locked"
}
}
Functional Authentication
Use Either for JWT authentication with typed errors:
@Component
public class JwtAuthenticationConverter {
public Either<AuthenticationError, Authentication> convert(Jwt jwt) {
return extractUsername(jwt)
.flatMap(this::validateToken)
.flatMap(this::extractAuthorities)
.map(authorities -> new JwtAuthenticationToken(jwt, authorities));
// Short-circuits on first error
}
}
For complete security integration details, see hkj-spring/SECURITY.md.
Monitoring with Spring Boot Actuator
Track functional programming patterns in production with built-in Actuator integration.
Enabling Actuator Integration
hkj:
actuator:
metrics:
enabled: true # Enable metrics tracking
health:
async-executor:
enabled: true # Monitor CompletableFuturePath thread pool
management:
endpoints:
web:
exposure:
include: health,info,metrics,hkj
Available Metrics
The starter automatically tracks:
- EitherPath metrics: Success/error counts and rates
- ValidationPath metrics: Valid/invalid counts and error distributions
- CompletableFuturePath metrics: Async operation durations and success rates
- Thread pool health: Active threads, queue size, saturation
Custom HKJ Endpoint
Access functional programming metrics via the custom actuator endpoint:
curl http://localhost:8080/actuator/hkj
Response:
{
"configuration": {
"web": {
"eitherPathEnabled": true,
"validatedPathEnabled": true,
"completableFuturePathEnabled": true
},
"jackson": {
"eitherFormat": "TAGGED",
"validatedFormat": "TAGGED"
}
},
"metrics": {
"eitherPath": {
"successCount": 1547,
"errorCount": 123,
"totalCount": 1670,
"successRate": 0.926
},
"validationPath": {
"validCount": 892,
"invalidCount": 45,
"totalCount": 937,
"validRate": 0.952
},
"completableFuturePath": {
"successCount": 234,
"errorCount": 12,
"totalCount": 246,
"successRate": 0.951
}
}
}
Prometheus Integration
Export metrics to Prometheus for monitoring and alerting:
management:
metrics:
export:
prometheus:
enabled: true
Example Prometheus queries:
# EitherPath error rate
rate(hkj_either_path_invocations_total{result="error"}[5m])
# CompletableFuturePath p95 latency
histogram_quantile(0.95,
rate(hkj_completable_future_path_async_duration_seconds_bucket[5m]))
# ValidationPath success rate
sum(rate(hkj_validation_path_invocations_total{result="valid"}[5m]))
/ sum(rate(hkj_validation_path_invocations_total[5m]))
For complete Actuator integration details, see hkj-spring/ACTUATOR.md.
Testing
Testing functional controllers is straightforward with MockMvc.
Testing Either Responses
@SpringBootTest
@AutoConfigureMockMvc
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
void shouldReturn200ForExistingUser() throws Exception {
mockMvc.perform(get("/api/users/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.value.id").value("1"))
.andExpect(jsonPath("$.value.email").value("alice@example.com"));
}
@Test
void shouldReturn404ForMissingUser() throws Exception {
mockMvc.perform(get("/api/users/999"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.error.type").value("UserNotFoundError"));
}
}
Testing Validated Responses
@Test
void shouldAccumulateValidationErrors() throws Exception {
String invalidRequest = """
{
"email": "invalid",
"firstName": "",
"lastName": "x"
}
""";
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(invalidRequest))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.errors").isArray())
.andExpect(jsonPath("$.errors.length()").value(3)); // All errors returned
}
Testing CompletableFuturePath Async Responses
@Test
void shouldHandleAsyncCompletableFuturePathResponse() throws Exception {
MvcResult result = mockMvc.perform(get("/api/users/1/async"))
.andExpect(request().asyncStarted()) // Verify async started
.andReturn();
mockMvc.perform(asyncDispatch(result)) // Dispatch async result
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value("1"));
}
Unit Testing Services
Services returning functional types are easy to test without mocking frameworks:
class UserServiceTest {
private UserService service;
@Test
void shouldReturnRightWhenUserExists() {
Either<DomainError, User> result = service.findById("1");
assertThat(result.isRight()).isTrue();
User user = result.getRight();
assertThat(user.id()).isEqualTo("1");
}
@Test
void shouldReturnLeftWhenUserNotFound() {
Either<DomainError, User> result = service.findById("999");
assertThat(result.isLeft()).isTrue();
DomainError error = result.getLeft();
assertThat(error).isInstanceOf(UserNotFoundError.class);
}
@Test
void shouldAccumulateValidationErrors() {
UserRequest invalid = new UserRequest("bad-email", "", "x");
Validated<List<ValidationError>, User> result =
service.validateAndCreate(invalid);
assertThat(result.isInvalid()).isTrue();
List<ValidationError> errors = result.getErrors();
assertThat(errors).hasSize(3);
}
}
For comprehensive testing examples, see hkj-spring/example/TESTING.md.
Migration Guide
Migrating from exception-based error handling to functional patterns is straightforward and can be done incrementally.
See the Migration Guide for a complete step-by-step walkthrough of:
- Converting exception-throwing methods to Either
- Replacing
@ExceptionHandlermethods with functional patterns - Migrating validation logic to Validated
- Converting async operations to CompletableFuturePath
- Maintaining backwards compatibility during migration
Architecture and Design
Auto-Configuration
The starter uses Spring Boot 4.x auto-configuration:
@AutoConfiguration
@ConditionalOnClass({DispatcherServlet.class, Kind.class})
@ConditionalOnWebApplication(type = SERVLET)
public class HkjWebMvcAutoConfiguration implements WebMvcConfigurer {
@Override
public void addReturnValueHandlers(List<HandlerMethodReturnValueHandler> handlers) {
handlers.add(new EitherPathReturnValueHandler(properties));
handlers.add(new ValidationPathReturnValueHandler(properties));
handlers.add(new CompletableFuturePathReturnValueHandler(properties));
}
}
Auto-configuration activates when:
higher-kinded-j-coreis on the classpath- Spring Web MVC is present
- Application is a servlet web app
Return Value Handlers
Each functional type has a dedicated handler:
- EitherPathReturnValueHandler: Converts
EitherPath<L, R>to HTTP responses - ValidationPathReturnValueHandler: Converts
ValidationPath<E, A>to HTTP responses - CompletableFuturePathReturnValueHandler: Unwraps
CompletableFuturePath<A>for async processing
Handlers are registered automatically and integrated seamlessly with Spring's request processing lifecycle.
Non-Invasive Design
The integration doesn't modify existing Spring Boot behaviour:
- Standard Spring MVC types work unchanged
- Exception handling still functions normally
- Can be disabled via configuration
- Coexists with traditional
ResponseEntityendpoints
Frequently Asked Questions
Can I mix functional and traditional exception handling?
Yes! The integration is non-invasive. You can use EitherPath, ValidationPath, and CompletableFuturePath alongside traditional ResponseEntity and exception-based endpoints in the same application.
Does this work with Spring WebFlux?
Currently, the starter supports Spring Web MVC (servlet-based). WebFlux support is planned for a future release.
Can I customise the error → HTTP status mapping?
Yes. Implement a custom return value handler or use the configuration properties to set default status codes. See CONFIGURATION.md for details.
How does performance compare to exceptions?
Functional error handling is generally faster than exception-throwing for expected error cases, as it avoids stack trace generation and exception propagation overhead. For success cases, performance is equivalent.
Can I use this with Spring Data repositories?
Yes. Wrap repository calls in your service layer:
@Service
public class UserService {
@Autowired
private UserRepository repository;
public Either<DomainError, User> findById(String id) {
return repository.findById(id)
.map(Either::<DomainError, User>right)
.orElseGet(() -> Either.left(new UserNotFoundError(id)));
}
}
Does this work with validation annotations (@Valid)?
The integration focuses on functional validation patterns. For Spring's @Valid integration, you can convert BindingResult to Validated in your controllers.
Related Documentation
- Either Monad - Comprehensive Either usage
- Validated Monad - Validation patterns
- Effect Path API - Path types and async composition
- Migration Guide - Step-by-step migration
- Configuration Guide - Complete configuration options
- Jackson Serialisation - JSON format details
- Security Integration - Spring Security patterns
- Actuator Monitoring - Metrics and health checks
- Testing Guide - Testing patterns
Summary
The hkj-spring-boot-starter brings functional programming patterns seamlessly into Spring Boot applications:
- Return functional types from controllers: EitherPath, ValidationPath, CompletableFuturePath
- Automatic HTTP response conversion: No boilerplate required
- Explicit, type-safe error handling: Errors in method signatures
- Composable operations: Functional composition with map/via/flatMap
- Zero configuration: Auto-configuration handles everything
- Production-ready: Actuator metrics, security integration
- Easy to test: No exception mocking required
Get started today by adding the dependency and returning functional types from your controllers. The framework handles the rest!
Previous: Introduction Next: Migrating to Functional Errors