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 Result type 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 switch rather than imperative if chains.

This pattern scales well: add a new Result variant, the compiler forces all switch sites to be updated.