Real World Example

One Service, Many Entry Points

At Swiggy or Zomato, "place order" can be triggered by the app (REST), by a reorder button in a notification (another REST endpoint), or by a scheduled subscription (a job). Because the logic lives in OrderService, all of them behave the same.

REST  POST /api/orders  ----\
Kafka "cart.checkout"  -------> OrderService.placeOrder() --> ProductService.reserve()
@Scheduled subscription ----/                            \--> OrderRepository.save()

ShopEase: Layered Product API

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

    @GetMapping            List<ProductResponse> list()                 { return service.list(); }
    @GetMapping("/{id}")   ProductResponse get(@PathVariable Long id)   { return service.get(id); }

    @PostMapping
    ResponseEntity<ProductResponse> create(@Valid @RequestBody CreateProductRequest req) {
        ProductResponse saved = service.create(req);
        return ResponseEntity.created(URI.create("/api/products/" + saved.id())).body(saved);
    }

    @PutMapping("/{id}")
    ProductResponse update(@PathVariable Long id, @Valid @RequestBody CreateProductRequest req) {
        return service.update(id, req);
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    void delete(@PathVariable Long id) { service.delete(id); }
}

// product/ProductService.java  - rules + mapping
@Service
@Transactional
class ProductService {
    private final ProductRepository repo;
    private final ProductMapper mapper;
    ProductService(ProductRepository repo, ProductMapper mapper) { this.repo = repo; this.mapper = mapper; }

    @Transactional(readOnly = true)
    List<ProductResponse> list() { return repo.findAll().stream().map(mapper::toResponse).toList(); }

    @Transactional(readOnly = true)
    ProductResponse get(Long id) { return mapper.toResponse(find(id)); }

    ProductResponse create(CreateProductRequest req) {
        if (repo.existsBySku(req.sku())) throw new DuplicateSkuException(req.sku());
        return mapper.toResponse(repo.save(mapper.toEntity(req)));
    }

    ProductResponse update(Long id, CreateProductRequest req) {
        Product p = find(id);
        mapper.update(req, p);              // dirty checking saves it at commit
        return mapper.toResponse(p);
    }

    void delete(Long id) { repo.delete(find(id)); }

    private Product find(Long id) {
        return repo.findById(id).orElseThrow(() -> new ResourceNotFoundException("Product", id));
    }
}

Example

The API behaves exactly like before; only the inside is better organized:

curl -X DELETE -i localhost:8080/api/products/1
curl -X DELETE localhost:8080/api/products/1

Example output

HTTP/1.1 204 No Content

{"type":"https://shopease.in/errors/not-found","title":"Resource not found","status":404,"detail":"Product 1 not found","instance":"/api/products/1","errorCode":"NOT_FOUND"}