Runnable Layered Example

All Three Layers in Plain Java

This program has the same shape as a Spring Boot app, without Spring: a repository interface with an in-memory implementation, a service with business rules, and a controller that turns results and exceptions into status codes. In Spring, the new calls in main are done for you by constructor injection.

Example

import java.math.BigDecimal;
import java.util.*;

public class Main {
    public static void main(String[] args) {
        ProductRepository repo = new InMemoryProductRepository();     // @Repository
        ProductService service = new ProductService(repo);            // @Service
        ProductController api = new ProductController(service);       // @RestController

        System.out.println(api.create(new Product(null, "Wireless Mouse", new BigDecimal("799.00"), 120)));
        System.out.println(api.create(new Product(null, "Clean Code", new BigDecimal("450.00"), 3)));
        System.out.println(api.create(new Product(null, "Clean Code", new BigDecimal("450.00"), 3)));
        System.out.println(api.reserve(2L, 2));
        System.out.println(api.reserve(2L, 5));
        System.out.println(api.get(9L));
        System.out.println(api.list());
    }
}

record Product(Long id, String name, BigDecimal price, int stock) {}
record Response(int status, Object body) {}

interface ProductRepository {
    Product save(Product p);
    Optional<Product> findById(Long id);
    Optional<Product> findByName(String name);
    List<Product> findAll();
}

class InMemoryProductRepository implements ProductRepository {
    private final Map<Long, Product> rows = new TreeMap<>();
    private long nextId = 1;
    public Product save(Product p) {
        Product saved = p.id() == null ? new Product(nextId++, p.name(), p.price(), p.stock()) : p;
        rows.put(saved.id(), saved);
        return saved;
    }
    public Optional<Product> findById(Long id) { return Optional.ofNullable(rows.get(id)); }
    public Optional<Product> findByName(String n) { return rows.values().stream().filter(p -> p.name().equals(n)).findFirst(); }
    public List<Product> findAll() { return List.copyOf(rows.values()); }
}

class NotFound extends RuntimeException { NotFound(String m) { super(m); } }
class Conflict extends RuntimeException { Conflict(String m) { super(m); } }

class ProductService {
    private final ProductRepository repo;
    ProductService(ProductRepository repo) { this.repo = repo; }

    Product create(Product p) {
        if (repo.findByName(p.name()).isPresent()) throw new Conflict(p.name() + " already exists");
        return repo.save(p);
    }
    Product get(Long id) { return repo.findById(id).orElseThrow(() -> new NotFound("Product " + id + " not found")); }
    Product reserve(Long id, int qty) {
        Product p = get(id);
        if (p.stock() < qty) throw new Conflict("Only " + p.stock() + " left of " + p.name());
        return repo.save(new Product(p.id(), p.name(), p.price(), p.stock() - qty));
    }
    List<Product> list() { return repo.findAll(); }
}

class ProductController {
    private final ProductService service;
    ProductController(ProductService service) { this.service = service; }

    Response create(Product p) { return call(201, () -> service.create(p)); }
    Response get(Long id) { return call(200, () -> service.get(id)); }
    Response reserve(Long id, int qty) { return call(200, () -> service.reserve(id, qty)); }
    Response list() { return call(200, service::list); }

    private Response call(int okStatus, java.util.function.Supplier<Object> action) {
        try { return new Response(okStatus, action.get()); }
        catch (NotFound e) { return new Response(404, e.getMessage()); }       // like @ExceptionHandler
        catch (Conflict e) { return new Response(409, e.getMessage()); }
    }
}

Output

Response[status=201, body=Product[id=1, name=Wireless Mouse, price=799.00, stock=120]]
Response[status=201, body=Product[id=2, name=Clean Code, price=450.00, stock=3]]
Response[status=409, body=Clean Code already exists]
Response[status=200, body=Product[id=2, name=Clean Code, price=450.00, stock=1]]
Response[status=409, body=Only 1 left of Clean Code]
Response[status=404, body=Product 9 not found]
Response[status=200, body=[Product[id=1, name=Wireless Mouse, price=799.00, stock=120], Product[id=2, name=Clean Code, price=450.00, stock=1]]]

The Same Classes in Spring

@Repository
class InMemoryProductRepository implements ProductRepository { ... }   // later replaced by JpaRepository

@Service
class ProductService {
    private final ProductRepository repo;
    ProductService(ProductRepository repo) { this.repo = repo; }      // injected by Spring
    ...
}

@RestController
@RequestMapping("/api/products")
class ProductController {
    private final ProductService service;
    ProductController(ProductService service) { this.service = service; }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    Product create(@RequestBody Product p) { return service.create(p); }
    // NotFound / Conflict are turned into 404 / 409 by @RestControllerAdvice
}

Exercise: Add a restock(Long id, int qty) rule to the service that rejects qty <= 0 with an IllegalArgumentException. Which HTTP status should the handler map it to?

Show answer
Product restock(Long id, int qty) {
    if (qty <= 0) throw new IllegalArgumentException("qty must be positive");
    Product p = get(id);
    return repo.save(new Product(p.id(), p.name(), p.price(), p.stock() + qty));
}
// Map IllegalArgumentException to 400 Bad Request: the client sent a bad value.