MongoDB Advanced Data Modeling dengan Spring Boot

Sebelumnya kita sudah belajar tentang membuat applikasi spring boot sederhana dengan mongodb di artikel ini spring-dan-mongodb-user-login dan juga ini belajar-relasi-mongo-db-dengan-studi, selanjutnya mari buat studi kasus yang lebih komplek untuk memberikan pemahaman lebih lanjut tentang MongoDB Advanced Data Modeling dengan Spring Boot.


1. Embedded vs Reference dan Implementasinya di Spring Boot

Artikel ini merupakan lanjutan langsung dari pembahasan sistem rental mobil (Owner & Car) sebelumnya. Pada bagian ini, fokus kita bergeser dari sekadar CRUD dan relasi dasar menuju pemahaman mendalam tentang cara memodelkan data MongoDB secara fungsional, sekaligus bagaimana keputusan tersebut diimplementasikan dengan benar di Spring Boot.

Tujuan utama artikel ini adalah membantu kamu berpikir seperti system designer, bukan sekadar penulis kode.

3. Mengubah Cara Pandang: MongoDB Bukan SQL Tanpa JOIN

Kesalahan paling umum saat pertama kali menggunakan MongoDB adalah memperlakukannya seperti relational database tanpa JOIN. Padahal, MongoDB justru memberi kebebasan untuk menyesuaikan struktur data dengan pola akses aplikasi, bukan sebaliknya.

Pertanyaan utama yang harus dijawab sebelum menentukan embedded atau reference bukanlah:

“Apakah ini relasi one-to-many?”

melainkan:

“Bagaimana data ini digunakan, dibaca, dan bertumbuh?”

4. Embedded Data: Ketika Data Selalu Berjalan Bersama

Studi Kasus: Riwayat Status Mobil

Dalam sistem rental mobil, setiap mobil memiliki status yang dapat berubah seiring waktu, seperti AVAILABLE, RENTED, atau MAINTENANCE. Riwayat perubahan ini selalu berkaitan langsung dengan mobil dan tidak pernah diakses secara terpisah.


5. Alasan Teknis Memilih Embedded

  • Data selalu dibaca bersama parent (Car)

  • Tidak memiliki makna bisnis tanpa parent

  • Jumlah data relatif kecil dan terkendali

  • Performa read lebih cepat tanpa query tambahan

6. Spring Data dengan Menggunakan record

Ya, mendukung record secara resmi.

Spring Data MongoDB:

  • bisa melakukan mapping constructor

  • mengenali @Id di parameter record

  • bisa serialize / deserialize embedded object di dalam record

Car entity dengan status history
package com.yusuf.springmongoclean.domain.rental;

import com.yusuf.springmongoclean.enumerator.CarColor;
import com.yusuf.springmongoclean.enumerator.CarStatus;
import com.yusuf.springmongoclean.enumerator.CarType;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;

import java.util.List;

@Document(collection = "cars")
public record Car(
@Id String id,
String ownerId,

CarType type,
String brand,
String model,
int year,

int totalSeats,
CarColor color,

String plateNumber,
int dailyPrice,
CarStatus status,

List<CarStatusHistory> statusHistory
) {}

Dan embedded record:
package com.yusuf.springmongoclean.domain.rental;

import com.yusuf.springmongoclean.enumerator.CarStatus;

import java.time.Instant;

public record CarStatusHistory(
CarStatus status,
Instant changedAt
) {}

MongoDB driver dan Spring Data akan memperlakukannya seperti immutable value object.

7. Kapan record Sangat Cocok untuk Entity MongoDB?

Cocok jika:

  • MongoDB (bukan JPA)
  • Tidak pakai lazy loading
  • Tidak pakai dirty checking
  • Update dilakukan dengan replace document
  • Domain bersifat state snapshot, bukan entity lifecycle berat

MongoDB tidak butuh:

  • proxy

  • setter

  • no-arg constructor

Jadi record = aman.

8. Embedded + Record = Kombinasi Ideal

Embedded document secara konsep memang value object.

CarStatusHistory:

  • tidak punya identity

  • tidak hidup sendiri

  • tidak berubah (append-only)

Secara DDD, ini textbook:

Entity → record
Embedded / Value Object → record

Record lebih konsisten secara model dibanding pakai class mutable.

9. Kapan record TIDAK Disarankan?

Jujur saja, ada case di mana class lebih cocok.

Hindari record jika:

  • pakai JPA / Hibernate

  • butuh partial update kompleks dengan mutation

  • domain punya banyak behavior internal

  • entity lifecycle kompleks (aggregate root berat)

Tapi kita tidak sedang berada di situ sekarang.

10. CRUD dengan Embeded Data CarStatusHistory di CARS

Kita lanjut rapi, bertahap, dan konsisten dengan desain record + embedded yang sudah kita bangun.
Fokus kita: CRUD Car yang benar-benar support List<CarStatusHistory> tanpa melanggar prinsip MongoDB + immutability.

Prinsip Dasar (Pegang Ini Dulu)

Sebelum ke kode, ini aturan mainnya:

  • Car tetap record (immutable)

  • CarStatusHistory adalah embedded value object

  • CRUD tidak pernah mutate list

  • Setiap perubahan status → append history

  • Tidak ada repository untuk CarStatusHistory


11. CREATE Car (Init Status History)

Saat mobil dibuat:

  • status default = AVAILABLE

  • history diisi 1 entry awal

Service: Create di CarService

embedded dibuat sekali, clean, eksplisit.
public Car create(CarRequest request) {
ownerRepository.findById(request.ownerId())
.orElseThrow(() -> new IllegalArgumentException("Owner not found"));
CarStatus initialStatus = CarStatus.AVAILABLE;

List<CarStatusHistory> statusHistories = List.of(
new CarStatusHistory(initialStatus, Instant.now())
);

return carRepository.save(new Car(
null,
request.ownerId(),
request.type(),
request.brand(),
request.model(),
request.year(),
request.totalSeats(),
request.color(),
request.plateNumber(),
request.dailyPrice(),
CarStatus.AVAILABLE,
statusHistories
));
}

12. READ Car di Car Service (Dengan History)

a. Find by ID di CarService

public Car findById(String id) {
return carRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Car not found"));
}

b. Find Cars by Owner di CarService

public List<Car> findByOwner(String ownerId) {
return carRepository.findByOwnerId(ownerId);
}

c. Delete Cars by ID di CarService

@DeleteMapping("/{id}")
public void delete(@PathVariable String id) {
service.delete(id);
}

13. UPDATE Car DTO

package com.yusuf.springmongoclean.dto.rental;

import com.yusuf.springmongoclean.enumerator.CarColor;

public record UpdateCarRequest(
String brand,
String model,
CarColor color,
Integer dailyPrice
) {}

14. UPDATE di Car Service (Core Use Case)

Ini CRUD paling penting karena melibatkan embedded list.

Service: UpdateCarStatus di CarService

public Car updateCarStatus(String carId, CarStatus newStatus) {

Car car = carRepository.findById(carId)
.orElseThrow(() -> new IllegalArgumentException("Car not found"));

if (car.status() == newStatus) {
return car; // no-op
}

List<CarStatusHistory> updatedHistory =
new ArrayList<>(car.statusHistory());

updatedHistory.add(
new CarStatusHistory(newStatus, Instant.now())
);

Car updatedCar = new Car(
car.id(),
car.ownerId(),
car.type(),
car.brand(),
car.model(),
car.year(),
car.totalSeats(),
car.color(),
car.plateNumber(),
car.dailyPrice(),
newStatus,
updatedHistory
);

return carRepository.save(updatedCar);
}

Service: UpdateCarInfo di CarService

public Car updateCarInfo(String carId, UpdateCarRequest request) {

Car car = findById(carId);

return carRepository.save(new Car(
car.id(),
car.ownerId(),
car.type(),
request.brand() != null ? request.brand() : car.brand(),
request.model() != null ? request.model() : car.model(),
car.year(),
car.totalSeats(),
request.color() != null ? request.color() : car.color(),
car.plateNumber(),
request.dailyPrice() > 0 ? request.dailyPrice() : car.dailyPrice(),
car.status(),
car.statusHistory()
));
}

Kenapa ini oke?

  • immutable

  • append-only history

  • MongoDB replace document secara atomic

15. UPDATE Car Controller (Rest Endpoints)

package com.yusuf.springmongoclean.controller.rental;

import com.yusuf.springmongoclean.domain.rental.Car;
import com.yusuf.springmongoclean.dto.rental.CarRequest;
import com.yusuf.springmongoclean.dto.rental.UpdateCarRequest;
import com.yusuf.springmongoclean.enumerator.CarStatus;
import com.yusuf.springmongoclean.service.rental.CarService;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/cars")
public class CarController {


private final CarService service;


public CarController(CarService service) {
this.service = service;
}


@PostMapping
public Car create(@RequestBody CarRequest request) {
return service.create(request);
}


@GetMapping("/owner/{ownerId}")
public List<Car> findByOwner(@PathVariable String ownerId) {
return service.findByOwner(ownerId);
}

@GetMapping("/{id}")
public Car findById(@PathVariable String id) {
return service.findById(id);
}

@PatchMapping("/{id}/status")
public Car updateCarStatus(
@PathVariable String id,
@RequestParam CarStatus status
) {
return service.updateCarStatus(id, status);
}

@PutMapping("/{id}/info")
public Car updateCarInfo(
@PathVariable String id,
@RequestBody UpdateCarRequest updateCarRequest
) {
return service.updateCarInfo(id, updateCarRequest);
}

@DeleteMapping("/{id}")
public void delete(@PathVariable String id) {
service.delete(id);
}
}

16. Endpoint - Create Car

CURL:

postman request POST 'http://localhost:8080/cars' \
  --header 'Content-Type: application/json' \
  --body '{
    "ownerId": "69772766754f818abd7b816e",
    "type": "MPV",
    "brand": "Toyota",
    "model": "Avanza",
    "year": 2022,
    "totalSeats": 7,
    "color": "BLACK",
    "plateNumber": "D 1234 AB",
    "dailyPrice": 350000
  }'




17. Update Status Car (From Available to Rented)

Curl:

curl --location --request PATCH 'http://localhost:8080/cars/69786949703ceb5efc23a849/status?status=RENTED'

18. Update Car Info (Partial Update)


Curl:

curl --location --request PUT 'http://localhost:8080/cars/69786949703ceb5efc23a849/info' \
--header 'Content-Type: application/json' \
--data '{
    "brand": "Toyota",
    "model": "Avanza Facelift",
    "color": "RED",
    "dailyPrice": 375000
}'


19. Data di Database



Pada tahap ini, kita tidak hanya membangun CRUD yang “berjalan”, tetapi sebuah fondasi sistem yang sadar akan struktur data, alur bisnis, dan karakteristik MongoDB sebagai document database. Dengan memanfaatkan embedded document melalui CarStatusHistory, kita melihat bagaimana MongoDB memungkinkan data dimodelkan secara lebih natural, mengikuti cara aplikasi benar-benar membaca dan menggunakan informasi.

Pendekatan menggunakan record yang immutable mendorong desain yang eksplisit dan aman, di mana setiap perubahan state tercermin sebagai objek baru, bukan mutasi tersembunyi. Hal ini membuat alur perubahan data lebih mudah dipahami, diuji, dan diaudit, sekaligus selaras dengan cara MongoDB menangani update dokumen secara atomik.

Lebih dari sekadar implementasi teknis, pembahasan ini menekankan pentingnya memilih struktur data berdasarkan konteks penggunaan, bukan kebiasaan dari dunia relational database. Embedded dan reference bukanlah kompetitor, melainkan alat yang saling melengkapi ketika digunakan pada tempat yang tepat.


Comments

Popular posts from this blog

Numpang Kerja Remote dari Bandung Creative Hub

Debugging PHP Web dengan XDebug di Intellij IDEA (PHP STORM)

Numpang Kerja Remote dari Bandung Digital Valley