Live coding · REST API · Validation · Spring MVC

Sipariş API'sini sıfırdan yaz

OrtaHedef 30 dk

Pair programming oturumlarında sık görülen "boş controller'ı doldur" görevi

00:00/ 30:00 hedefÖnce kendin çöz; ipuçlarını sırayla aç.

Bir sipariş oluşturma ve okuma endpoint'i lazım. Elinde şunlar var: ProductRepository (findAllById), OrderRepository ve Order/OrderLine entity'leri. Fiyatlar ürün tablosundan okunur.

  • POST /api/v1/orders, gövde: {"customerId": 7, "items": [{"productId": 3, "quantity": 2}]}
  • GET /api/v1/orders/{id}

Görevler

  1. Request ve response DTO'larını tasarla (entity'yi dışarı açma).
  2. Validasyon: boş liste, pozitif olmayan adet, eksik alanlar → 400 ve anlaşılır hata gövdesi.
  3. Var olmayan ürün → anlamlı bir hata; var olmayan sipariş → 404.
  4. Oluşturmada 201 + Location header dön. Toplamı sunucu hesaplasın.
  5. Mobil uygulama ağ hatasında isteği tekrar gönderiyor: çift sipariş oluşmasını engelle.
Başlangıç kodu
// Mevcut: Product(id, name, price: BigDecimal), ProductRepository, Order, OrderLine, OrderRepository

// ===== OrderController.java =====
@RestController
public class OrderController {

}

// ===== OrderService.java =====
@Service
public class OrderService {

}
İpucu 1

record CreateOrderRequest(@NotNull Long customerId, @NotEmpty List<@Valid ItemRequest> items); record ItemRequest(@NotNull Long productId, @Positive int quantity). Controller'da @Valid @RequestBody.

İpucu 2

Servis: ürünleri tek sorguda findAllById ile çek (N+1 yok), eksik id varsa exception, fiyatı DB'den al, toplamı BigDecimal ile hesapla, @Transactional.

İpucu 3

201: ResponseEntity.created(URI.create("/api/v1/orders/" + id)).body(response). Çift sipariş: Idempotency-Key header + DB'de unique constraint; aynı key gelirse ilk siparişi dön.

Referans çözümü göster
Çözüm
// ===== DTO'lar =====
public record CreateOrderRequest(
        @NotNull Long customerId,
        @NotEmpty List<@Valid OrderItemRequest> items) {}

public record OrderItemRequest(
        @NotNull Long productId,
        @Positive int quantity) {}

public record OrderResponse(Long id, Long customerId, String status, BigDecimal total, List<OrderLineResponse> lines) {
    static OrderResponse from(Order o) { /* mapping */ }
}

// ===== OrderController.java =====
@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {

    private final OrderService orderService;

    public OrderController(OrderService orderService) { this.orderService = orderService; }

    @PostMapping
    public ResponseEntity<OrderResponse> create(
            @RequestHeader(name = "Idempotency-Key", required = false) String idempotencyKey,
            @Valid @RequestBody CreateOrderRequest request) {
        OrderResponse created = orderService.create(request, idempotencyKey);
        return ResponseEntity.created(URI.create("/api/v1/orders/" + created.id())).body(created);
    }

    @GetMapping("/{id}")
    public OrderResponse get(@PathVariable long id) {
        return orderService.get(id);
    }
}

// ===== OrderService.java =====
@Service
public class OrderService {

    private final OrderRepository orderRepository;
    private final ProductRepository productRepository;

    public OrderService(OrderRepository orderRepository, ProductRepository productRepository) {
        this.orderRepository = orderRepository;
        this.productRepository = productRepository;
    }

    @Transactional
    public OrderResponse create(CreateOrderRequest request, String idempotencyKey) {
        if (idempotencyKey != null) {
            Optional<Order> existing = orderRepository.findByIdempotencyKey(idempotencyKey);
            if (existing.isPresent()) return OrderResponse.from(existing.get());   // tekrar istek: aynı cevap
        }

        Set<Long> ids = request.items().stream().map(OrderItemRequest::productId).collect(Collectors.toSet());
        Map<Long, Product> products = productRepository.findAllById(ids).stream()     // tek sorgu
                .collect(Collectors.toMap(Product::getId, Function.identity()));
        Set<Long> missing = new HashSet<>(ids);
        missing.removeAll(products.keySet());
        if (!missing.isEmpty()) throw new ProductNotFoundException(missing);

        Order order = Order.create(request.customerId(), idempotencyKey);
        for (OrderItemRequest item : request.items()) {
            Product p = products.get(item.productId());
            order.addLine(p.getId(), p.getPrice(), item.quantity());                  // fiyat DB'den
        }
        // orders.idempotency_key UNIQUE: eşzamanlı iki aynı istekten biri constraint hatası alır
        return OrderResponse.from(orderRepository.save(order));
    }

    @Transactional(readOnly = true)
    public OrderResponse get(long id) {
        return orderRepository.findById(id).map(OrderResponse::from)
                .orElseThrow(() -> new OrderNotFoundException(id));
    }
}

// ===== GlobalExceptionHandler.java =====
@RestControllerAdvice
class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    ProblemDetail validation(MethodArgumentNotValidException e) {
        ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Validation failed");
        pd.setProperty("errors", e.getBindingResult().getFieldErrors().stream()
                .map(f -> Map.of("field", f.getField(), "message", String.valueOf(f.getDefaultMessage())))
                .toList());
        return pd;
    }

    @ExceptionHandler(OrderNotFoundException.class)
    ProblemDetail orderNotFound(OrderNotFoundException e) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
    }

    @ExceptionHandler(ProductNotFoundException.class)
    ProblemDetail productNotFound(ProductNotFoundException e) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.UNPROCESSABLE_ENTITY, e.getMessage());
    }

    @ExceptionHandler(DataIntegrityViolationException.class)
    ProblemDetail conflict(DataIntegrityViolationException e) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, "Duplicate request");
    }
}
Açıklama: neden böyle?
  • Entity'yi dışarı açmamak: Lazy alanlar serileştirmede patlar, iç alanlar sızar, API sözleşmesi DB şemasına kilitlenir. Record DTO'lar ile sözleşme ayrı tutulur.
  • İç içe validasyon: List<@Valid OrderItemRequest> yazmazsan liste elemanları doğrulanmaz. Klasik bir gözden kaçırma.
  • Fiyata asla istemciden güvenme: İstek gövdesinde fiyat olsa bile kullanılmaz. Toplam, DB'deki fiyattan sunucuda hesaplanır.
  • Status kodları: 201 + Location (oluşturma), 400 (sözdizimi/validasyon), 404 (kaynak yok), 409 (çakışma), 422 (iş kuralı: var olmayan ürün). 422 mi 400 mü konusunda takımın standardına uyulur; tutarlı olmak önemli.
  • Idempotency: POST idempotent değildir. Mobil retry'ı çift sipariş yaratır. Çözüm: istemci Idempotency-Key üretir, sunucu key'i siparişle birlikte unique olarak saklar ve tekrar gelen isteğe ilk cevabı döner. "Önce bak, yoksa yaz" yarışını kapatan şey unique constraint'tir.
  • N+1: Her ürün için ayrı findById yerine tek findAllById.
  • Ayrı request/response DTO'ları (record), entity dışarı açılmıyor
  • Bean Validation: @Valid, @NotEmpty, @Positive, @NotNull (iç içe listede @Valid)
  • Hata yönetimi: @RestControllerAdvice; validasyon 400, sipariş yok 404, ürün yok 404/422
  • 201 Created + Location header
  • Fiyat ve toplam sunucuda, DB'deki ürün fiyatından (istemciye güvenmeden), BigDecimal
  • Ürünleri tek sorguda çekmek (findAllById), @Transactional servis
  • Idempotency: Idempotency-Key header + unique constraint, tekrar isteğe aynı cevap
  • Sipariş oluşturulurken stok da düşülecek. Bu metotta nereye ve nasıl eklersin? (atomik UPDATE, aynı transaction)
  • Sipariş oluşunca kargo servisine haber verilecek. Doğrudan Kafka'ya mı gönderirsin? (dual-write, outbox)
  • GET /api/v1/orders?customerId=7 için pagination'ı nasıl tasarlarsın? (keyset, Slice)
  • Bu endpoint'i nasıl test edersin? (@WebMvcTest + MockMvc ve Testcontainers ile integration)