4.5 Examples Spring Service Integration
Complete service and controller layer using records for DTOs and sealed types for explicit result handling with Spring.
// ============== RECORDS (DTOs) ==============
record CreateProductRequest(String sku, String name, BigDecimal price) {}
record CreateProductResponse(String id, String sku, String name, BigDecimal price) {}
record FetchProductResponse(String id, String sku, String name, BigDecimal price) {}
// ============== SEALED RESULTS ==============
sealed interface Result permits Success, Failure {}
record Success(Object body) implements Result {}
record Failure(String error, int statusCode) implements Result {}
// ============== SERVICE LAYER ==============
import org.springframework.stereotype.Service;
import java.util.UUID;
import java.util.Map;
import java.util.Objects;
@Service
public class ProductService {
private final Map<String, Product> store = new java.util.concurrent.ConcurrentHashMap<>();
// Internal domain record
record Product(String id, String sku, String name, BigDecimal price) {}
Result createProduct(CreateProductRequest req) {
if (req == null || req.sku() == null || req.name() == null || req.price() == null) {
return new Failure("Invalid request payload", 400);
}
if (req.sku().isBlank() || req.name().isBlank()) {
return new Failure("sku and name must not be blank", 400);
}
if (req.price().signum() <= 0) {
return new Failure("price must be positive", 400);
}
var id = UUID.randomUUID().toString();
var product = new Product(id, req.sku(), req.name(), req.price());
store.put(id, product);
var response = new CreateProductResponse(product.id(), product.sku(), product.name(), product.price());
return new Success(response);
}
Result fetchProduct(String id) {
if (id == null || id.isBlank()) {
return new Failure("id required", 400);
}
var product = store.get(id);
if (product == null) {
return new Failure("product not found", 404);
}
var response = new FetchProductResponse(product.id(), product.sku(), product.name(), product.price());
return new Success(response);
}
}
// ============== CONTROLLER ==============
import org.springframework.web.bind.annotation.*;
import org.springframework.http.ResponseEntity;
import org.springframework.http.HttpStatus;
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductService productService;
public ProductController(ProductService productService) {
this.productService = productService;
}
@PostMapping
ResponseEntity<?> createProduct(@RequestBody CreateProductRequest req) {
var result = productService.createProduct(req);
return switch (result) {
case Success(var body) -> ResponseEntity.status(201).body(body);
case Failure(var error, var status) -> ResponseEntity.status(status).body(Map.of("error", error));
};
}
@GetMapping("/{id}")
ResponseEntity<?> getProduct(@PathVariable String id) {
var result = productService.fetchProduct(id);
return switch (result) {
case Success(var body) -> ResponseEntity.ok(body);
case Failure(var error, var status) -> ResponseEntity.status(status).body(Map.of("error", error));
};
}
}
// ============== ERROR HANDLER (optional) ==============
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(IllegalArgumentException.class)
ResponseEntity<?> handleValidation(IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
Highlights:
- Record DTOs are concise and immutable; validation in the service layer.
- Sealed
Resulttype ensures exhaustive handling in the controller; no unchecked casts or null returns. - Spring binds record request bodies automatically (as of Spring 6.0+).
- Controller logic is declarative via
switchrather than imperativeifchains.
This pattern scales well: add a new Result variant, the compiler forces all switch sites to be updated.