Sipariş API'sini sıfırdan yaz
Pair programming oturumlarında sık görülen "boş controller'ı doldur" görevi
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
- Request ve response DTO'larını tasarla (entity'yi dışarı açma).
- Validasyon: boş liste, pozitif olmayan adet, eksik alanlar → 400 ve anlaşılır hata gövdesi.
- Var olmayan ürün → anlamlı bir hata; var olmayan sipariş → 404.
- Oluşturmada 201 + Location header dön. Toplamı sunucu hesaplasın.
- Mobil uygulama ağ hatasında isteği tekrar gönderiyor: çift sipariş oluşmasını engelle.
// 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
// ===== 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ı
findByIdyerine tekfindAllById.
- 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)