Tampilan
Kenapa penting
Hampir setiap backend e-commerce punya satu alur yang paling sensitif: pesanan dibuat, stok berkurang, dan dua hal itu tidak boleh tidak sinkron. Bila stok berkurang tetapi pesanan gagal tersimpan, barang "hilang"; bila dua pelanggan membeli unit terakhir yang sama, stok menjadi minus dan ada pelanggan yang kecewa. Proyek mini ini menyatukan seluruh modul M08 — entity & relasi, Spring Data, transaksi, Flyway, performa query, paginasi, dan Testcontainers — ke dalam satu REST API pesanan yang berjalan di atas PostgreSQL sungguhan. Ikuti langkahnya berurutan dan ketik ulang kodenya; latihan kode lengkap dengan test otomatis untuk proyek ini menyusul di folder exercises/.
Konsep inti
Kebutuhan & tabel endpoint
| Method & path | Fungsi | Sukses | Error utama |
|---|---|---|---|
POST /api/orders | Buat pesanan berisi beberapa item | 201 + header Location | 400 validasi, 404 SKU, 409 stok |
GET /api/orders/{id} | Detail pesanan beserta item | 200 | 404 |
GET /api/orders?customerId=… | Daftar pesanan pelanggan, berhalaman | 200 | 400 parameter |
POST /api/orders/{id}/cancel | Batalkan pesanan dan kembalikan stok | 200 | 404, 409 status/konflik |
Aturan bisnis:
- Membuat pesanan bersifat all-or-nothing: semua item tersedia → stok semua item berkurang; satu saja kurang → tidak ada yang berubah.
- SKU yang sama boleh muncul dua kali di request; jumlahnya digabung.
- Hanya pesanan berstatus
PLACEDyang bisa dibatalkan; pembatalan mengembalikan stok. - Update bersamaan pada produk atau pesanan yang sama tidak boleh saling menimpa (lost update) →
409 Conflict.
Setup proyek
Buat proyek Spring Boot 4.1 (Java 25, Maven) dengan dependency berikut. Di Boot 4 starter dipecah per teknologi, jadi Flyway butuh starter-nya sendiri.
| Kebutuhan | Artifact |
|---|---|
| REST + validasi | spring-boot-starter-webmvc, spring-boot-starter-validation |
| JPA + PostgreSQL | spring-boot-starter-data-jpa, org.postgresql:postgresql (runtime) |
| Migrasi | spring-boot-starter-flyway, org.flywaydb:flyway-database-postgresql |
| Database lokal | spring-boot-docker-compose (scope runtime, optional) |
| Test | spring-boot-starter-test, spring-boot-testcontainers, modul Testcontainers JUnit Jupiter & PostgreSQL |
Struktur package dibuat per fitur agar semua yang berhubungan dengan pesanan berdekatan:
text
src/main/java/id/jalurjava/orders/
├── OrdersApplication.java
├── product/ Product, ProductRepository, InsufficientStockException, ProductNotFoundException
├── order/ Order, OrderItem, OrderStatus, OrderRepository, OrderService, OrderController, dto/
└── web/ ApiExceptionHandler
src/main/resources/
├── application.yaml
└── db/migration/ V1__create_schema.sql, V2__seed_products.sql
compose.yamlKonfigurasi minimum — skema hanya dikelola Flyway, Hibernate cukup memvalidasi:
yaml
# src/main/resources/application.yaml
spring:
jpa:
open-in-view: false # lazy loading di luar service harus gagal cepat, bukan diam-diam query
hibernate:
ddl-auto: validate
data:
web:
pageable:
max-page-size: 100 # klien tidak bisa meminta size=100000Untuk development, compose.yaml di root proyek dijalankan otomatis oleh spring-boot-docker-compose saat ./mvnw spring-boot:run, dan koneksinya disuntikkan tanpa menulis URL JDBC:
yaml
# compose.yaml
services:
postgres:
image: "postgres:17-alpine"
environment:
POSTGRES_DB: orders
POSTGRES_USER: app
POSTGRES_PASSWORD: app-local-only # hanya untuk laptop; production pakai secret
ports:
- "5432:5432"Skema & migrasi
Constraint di database adalah pertahanan terakhir: walaupun ada bug di kode, check (stock >= 0) menolak stok minus.
sql
-- V1__create_schema.sql
create table products (
id bigint generated by default as identity primary key,
sku varchar(40) not null unique,
name varchar(200) not null,
price numeric(12, 2) not null check (price >= 0),
stock integer not null check (stock >= 0),
version bigint not null default 0
);
create table orders (
id bigint generated by default as identity primary key,
customer_id varchar(64) not null,
status varchar(20) not null,
total_amount numeric(14, 2) not null,
created_at timestamptz not null,
version bigint not null default 0
);
-- Mendukung query daftar: where customer_id = ? order by created_at desc
create index idx_orders_customer_created on orders (customer_id, created_at desc);
create table order_items (
id bigint generated by default as identity primary key,
order_id bigint not null references orders (id) on delete cascade,
product_id bigint not null references products (id),
quantity integer not null check (quantity > 0),
unit_price numeric(12, 2) not null
);
-- PostgreSQL tidak otomatis membuat index untuk foreign key
create index idx_order_items_order on order_items (order_id);sql
-- V2__seed_products.sql (data contoh untuk development)
insert into products (sku, name, price, stock) values
('KOPI-250', 'Kopi Arabika 250 g', 85000.00, 10),
('TEH-100', 'Teh Melati 100 g', 25000.00, 30);Tabel bernama orders, bukan order, karena ORDER adalah kata kunci SQL. unit_price disalin ke item supaya perubahan harga produk di kemudian hari tidak mengubah nilai pesanan lama.
Entity & relasi
Aturan bisnis tinggal di entity, bukan tersebar di controller. Product menjaga stoknya sendiri dan punya @Version:
java
// Potongan — getter lain dihilangkan
@Entity
@Table(name = "products")
public class Product {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String sku;
private String name;
private BigDecimal price;
private int stock;
@Version
private long version;
protected Product() {} // dibutuhkan JPA
public Product(String sku, String name, BigDecimal price, int stock) {
this.sku = sku; this.name = name; this.price = price; this.stock = stock;
}
public void decreaseStock(int quantity) {
if (quantity > stock) {
throw new InsufficientStockException(sku, stock, quantity);
}
stock -= quantity;
}
public void increaseStock(int quantity) {
stock += quantity;
}
}Order adalah pemilik siklus hidup item-nya (cascade + orphanRemoval), sedangkan sisi @ManyToOne di OrderItem memegang foreign key:
java
// Potongan
@Entity
@Table(name = "orders")
public class Order {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "customer_id") private String customerId;
@Enumerated(EnumType.STRING) private OrderStatus status;
@Column(name = "total_amount") private BigDecimal totalAmount;
@Column(name = "created_at") private Instant createdAt;
@Version private long version;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items = new ArrayList<>();
protected Order() {}
public Order(String customerId, Instant createdAt) {
this.customerId = customerId;
this.createdAt = createdAt;
this.status = OrderStatus.PLACED;
this.totalAmount = BigDecimal.ZERO;
}
public void addItem(Product product, int quantity) {
product.decreaseStock(quantity);
OrderItem item = new OrderItem(this, product, quantity, product.getPrice());
items.add(item);
totalAmount = totalAmount.add(item.subtotal());
}
public void cancel() {
if (status != OrderStatus.PLACED) {
throw new OrderNotCancellableException(id, status);
}
items.forEach(item -> item.getProduct().increaseStock(item.getQuantity()));
status = OrderStatus.CANCELLED;
}
}
@Entity
@Table(name = "order_items")
public class OrderItem {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "order_id")
private Order order;
@ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "product_id")
private Product product;
private int quantity;
@Column(name = "unit_price") private BigDecimal unitPrice;
protected OrderItem() {}
OrderItem(Order order, Product product, int quantity, BigDecimal unitPrice) {
this.order = order; this.product = product; this.quantity = quantity; this.unitPrice = unitPrice;
}
BigDecimal subtotal() {
return unitPrice.multiply(BigDecimal.valueOf(quantity));
}
}OrderStatus cukup enum OrderStatus { PLACED, CANCELLED }, disimpan sebagai teks (EnumType.STRING) agar menambah konstanta baru tidak menggeser nilai lama.
Repository
java
public interface ProductRepository extends JpaRepository<Product, Long> {
List<Product> findBySkuIn(Collection<String> skus); // satu query untuk semua SKU
}
public interface OrderRepository extends JpaRepository<Order, Long> {
Page<Order> findByCustomerId(String customerId, Pageable pageable);
@EntityGraph(attributePaths = {"items", "items.product"})
Optional<Order> findWithItemsById(Long id); // detail: order + item + produk dalam satu query
}Service transaksional
Request bisa memuat SKU kembar, jadi gabungkan dulu. Logika penggabungan ini Java murni, jadi bisa kamu jalankan sendiri:
java
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class MergeOrderLines {
record ItemRequest(String sku, int quantity) {}
public static void main(String[] args) {
List<ItemRequest> items = List.of(
new ItemRequest("KOPI-250", 2),
new ItemRequest("TEH-100", 1),
new ItemRequest("KOPI-250", 3));
Map<String, Integer> qtyBySku = items.stream()
.collect(Collectors.toMap(
ItemRequest::sku,
ItemRequest::quantity,
Integer::sum, // SKU kembar → jumlahkan
LinkedHashMap::new)); // pertahankan urutan input
System.out.println(qtyBySku);
}
}text
{KOPI-250=5, TEH-100=1}Service memakai readOnly = true sebagai default di level class, lalu membuka transaksi tulis hanya pada method yang mengubah data:
java
// Potongan
@Service
@Transactional(readOnly = true)
public class OrderService {
private final OrderRepository orders;
private final ProductRepository products;
private final Clock clock;
public OrderService(OrderRepository orders, ProductRepository products, Clock clock) {
this.orders = orders; this.products = products; this.clock = clock;
}
@Transactional
public OrderResponse placeOrder(PlaceOrderRequest request) {
Map<String, Integer> qtyBySku = request.items().stream()
.collect(Collectors.toMap(ItemRequest::sku, ItemRequest::quantity,
Integer::sum, LinkedHashMap::new));
Map<String, Product> productBySku = products.findBySkuIn(qtyBySku.keySet()).stream()
.collect(Collectors.toMap(Product::getSku, p -> p));
Order order = new Order(request.customerId(), clock.instant());
qtyBySku.forEach((sku, qty) -> {
Product product = productBySku.get(sku);
if (product == null) {
throw new ProductNotFoundException(sku);
}
order.addItem(product, qty); // bisa melempar InsufficientStockException → rollback semua
});
return OrderResponse.from(orders.save(order));
}
public OrderResponse findById(long id) {
return orders.findWithItemsById(id).map(OrderResponse::from)
.orElseThrow(() -> new OrderNotFoundException(id));
}
public PageResponse<OrderSummaryResponse> findByCustomer(String customerId, Pageable pageable) {
return PageResponse.from(orders.findByCustomerId(customerId, pageable).map(OrderSummaryResponse::from));
}
@Transactional
public OrderResponse cancel(long id) {
Order order = orders.findWithItemsById(id).orElseThrow(() -> new OrderNotFoundException(id));
order.cancel();
return OrderResponse.from(order);
}
}Perhatikan: tidak ada products.save(...). Produk adalah entity managed di persistence context, jadi perubahan stok ditulis otomatis saat flush (dirty checking). UPDATE itu berbentuk update products set stock=?, version=? where id=? and version=?. Bila transaksi lain sudah lebih dulu mengubah produk yang sama, tidak ada baris yang cocok dan Spring melempar ObjectOptimisticLockingFailureException saat commit — setelah method selesai, ketika proxy transaksi menutupnya. Exception itu tetap naik ke controller, sehingga bisa dipetakan ke 409. Clock di-inject (daftarkan @Bean Clock clock() { return Clock.systemUTC(); }) agar waktu bisa dikendalikan di test.
Controller, DTO, dan ProblemDetail
java
// Potongan — DTO request & response
public record PlaceOrderRequest(
@NotBlank String customerId,
@NotEmpty @Size(max = 50) List<@Valid ItemRequest> items) {}
public record ItemRequest(@NotBlank String sku, @Positive @Max(100) int quantity) {}
public record OrderSummaryResponse(long id, String status, BigDecimal totalAmount, Instant createdAt) {
static OrderSummaryResponse from(Order o) {
return new OrderSummaryResponse(o.getId(), o.getStatus().name(), o.getTotalAmount(), o.getCreatedAt());
}
}
public record PageResponse<T>(List<T> content, int page, int size, long totalElements, int totalPages) {
static <T> PageResponse<T> from(Page<T> p) {
return new PageResponse<>(p.getContent(), p.getNumber(), p.getSize(), p.getTotalElements(), p.getTotalPages());
}
}OrderSummaryResponse sengaja tidak menyentuh items — itulah yang membuat endpoint daftar bebas N+1. PageResponse milikmu sendiri menjaga bentuk JSON tetap stabil, alih-alih menyerialisasi Page (PageImpl) langsung. OrderResponse (detail beserta item) disusun dengan pola yang sama.
java
// Potongan
@RestController
@RequestMapping("/api/orders")
class OrderController {
private final OrderService service;
OrderController(OrderService service) { this.service = service; }
@PostMapping
ResponseEntity<OrderResponse> place(@Valid @RequestBody PlaceOrderRequest request, UriComponentsBuilder uri) {
OrderResponse created = service.placeOrder(request);
return ResponseEntity.created(uri.path("/api/orders/{id}").build(created.id())).body(created);
}
@GetMapping("/{id}")
OrderResponse get(@PathVariable long id) { return service.findById(id); }
@GetMapping
PageResponse<OrderSummaryResponse> list(
@RequestParam String customerId,
@PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) {
return service.findByCustomer(customerId, pageable);
}
@PostMapping("/{id}/cancel")
OrderResponse cancel(@PathVariable long id) { return service.cancel(id); }
}Handler terpusat melanjutkan pola dari proyek mini M07; yang baru hanya error stok dan konflik versi:
java
// Potongan
@RestControllerAdvice
class ApiExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler({OrderNotFoundException.class, ProductNotFoundException.class})
ProblemDetail handleNotFound(RuntimeException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}
@ExceptionHandler({InsufficientStockException.class, OrderNotCancellableException.class})
ProblemDetail handleBusinessConflict(RuntimeException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
}
@ExceptionHandler(OptimisticLockingFailureException.class)
ProblemDetail handleConcurrentUpdate(OptimisticLockingFailureException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT,
"Data sedang diubah oleh permintaan lain. Muat ulang lalu coba lagi.");
problem.setTitle("Concurrent update");
return problem; // jangan bocorkan ex.getMessage() — isinya nama class & SQL internal
}
}Test integrasi dengan Testcontainers
Test ini menyalakan PostgreSQL asli di Docker, menjalankan migrasi Flyway, lalu memanggil API lewat MockMvc. @ServiceConnection menyuntikkan URL, user, dan password container ke datasource:
java
// Potongan — import dihilangkan
@SpringBootTest
@AutoConfigureMockMvc
@Testcontainers
class OrderApiIT {
@Container
@ServiceConnection
static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:17-alpine");
@Autowired MockMvc mvc;
@Autowired ProductRepository products;
@Autowired OrderRepository orders;
@BeforeEach
void resetData() {
orders.deleteAll();
products.deleteAll();
products.save(new Product("KOPI-250", "Kopi Arabika 250 g", new BigDecimal("85000.00"), 10));
products.save(new Product("TEH-100", "Teh Melati 100 g", new BigDecimal("25000.00"), 1));
}
@Test
void placeOrder_reducesStockOfEveryItem() throws Exception {
mvc.perform(post("/api/orders").contentType(MediaType.APPLICATION_JSON).content("""
{"customerId":"c-1","items":[{"sku":"KOPI-250","quantity":2},{"sku":"TEH-100","quantity":1}]}
"""))
.andExpect(status().isCreated())
.andExpect(header().exists("Location"))
.andExpect(jsonPath("$.status").value("PLACED"))
.andExpect(jsonPath("$.items.length()").value(2));
assertThat(stockOf("KOPI-250")).isEqualTo(8);
assertThat(stockOf("TEH-100")).isZero();
}
@Test
void insufficientStock_returns409_andRollsBackOtherItems() throws Exception {
mvc.perform(post("/api/orders").contentType(MediaType.APPLICATION_JSON).content("""
{"customerId":"c-1","items":[{"sku":"KOPI-250","quantity":2},{"sku":"TEH-100","quantity":5}]}
"""))
.andExpect(status().isConflict())
.andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON));
assertThat(stockOf("KOPI-250")).isEqualTo(10); // item pertama ikut di-rollback
assertThat(orders.count()).isZero();
}
private int stockOf(String sku) {
return products.findBySkuIn(List.of(sku)).getFirst().getStock();
}
}Test kedua adalah yang paling berharga: ia membuktikan sifat all-or-nothing di database sungguhan. Tambahkan sendiri test pembatalan (stok kembali, pembatalan kedua → 409) dan test daftar berhalaman (size=1 → totalPages sesuai).
Contoh di dunia backend
Sebelum menganggap API ini siap, jalankan checklist yang biasa dipakai saat code review fitur data:
| Area | Pertanyaan | Di proyek ini |
|---|---|---|
| N+1 | Apakah daftar menyentuh relasi lazy per baris? | Daftar hanya kolom ringkasan; detail pakai @EntityGraph |
| N+1 | Apakah produk dimuat satu per satu dalam loop? | findBySkuIn — satu query untuk semua SKU |
| Index | Apakah query utama punya index yang cocok? | (customer_id, created_at desc) dan index FK order_items.order_id |
| Transaksi | Apakah satu use case = satu transaksi di service? | placeOrder dan cancel ber-@Transactional; baca readOnly |
| Konkurensi | Apa yang terjadi saat dua request mengubah baris yang sama? | @Version → 409; check (stock >= 0) sebagai pengaman akhir |
| Skema | Siapa pemilik skema? | Flyway; Hibernate hanya validate |
| Batas | Bisakah klien meminta data tak terbatas? | max-page-size: 100, @Size(max = 50) item per pesanan |
Untuk melihat masalah sebelum production, nyalakan log SQL sementara saat development (logging.level.org.hibernate.SQL=debug) lalu panggil endpoint daftar: kamu harus melihat tepat dua query — satu select halaman dan satu select count(*). Bila jumlah query ikut naik seiring ukuran halaman, ada N+1 yang lolos.
Untuk produk yang sangat laris (flash sale), optimistic locking bisa menghasilkan banyak 409 karena banyak transaksi berebut baris yang sama. Opsi lanjutannya: retry terbatas di sisi service untuk konflik versi, atau UPDATE atomik update products set stock = stock - :qty where id = :id and stock >= :qty lewat @Modifying @Query yang memeriksa jumlah baris terubah. Pilih berdasarkan pola beban, dan catat alasannya.
Pertanyaan interview
1. Bagaimana kamu memastikan pembuatan pesanan dan pengurangan stok tetap konsisten?
Keduanya dijalankan dalam satu method service @Transactional, sehingga exception apa pun (stok kurang, SKU tidak ada) me-rollback seluruh perubahan. Untuk konkurensi, entity Product memakai @Version agar update bersamaan terdeteksi dan transaksi yang kalah gagal dengan 409, bukan menimpa diam-diam. Constraint check (stock >= 0) di database menjadi pengaman terakhir bila ada jalur kode yang lolos.
2. Kenapa endpoint daftar pesanan tidak memakai join fetch untuk item?
join fetch pada koleksi menggandakan baris per item, sehingga limit/offset di SQL tidak lagi berarti "N pesanan". Hibernate lalu memuat semua baris dan memaginasi di memori sambil memberi peringatan HHH90003004. Solusinya: daftar menampilkan data ringkasan dari tabel orders saja, dan item dimuat di endpoint detail dengan entity graph — atau, bila daftar memang butuh item, pakai dua query (ID halaman dulu, lalu fetch item untuk ID tersebut).
3. Kenapa ddl-auto diset ke validate dan bukan update?
update membiarkan Hibernate mengubah skema secara implisit tanpa riwayat, tidak bisa menghapus atau mengganti nama kolom dengan aman, dan hasilnya berbeda antar-environment. Dengan Flyway, setiap perubahan adalah file migrasi berversi yang di-review dan diterapkan sama persis di laptop, CI, dan production; validate memastikan entity tidak menyimpang dari skema itu saat aplikasi start.
Jebakan umum
- Memanggil
order.getItems()di DTO daftar — setiap baris memicu query tambahan (N+1). Daftar cukup memakai kolom ringkasan. - Menggabungkan
join fetchkoleksi denganPageable— paginasi pindah ke memori dan bisa memuat ribuan baris. - Menangkap
RuntimeExceptiondi dalam method@Transactionallalu mengembalikan nilai biasa — transaksi tetap commit dengan data setengah jadi. Biarkan exception domain keluar dari service. - Menandai test integrasi API dengan
@Transactional— commit dan pengecekan@Versiontidak pernah terjadi; bersihkan data di@BeforeEachsebagai gantinya. - Mengedit migrasi Flyway yang sudah diterapkan — checksum tidak cocok dan aplikasi gagal start. Selalu buat versi baru.
- Membocorkan
ex.getMessage()dari exception teknis (JPA/JDBC) keProblemDetail— isinya SQL dan nama class internal.
Ringkasan
- Skema dikelola Flyway (
V1__…,V2__…) lengkap dengan constraint dan index; Hibernate cukupddl-auto: validate. - Aturan bisnis (kurangi/kembalikan stok, cek status) tinggal di entity; service mengorkestrasi dalam satu transaksi.
@Transactionalmembuat pesanan all-or-nothing;@Versionmencegah lost update dan dipetakan ke409 Conflict.- Daftar berhalaman memakai DTO ringkasan +
PageResponsesendiri; detail memuat item dengan@EntityGraph— bebas N+1. - Test integrasi Testcontainers dengan PostgreSQL asli membuktikan rollback dan perhitungan stok;
compose.yaml+spring-boot-docker-composemenyiapkan database untuk development.