Spring Boot #
Membangun aplikasi Java enterprise dari nol dengan Spring Framework murni adalah pengalaman yang melelahkan — XML konfigurasi yang panjang, dependency yang harus dikonfigurasi manual satu per satu, dan waktu yang habis hanya untuk boilerplate sebelum satu baris logika bisnis ditulis. Spring Boot lahir untuk menyelesaikan masalah ini. Dengan prinsip convention over configuration, Spring Boot membuat keputusan default yang masuk akal untuk kamu — embedded Tomcat sudah siap, auto-configuration aktif berdasarkan dependency yang ada di classpath, dan satu anotasi @SpringBootApplication sudah cukup untuk menjalankan aplikasi production-grade. Hasilnya adalah kamu bisa fokus pada apa yang penting: logika bisnis. Spring Boot saat ini adalah framework Java paling banyak digunakan di industri, dan memahaminya dengan baik adalah fondasi yang akan memengaruhi cara kamu merancang dan membangun sistem.
Cara Kerja Spring Boot #
Sebelum menulis kode, penting memahami mekanisme inti yang membuat Spring Boot bekerja. Dua konsep ini mengontrol hampir semua yang terjadi di dalam aplikasi Spring Boot.
Inversion of Control dan Dependency Injection #
Spring Boot menerapkan prinsip Inversion of Control (IoC) — alih-alih kelas yang menciptakan dependensinya sendiri, Spring yang bertanggung jawab menciptakan dan menyuntikkan objek yang dibutuhkan. Objek yang dikelola Spring disebut Bean.
flowchart LR
subgraph TANPA[Tanpa IoC — kelas kontrol dependensinya sendiri]
A1[OrderService] -->|new UserRepository| B1[UserRepository]
A1 -->|new EmailService| C1[EmailService]
end
subgraph DENGAN[Dengan IoC — Spring yang kontrol]
IOC[Spring IoC\nContainer]
IOC -->|inject| A2[OrderService]
IOC -->|buat dan kelola| B2[UserRepository]
IOC -->|buat dan kelola| C2[EmailService]
B2 -->|disuntikkan ke| A2
C2 -->|disuntikkan ke| A2
endAuto-Configuration #
Spring Boot memeriksa classpath kamu saat startup. Jika ia menemukan spring-boot-starter-web, ia otomatis mengkonfigurasi Tomcat, Jackson, dan Spring MVC. Jika menemukan spring-boot-starter-data-jpa, ia mengkonfigurasi Hibernate dan DataSource. Ini disebut auto-configuration — kamu tidak perlu mendefinisikan bean secara manual untuk hal-hal yang sudah punya default yang masuk akal.
Application Startup
↓
@SpringBootApplication ditemukan
↓
@EnableAutoConfiguration aktif
↓
Scan classpath — dependency apa yang ada?
↓
spring-boot-starter-web ditemukan
→ konfigurasi Tomcat (port 8080)
→ konfigurasi Jackson (JSON serialisasi)
→ konfigurasi Spring MVC (dispatcher servlet)
↓
spring-boot-starter-data-jpa ditemukan
→ konfigurasi Hibernate
→ konfigurasi DataSource dari application.properties
↓
Aplikasi siap melayani request
Setup Project #
Cara tercepat membuat project Spring Boot baru adalah melalui Spring Initializr di start.spring.io. Pilih dependency yang dibutuhkan, download, dan buka di IDE.
Untuk Maven, struktur pom.xml dasar:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<!-- Spring Boot parent — mengelola versi semua dependency -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.0</version>
<relativePath/>
</parent>
<groupId>com.contoh</groupId>
<artifactId>toko-online</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>toko-online</name>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<!-- Web MVC + Tomcat embedded -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Data JPA + Hibernate -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Validasi bean (Jakarta Validation) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Driver database PostgreSQL -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- H2 untuk testing (in-memory database) -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<!-- Testing -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
Struktur direktori standar Spring Boot:
src/
├── main/
│ ├── java/
│ │ └── com/contoh/tokoonline/
│ │ ├── TokoOnlineApplication.java ← entry point
│ │ ├── controller/ ← REST controller
│ │ ├── service/ ← logika bisnis
│ │ ├── repository/ ← akses database
│ │ ├── model/ ← entity dan DTO
│ │ └── config/ ← konfigurasi kustom
│ └── resources/
│ ├── application.properties ← konfigurasi utama
│ ├── application-dev.properties ← konfigurasi dev
│ └── application-prod.properties ← konfigurasi prod
└── test/
└── java/
└── com/contoh/tokoonline/ ← test classes
Entry Point #
package com.contoh.tokoonline;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
// @SpringBootApplication adalah gabungan dari:
// @Configuration — kelas ini adalah sumber konfigurasi Spring
// @EnableAutoConfiguration — aktifkan auto-configuration
// @ComponentScan — scan semua @Component, @Service, @Repository di package ini
@SpringBootApplication
public class TokoOnlineApplication {
public static void main(String[] args) {
SpringApplication.run(TokoOnlineApplication.class, args);
}
}
Dependency Injection #
Spring mendukung tiga cara injeksi dependency. Memilih cara yang tepat penting untuk testability dan kejelasan kode.
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class OrderService {
// ✗ ANTI-PATTERN: field injection — tidak bisa ditest tanpa Spring context
@Autowired
private UserRepository userRepository;
// ✗ ANTI-PATTERN: setter injection — dependensi bisa null jika setter tidak dipanggil
private EmailService emailService;
@Autowired
public void setEmailService(EmailService emailService) {
this.emailService = emailService;
}
// ✓ BENAR: constructor injection — dependensi wajib ada, bisa ditest tanpa Spring
// Sejak Spring 4.3, @Autowired opsional jika hanya ada satu constructor
private final ProductRepository productRepository;
private final NotificationService notificationService;
public OrderService(ProductRepository productRepository,
NotificationService notificationService) {
this.productRepository = productRepository;
this.notificationService = notificationService;
}
}
Anotasi Stereotype #
Spring mengenali kelas sebagai Bean berdasarkan anotasi stereotype:
// @Component — Bean generik, tidak ada peran spesifik
@org.springframework.stereotype.Component
public class DataConverter { }
// @Service — Bean untuk logika bisnis (semantik saja, sama seperti @Component)
@Service
public class PembayaranService { }
// @Repository — Bean untuk akses data; Spring menangani translasi exception database
@org.springframework.stereotype.Repository
public class ProdukRepositoryImpl { }
// @Controller — Bean untuk handler HTTP request (mengembalikan view)
@org.springframework.stereotype.Controller
public class HalamanController { }
// @RestController — @Controller + @ResponseBody; semua method return JSON/data langsung
@org.springframework.web.bind.annotation.RestController
public class ProdukApiController { }
// @Configuration — mendefinisikan Bean secara programatis
@org.springframework.context.annotation.Configuration
public class AppConfig {
// @Bean — daftarkan objek sebagai Spring Bean secara eksplisit
// Berguna untuk library pihak ketiga yang tidak bisa diberi anotasi @Component
@org.springframework.context.annotation.Bean
public com.fasterxml.jackson.databind.ObjectMapper objectMapper() {
return new com.fasterxml.jackson.databind.ObjectMapper()
.findAndRegisterModules();
}
}
Konfigurasi dengan application.properties #
Semua konfigurasi aplikasi Spring Boot diatur melalui application.properties atau application.yml. Spring Boot menyediakan ratusan property bawaan, dan kamu bisa mendefinisikan property kustom sendiri.
# ===== Server =====
server.port=8080
server.servlet.context-path=/api
# ===== Database =====
spring.datasource.url=jdbc:postgresql://localhost:5432/tokoonline
spring.datasource.username=postgres
spring.datasource.password=rahasia
spring.datasource.driver-class-name=org.postgresql.Driver
# Connection pool (HikariCP — default Spring Boot)
spring.datasource.hikari.maximum-pool-size=10
spring.datasource.hikari.minimum-idle=2
spring.datasource.hikari.connection-timeout=30000
# ===== JPA / Hibernate =====
spring.jpa.hibernate.ddl-auto=validate
# none → tidak ubah schema (production)
# validate → validasi schema cocok dengan entity (production-safe)
# update → update schema otomatis (development saja)
# create → buat ulang schema setiap startup (testing saja)
spring.jpa.show-sql=false
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
# ===== Logging =====
logging.level.root=INFO
logging.level.com.contoh.tokoonline=DEBUG
logging.level.org.hibernate.SQL=DEBUG
# ===== Actuator (monitoring) =====
management.endpoints.web.exposure.include=health,info,metrics
management.endpoint.health.show-details=when-authorized
Property Kustom dengan @ConfigurationProperties #
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
// Baca semua property dengan prefix "app" dari application.properties
@Component
@ConfigurationProperties(prefix = "app")
public class AppProperties {
private String nama;
private int batasHalamanDefault = 20;
private Upload upload = new Upload();
// getter dan setter wajib ada untuk binding
public String getNama() { return nama; }
public void setNama(String nama) { this.nama = nama; }
public int getBatasHalamanDefault() { return batasHalamanDefault; }
public void setBatasHalamanDefault(int batas) { this.batasHalamanDefault = batas; }
public Upload getUpload() { return upload; }
public void setUpload(Upload upload) { this.upload = upload; }
public static class Upload {
private String direktori = "/tmp/uploads";
private long ukuranMaks = 10 * 1024 * 1024; // 10 MB
public String getDirektori() { return direktori; }
public void setDirektori(String direktori) { this.direktori = direktori; }
public long getUkuranMaks() { return ukuranMaks; }
public void setUkuranMaks(long ukuranMaks) { this.ukuranMaks = ukuranMaks; }
}
}
# application.properties
app.nama=Toko Online Saya
app.batas-halaman-default=25
app.upload.direktori=/data/uploads
app.upload.ukuran-maks=20971520
Profiles #
Profile memungkinkan konfigurasi berbeda untuk environment yang berbeda tanpa mengubah kode:
# application.properties — konfigurasi yang berlaku untuk semua profile
spring.application.name=toko-online
# application-dev.properties — hanya aktif saat profile=dev
spring.datasource.url=jdbc:h2:mem:testdb
spring.jpa.hibernate.ddl-auto=create-drop
logging.level.com.contoh=DEBUG
# application-prod.properties — hanya aktif saat profile=prod
spring.datasource.url=jdbc:postgresql://prod-db:5432/tokoonline
spring.jpa.hibernate.ddl-auto=validate
logging.level.com.contoh=WARN
Aktifkan profile saat menjalankan aplikasi:
# Via environment variable (cara yang direkomendasikan di production)
export SPRING_PROFILES_ACTIVE=prod
java -jar toko-online.jar
# Via argumen JVM
java -jar toko-online.jar --spring.profiles.active=prod
# Via application.properties (untuk development)
spring.profiles.active=dev
// @Profile — Bean hanya dibuat jika profile tertentu aktif
@Service
@org.springframework.context.annotation.Profile("dev")
public class MockEmailService implements EmailService {
@Override
public void kirim(String ke, String subjek, String isi) {
System.out.println("[DEV] Simulasi email ke " + ke + ": " + subjek);
}
}
@Service
@org.springframework.context.annotation.Profile("prod")
public class SmtpEmailService implements EmailService {
@Override
public void kirim(String ke, String subjek, String isi) {
// implementasi SMTP sungguhan
}
}
REST API dengan Spring MVC #
Spring MVC menyediakan anotasi lengkap untuk membangun REST API. Berikut contoh controller CRUD yang representatif untuk entitas Produk.
Entity dan DTO #
import jakarta.persistence.*;
import jakarta.validation.constraints.*;
// Entity — representasi tabel database
@Entity
@Table(name = "produk")
public class Produk {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank(message = "Nama produk tidak boleh kosong")
@Size(min = 3, max = 100, message = "Nama produk harus 3-100 karakter")
@Column(nullable = false, length = 100)
private String nama;
@NotNull(message = "Harga tidak boleh null")
@DecimalMin(value = "0.01", message = "Harga minimal Rp 0,01")
@Column(nullable = false, precision = 15, scale = 2)
private java.math.BigDecimal harga;
@Min(value = 0, message = "Stok tidak boleh negatif")
@Column(nullable = false)
private int stok;
@Column(name = "dibuat_pada", updatable = false)
@org.springframework.data.annotation.CreatedDate
private java.time.LocalDateTime dibuatPada;
// getter dan setter
public Long getId() { return id; }
public String getNama() { return nama; }
public void setNama(String nama) { this.nama = nama; }
public java.math.BigDecimal getHarga() { return harga; }
public void setHarga(java.math.BigDecimal harga) { this.harga = harga; }
public int getStok() { return stok; }
public void setStok(int stok) { this.stok = stok; }
public java.time.LocalDateTime getDibuatPada() { return dibuatPada; }
}
// DTO — objek transfer data, terpisah dari entity
// Mencegah entity terkekspos langsung ke client
public record ProdukRequest(
@NotBlank String nama,
@NotNull @DecimalMin("0.01") java.math.BigDecimal harga,
@Min(0) int stok
) {}
public record ProdukResponse(
Long id,
String nama,
java.math.BigDecimal harga,
int stok,
java.time.LocalDateTime dibuatPada
) {
public static ProdukResponse dari(Produk produk) {
return new ProdukResponse(
produk.getId(),
produk.getNama(),
produk.getHarga(),
produk.getStok(),
produk.getDibuatPada()
);
}
}
Repository #
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;
@Repository
public interface ProdukRepository extends JpaRepository<Produk, Long> {
// Spring Data JPA otomatis membuat query dari nama method
List<Produk> findByNamaContainingIgnoreCase(String kata);
List<Produk> findByHargaBetween(BigDecimal min, BigDecimal max);
List<Produk> findByStokGreaterThan(int stokMinimal);
Optional<Produk> findByNamaIgnoreCase(String nama);
// JPQL query kustom
@Query("SELECT p FROM Produk p WHERE p.stok = 0")
List<Produk> findStokHabis();
// Native SQL query
@Query(value = "SELECT * FROM produk ORDER BY harga ASC LIMIT :limit",
nativeQuery = true)
List<Produk> findTermurah(@org.springframework.data.repository.query.Param("limit") int limit);
}
Service #
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@Transactional(readOnly = true) // semua method read-only secara default
public class ProdukService {
private final ProdukRepository produkRepository;
public ProdukService(ProdukRepository produkRepository) {
this.produkRepository = produkRepository;
}
public List<ProdukResponse> semuaProduk() {
return produkRepository.findAll().stream()
.map(ProdukResponse::dari)
.toList();
}
public ProdukResponse cariProduk(Long id) {
Produk produk = produkRepository.findById(id)
.orElseThrow(() -> new ProdukTidakDitemukanException(id));
return ProdukResponse.dari(produk);
}
@Transactional // override ke read-write untuk operasi tulis
public ProdukResponse buatProduk(ProdukRequest request) {
Produk produk = new Produk();
produk.setNama(request.nama());
produk.setHarga(request.harga());
produk.setStok(request.stok());
Produk tersimpan = produkRepository.save(produk);
return ProdukResponse.dari(tersimpan);
}
@Transactional
public ProdukResponse perbaruiProduk(Long id, ProdukRequest request) {
Produk produk = produkRepository.findById(id)
.orElseThrow(() -> new ProdukTidakDitemukanException(id));
produk.setNama(request.nama());
produk.setHarga(request.harga());
produk.setStok(request.stok());
return ProdukResponse.dari(produk); // JPA auto-commit saat transaction selesai
}
@Transactional
public void hapusProduk(Long id) {
if (!produkRepository.existsById(id)) {
throw new ProdukTidakDitemukanException(id);
}
produkRepository.deleteById(id);
}
}
Controller #
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
import java.util.List;
@RestController
@RequestMapping("/produk")
public class ProdukController {
private final ProdukService produkService;
public ProdukController(ProdukService produkService) {
this.produkService = produkService;
}
// GET /produk
@GetMapping
public ResponseEntity<List<ProdukResponse>> semuaProduk() {
return ResponseEntity.ok(produkService.semuaProduk());
}
// GET /produk/{id}
@GetMapping("/{id}")
public ResponseEntity<ProdukResponse> cariProduk(@PathVariable Long id) {
return ResponseEntity.ok(produkService.cariProduk(id));
}
// POST /produk
@PostMapping
public ResponseEntity<ProdukResponse> buatProduk(
@Valid @RequestBody ProdukRequest request) {
ProdukResponse dibuat = produkService.buatProduk(request);
return ResponseEntity.status(HttpStatus.CREATED).body(dibuat);
}
// PUT /produk/{id}
@PutMapping("/{id}")
public ResponseEntity<ProdukResponse> perbaruiProduk(
@PathVariable Long id,
@Valid @RequestBody ProdukRequest request) {
return ResponseEntity.ok(produkService.perbaruiProduk(id, request));
}
// DELETE /produk/{id}
@DeleteMapping("/{id}")
public ResponseEntity<Void> hapusProduk(@PathVariable Long id) {
produkService.hapusProduk(id);
return ResponseEntity.noContent().build();
}
// GET /produk/cari?kata=laptop
@GetMapping("/cari")
public ResponseEntity<List<ProdukResponse>> cari(
@RequestParam String kata) {
List<Produk> produk = produkService.cariProdukByNama(kata);
return ResponseEntity.ok(produk.stream().map(ProdukResponse::dari).toList());
}
}
Exception Handling Global #
Menangani exception per controller sangat tidak efisien. Gunakan @ControllerAdvice untuk satu titik penanganan semua exception.
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.net.URI;
import java.time.Instant;
import java.util.Map;
import java.util.stream.Collectors;
// Custom exception
public class ProdukTidakDitemukanException extends RuntimeException {
public ProdukTidakDitemukanException(Long id) {
super("Produk dengan ID " + id + " tidak ditemukan");
}
}
@RestControllerAdvice
public class GlobalExceptionHandler {
// Tangani resource tidak ditemukan → 404
@ExceptionHandler(ProdukTidakDitemukanException.class)
public ProblemDetail handleProdukTidakDitemukan(ProdukTidakDitemukanException ex) {
ProblemDetail detail = ProblemDetail.forStatus(HttpStatus.NOT_FOUND);
detail.setTitle("Produk Tidak Ditemukan");
detail.setDetail(ex.getMessage());
detail.setProperty("timestamp", Instant.now());
return detail;
}
// Tangani validasi gagal → 400
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidationFailed(MethodArgumentNotValidException ex) {
Map<String, String> errors = ex.getBindingResult()
.getFieldErrors()
.stream()
.collect(Collectors.toMap(
org.springframework.validation.FieldError::getField,
fe -> fe.getDefaultMessage() != null ? fe.getDefaultMessage() : "tidak valid"
));
ProblemDetail detail = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
detail.setTitle("Validasi Gagal");
detail.setDetail("Satu atau lebih field tidak valid");
detail.setProperty("errors", errors);
detail.setProperty("timestamp", Instant.now());
return detail;
}
// Tangani semua exception yang tidak tertangani → 500
@ExceptionHandler(Exception.class)
public ProblemDetail handleGeneral(Exception ex) {
// Jangan expose detail error ke client di production
ProblemDetail detail = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR);
detail.setTitle("Terjadi Kesalahan");
detail.setDetail("Silakan coba lagi atau hubungi tim support");
detail.setProperty("timestamp", Instant.now());
return detail;
}
}
Format respons error menggunakan RFC 9457 Problem Details:
{
"type": "about:blank",
"title": "Validasi Gagal",
"status": 400,
"detail": "Satu atau lebih field tidak valid",
"errors": {
"nama": "Nama produk tidak boleh kosong",
"harga": "Harga minimal Rp 0,01"
},
"timestamp": "2024-05-10T08:30:00Z"
}
Lifecycle Bean dan ApplicationContext #
Kadang kamu perlu menjalankan kode tertentu saat aplikasi baru saja siap atau saat akan dimatikan.
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.event.EventListener;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
// Jalankan kode saat aplikasi siap (setelah semua bean terbuat)
@org.springframework.stereotype.Component
public class StartupRunner implements ApplicationRunner {
private final ProdukService produkService;
public StartupRunner(ProdukService produkService) {
this.produkService = produkService;
}
@Override
public void run(org.springframework.boot.ApplicationArguments args) throws Exception {
System.out.println("Aplikasi siap. Melakukan inisialisasi data...");
// Contoh: seed data awal jika database kosong
}
}
// @PostConstruct dan @PreDestroy — lifecycle di level Bean
@org.springframework.stereotype.Service
public class KoneksiService {
@PostConstruct
public void inisialisasi() {
// Dipanggil setelah bean terbuat dan semua dependency di-inject
System.out.println("KoneksiService: membuka koneksi...");
}
@PreDestroy
public void bersihkan() {
// Dipanggil sebelum bean dihancurkan (saat aplikasi shutdown)
System.out.println("KoneksiService: menutup koneksi...");
}
}
// @EventListener — dengarkan event Spring
@org.springframework.stereotype.Component
public class AplikasiListener {
@EventListener(ApplicationReadyEvent.class)
public void saaatSiap() {
System.out.println("Aplikasi telah sepenuhnya siap melayani request.");
}
}
Pengujian dengan Spring Boot Test #
Spring Boot menyediakan dukungan testing yang sangat lengkap — mulai dari unit test sederhana hingga integration test yang menjalankan server sungguhan.
Unit Test Service #
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.util.Optional;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class) // tidak butuh Spring context — murni unit test
class ProdukServiceTest {
@Mock
private ProdukRepository produkRepository;
@InjectMocks
private ProdukService produkService;
@Test
void cariProduk_ketikaDitemukan_kembalikanResponse() {
// Arrange
Produk produk = new Produk();
produk.setNama("Laptop");
produk.setHarga(new BigDecimal("15000000"));
produk.setStok(10);
when(produkRepository.findById(1L)).thenReturn(Optional.of(produk));
// Act
ProdukResponse response = produkService.cariProduk(1L);
// Assert
assertThat(response.nama()).isEqualTo("Laptop");
assertThat(response.harga()).isEqualByComparingTo("15000000");
verify(produkRepository, times(1)).findById(1L);
}
@Test
void cariProduk_ketikaIdTidakAda_lemparException() {
when(produkRepository.findById(99L)).thenReturn(Optional.empty());
assertThatThrownBy(() -> produkService.cariProduk(99L))
.isInstanceOf(ProdukTidakDitemukanException.class)
.hasMessageContaining("99");
}
}
Integration Test Controller #
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import java.math.BigDecimal;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
// @WebMvcTest — hanya load layer web, mock semua layer lain
// Lebih cepat dari @SpringBootTest karena tidak load full context
@WebMvcTest(ProdukController.class)
class ProdukControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private ProdukService produkService;
@Test
void semuaProduk_kembalikanListJson() throws Exception {
ProdukResponse laptop = new ProdukResponse(1L, "Laptop", new BigDecimal("15000000"), 5, null);
ProdukResponse mouse = new ProdukResponse(2L, "Mouse", new BigDecimal("250000"), 20, null);
when(produkService.semuaProduk()).thenReturn(List.of(laptop, mouse));
mockMvc.perform(get("/produk"))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.length()").value(2))
.andExpect(jsonPath("$[0].nama").value("Laptop"))
.andExpect(jsonPath("$[1].harga").value(250000));
}
@Test
void buatProduk_denganBodyTidakValid_kembalikan400() throws Exception {
String bodyTidakValid = """
{
"nama": "",
"harga": -100,
"stok": -5
}
""";
mockMvc.perform(post("/produk")
.contentType(MediaType.APPLICATION_JSON)
.content(bodyTidakValid))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.title").value("Validasi Gagal"));
}
}
Integration Test End-to-End #
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.ActiveProfiles;
// @SpringBootTest — jalankan server sungguhan di port acak
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test") // gunakan application-test.properties (H2 in-memory)
class ProdukIntegrationTest {
@Autowired
private TestRestTemplate restTemplate;
@Test
void buatDanAmbilProduk_endToEnd() {
// Buat produk
ProdukRequest request = new ProdukRequest("Keyboard", new java.math.BigDecimal("500000"), 15);
ResponseEntity<ProdukResponse> buatResponse =
restTemplate.postForEntity("/produk", request, ProdukResponse.class);
assertThat(buatResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
Long id = buatResponse.getBody().id();
// Ambil produk yang baru dibuat
ResponseEntity<ProdukResponse> getResponse =
restTemplate.getForEntity("/produk/" + id, ProdukResponse.class);
assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(getResponse.getBody().nama()).isEqualTo("Keyboard");
}
}
Kapan Menggunakan Spring Boot dan Kapan Tidak #
GUNAKAN SPRING BOOT JIKA:
✓ Membangun REST API atau layanan backend enterprise
✓ Butuh integrasi database dengan ORM (JPA/Hibernate)
✓ Tim sudah familiar dengan ekosistem Spring
✓ Butuh ekosistem lengkap: security, batch, messaging, dll
✓ Butuh testing infrastructure yang matang
✓ Aplikasi jangka panjang yang perlu maintainability tinggi
PERTIMBANGKAN ALTERNATIF JIKA:
✗ Aplikasi sangat sederhana — Spring overhead tidak sebanding
✗ Butuh startup time sangat cepat → Quarkus atau Micronaut
✗ Memory footprint sangat kritis (embedded/IoT) → framework lebih ringan
✗ Tim lebih familiar dengan framework lain → pilih yang dikuasai
✗ Butuh native compilation → Quarkus dengan GraalVM lebih matang
Ringkasan #
- Spring Boot = Spring Framework + auto-configuration + embedded server — kamu tidak perlu mengkonfigurasi Tomcat, Jackson, atau HikariCP secara manual; semuanya sudah punya default yang masuk akal.
- Selalu gunakan constructor injection, bukan field injection. Constructor injection memudahkan unit testing tanpa Spring context dan memaksa dependensi selalu tersedia.
@SpringBootApplicationadalah kombinasi@Configuration,@EnableAutoConfiguration, dan@ComponentScan— satu anotasi untuk mengaktifkan semua mekanisme Spring Boot.- Pisahkan Controller, Service, dan Repository — controller hanya routing dan validasi request, service untuk logika bisnis, repository untuk akses data. Ini menjaga kode tetap testable dan terpisah.
- Gunakan DTO (
record) alih-alih entity langsung di response API — mencegah data sensitif terkekspos dan memisahkan model database dari kontrak API.@ControllerAdvice+@ExceptionHandleradalah cara terpusat menangani semua exception — lebih bersih dari try-catch di setiap controller.- Gunakan Spring Profiles untuk memisahkan konfigurasi per environment —
application-dev.propertiesuntuk development,application-prod.propertiesuntuk production.@WebMvcTestuntuk test layer web tanpa load full context (cepat),@SpringBootTestuntuk integration test end-to-end yang membutuhkan semua komponen.spring.jpa.hibernate.ddl-auto=validatedi production — jangan pernah gunakancreateatauupdatedi production karena bisa mengubah schema database secara tidak terkontrol.