Tampilan
Kenapa penting
Kode backend jarang ditulis sekali lalu ditinggal. Aturan diskon berubah tiap bulan, kurir baru ditambahkan, payment gateway diganti, dan tim bertambah. Desain yang buruk membuat setiap perubahan kecil menyentuh banyak file dan merusak fitur lain; desain yang baik membuat perubahan terkurung di satu tempat. SOLID adalah lima prinsip yang dirumuskan untuk menjaga desain OOP tetap lentur, dan istilah ini hampir pasti muncul di wawancara kerja backend. Spring Boot sendiri dibangun di atas beberapa prinsip ini — terutama dependency inversion — jadi memahaminya sekarang membuat framework itu terasa masuk akal nanti.
Konsep inti
S — Single Responsibility Principle
Sebuah class sebaiknya hanya punya satu alasan untuk berubah.
Bayangkan InvoiceService yang menghitung total, memformat teks, dan mengirim email sekaligus. Perubahan rumus pajak, perubahan tampilan, dan perubahan penyedia email semuanya menyentuh class yang sama — dan saling berisiko merusak. Pecah berdasarkan alasan berubahnya:
java
record InvoiceLine(String item, long unitPrice, int quantity) {}
class InvoiceCalculator { // alasan berubah: aturan harga & pajak
static final int TAX_PERCENT = 11;
long subtotal(InvoiceLine[] lines) {
long total = 0;
for (InvoiceLine line : lines) {
total += line.unitPrice() * line.quantity();
}
return total;
}
long tax(long subtotal) {
return subtotal * TAX_PERCENT / 100;
}
}
class InvoiceFormatter { // alasan berubah: tampilan invoice
String format(String invoiceId, long subtotal, long tax) {
return invoiceId + " | subtotal Rp" + subtotal + " | PPN Rp" + tax + " | total Rp" + (subtotal + tax);
}
}
interface InvoiceSender { // alasan berubah: cara mengirim
void send(String recipient, String content);
}
class ConsoleSender implements InvoiceSender {
public void send(String recipient, String content) {
System.out.println("kirim ke " + recipient + ": " + content);
}
}
class InvoiceService { // hanya mengoordinasikan
private final InvoiceCalculator calculator = new InvoiceCalculator();
private final InvoiceFormatter formatter = new InvoiceFormatter();
private final InvoiceSender sender;
InvoiceService(InvoiceSender sender) {
this.sender = sender;
}
void issue(String invoiceId, String email, InvoiceLine... lines) {
long subtotal = calculator.subtotal(lines);
String content = formatter.format(invoiceId, subtotal, calculator.tax(subtotal));
sender.send(email, content);
}
}
public class SrpDemo {
public static void main(String[] args) {
InvoiceService service = new InvoiceService(new ConsoleSender());
service.issue("INV-001", "budi@contoh.com",
new InvoiceLine("Kopi Gayo", 85_000, 2),
new InvoiceLine("Teh Melati", 40_000, 1));
}
}Output: kirim ke budi@contoh.com: INV-001 | subtotal Rp210000 | PPN Rp23100 | total Rp233100. Setiap class kecil kini mudah diuji sendiri, dan InvoiceService terbaca seperti alur bisnisnya.
O — Open/Closed Principle
Terbuka untuk perluasan, tertutup untuk modifikasi.
Menambah kurir baru seharusnya cukup dengan menulis kode baru, bukan mengedit switch raksasa yang sudah teruji. Polymorphism dari bab sebelumnya adalah alatnya:
java
interface ShippingRate {
String code();
long cost(int weightGrams);
}
class RegularRate implements ShippingRate {
public String code() { return "REG"; }
public long cost(int weightGrams) { return 10_000L * ((weightGrams + 999) / 1000); }
}
class ExpressRate implements ShippingRate {
public String code() { return "EXP"; }
public long cost(int weightGrams) { return 25_000L + 15_000L * ((weightGrams + 999) / 1000); }
}
// Kurir baru: cukup class baru, kode lain tidak diubah
class SameDayRate implements ShippingRate {
public String code() { return "SDD"; }
public long cost(int weightGrams) { return weightGrams <= 5_000 ? 40_000 : 40_000 + 8_000L * ((weightGrams - 5_000 + 999) / 1000); }
}
class ShippingQuote {
private final ShippingRate[] rates;
ShippingQuote(ShippingRate... rates) {
this.rates = rates;
}
void print(int weightGrams) {
for (ShippingRate rate : rates) {
System.out.println(rate.code() + " → Rp" + rate.cost(weightGrams));
}
}
}
public class OcpDemo {
public static void main(String[] args) {
new ShippingQuote(new RegularRate(), new ExpressRate(), new SameDayRate()).print(2_500);
}
}Output: REG → Rp30000, EXP → Rp70000, SDD → Rp40000. ShippingQuote tidak pernah tahu ada SameDayRate, dan tidak perlu diubah saat kurir itu ditambahkan.
L — Liskov Substitution Principle
Object subclass harus bisa menggantikan object induknya tanpa membuat pemanggil terkejut.
Contoh klasik: secara matematika persegi adalah persegi panjang, tetapi di kode belum tentu.
java
class Rectangle {
protected int width;
protected int height;
void setWidth(int width) { this.width = width; }
void setHeight(int height) { this.height = height; }
int area() { return width * height; }
}
class Square extends Rectangle { // "persegi adalah persegi panjang"... benarkah?
@Override void setWidth(int width) { this.width = width; this.height = width; }
@Override void setHeight(int height) { this.width = height; this.height = height; }
}
public class LspSquare {
static int resize(Rectangle r) {
r.setWidth(5);
r.setHeight(4);
return r.area(); // pemanggil berharap 5 × 4 = 20
}
public static void main(String[] args) {
System.out.println(resize(new Rectangle()));
System.out.println(resize(new Square()));
}
}Output: 20 lalu 16. Versi backend-nya lebih sering terjadi: subclass yang melempar UnsupportedOperationException untuk method yang dijanjikan induknya, atau yang diam-diam mengabaikan sebagian aturan. Pertanyaan pengujinya: "apakah semua kode yang memakai tipe induk tetap benar bila diberi subclass ini?" Bila tidak, pewarisannya salah.
I — Interface Segregation Principle
Jangan paksa class bergantung pada method yang tidak ia pakai.
Interface PaymentMethod yang mewajibkan refund() memaksa CashOnDelivery menulis method palsu — yang juga melanggar LSP. Pecah menjadi interface kecil yang fokus:
java
interface PaymentMethod {
String name();
boolean charge(long amount);
}
interface Refundable {
boolean refund(long amount);
}
class CreditCard implements PaymentMethod, Refundable {
public String name() { return "Kartu kredit"; }
public boolean charge(long amount) { return true; }
public boolean refund(long amount) { return true; }
}
class CashOnDelivery implements PaymentMethod { // tidak dipaksa punya refund()
public String name() { return "COD"; }
public boolean charge(long amount) { return true; }
}
public class IspDemo {
static String cancel(PaymentMethod method, long amount) {
if (method instanceof Refundable refundable && refundable.refund(amount)) {
return method.name() + ": dana dikembalikan otomatis";
}
return method.name() + ": tidak perlu refund, cukup batalkan pengiriman";
}
public static void main(String[] args) {
System.out.println(cancel(new CreditCard(), 150_000));
System.out.println(cancel(new CashOnDelivery(), 150_000));
}
}Output: Kartu kredit: dana dikembalikan otomatis lalu COD: tidak perlu refund, cukup batalkan pengiriman.
D — Dependency Inversion Principle
Kode tingkat tinggi (aturan bisnis) bergantung pada abstraksi, bukan pada detail seperti database atau HTTP.
OrderService tidak membuat repository sendiri; ia menerima interface lewat constructor (constructor injection). Implementasi asli, in-memory, atau palsu untuk test bisa dipasang tanpa mengubah service.
java
record Order(String id, long total) {}
interface OrderRepository { // abstraksi milik lapisan bisnis
void save(Order order);
int count();
}
class InMemoryOrderRepository implements OrderRepository {
private final Order[] orders = new Order[100];
private int size;
public void save(Order order) {
orders[size++] = order;
}
public int count() {
return size;
}
}
class OrderService {
private final OrderRepository repository;
OrderService(OrderRepository repository) { // constructor injection
this.repository = repository;
}
Order place(String id, long total) {
if (total <= 0) {
throw new IllegalArgumentException("Total harus positif");
}
Order order = new Order(id, total);
repository.save(order);
return order;
}
}
public class DipDemo {
public static void main(String[] args) {
OrderRepository repository = new InMemoryOrderRepository(); // di Spring: dibuatkan framework
OrderService service = new OrderService(repository);
service.place("INV-001", 210_000);
service.place("INV-002", 85_000);
System.out.println("Tersimpan: " + repository.count() + " pesanan");
}
}Output: Tersimpan: 2 pesanan. Di Spring Boot, baris "siapa membuat object apa" di main dikerjakan oleh framework (IoC container) — kamu cukup menulis constructor seperti OrderService di atas.
Coupling, cohesion, dan prinsip pendamping
- Coupling — seberapa erat sebuah class bergantung pada class lain. Targetnya rendah: bergantung pada interface kecil, bukan detail implementasi.
- Cohesion — seberapa erat isi sebuah class saling berkaitan. Targetnya tinggi: semua field dan method melayani satu tujuan.
- Composition over inheritance — susun perilaku dari object kecil (seperti
InvoiceServicedi atas) alih-alih hierarki class yang dalam. - DRY (Don't Repeat Yourself) — satu aturan bisnis ditulis di satu tempat. Tapi duplikasi kebetulan (dua kode mirip dengan alasan berubah berbeda) tidak harus digabung.
- KISS (Keep It Simple) dan YAGNI (You Aren't Gonna Need It) — jangan membangun abstraksi untuk kebutuhan yang belum ada.
Prinsip-prinsip ini adalah alat untuk mengelola perubahan, bukan aturan mutlak. Interface untuk setiap class, factory untuk setiap object, dan lima lapis abstraksi untuk fitur sederhana sama buruknya dengan god class — hanya dengan cara yang berbeda.
Code smell dan refactoring kecil
Code smell adalah tanda bahwa desain mungkin bermasalah:
| Smell | Gejala | Refactoring kecil |
|---|---|---|
| Long method | method puluhan baris yang mengerjakan banyak langkah | ekstrak method bernama jelas per langkah |
| God class | satu class XxxManager tahu dan mengerjakan segalanya | pecah per tanggung jawab (SRP) |
| Primitive obsession | uang, email, SKU berupa long/String mentah di mana-mana | value object/record dengan validasi (Money) |
| Feature envy | method lebih banyak memakai data class lain | pindahkan method ke class pemilik data |
| Magic number | if (total > 500000) tanpa penjelasan | konstanta bernama FREE_SHIPPING_THRESHOLD |
java
// Potongan: sebelum — magic number & primitive obsession
if (total > 500000 && email.contains("@")) { ... }
// Potongan: sesudah — nama yang menjelaskan maksud
if (total > FREE_SHIPPING_THRESHOLD && customer.email().isValid()) { ... }Refactoring dilakukan sedikit demi sedikit dengan test sebagai jaring pengaman — ubah struktur tanpa mengubah perilaku, jalankan test, ulangi.
Contoh di dunia backend
Semua prinsip di atas muncul bersamaan di sebuah endpoint checkout yang sehat:
text
CheckoutController → menerima request, memanggil service (SRP: hanya urusan HTTP)
CheckoutService → aturan bisnis; bergantung pada interface (DIP)
├─ PaymentGateway → interface kecil; implementasi Midtrans/Xendit/palsu (OCP, ISP)
├─ OrderRepository → interface; implementasi database/in-memory (DIP)
└─ DiscountRule[] → aturan diskon baru = class baru (OCP)Saat payment gateway diganti, hanya satu class implementasi yang berubah. Saat ada diskon baru, cukup tambah implementasi DiscountRule seperti di latihan bab Polymorphism. Saat menulis unit test CheckoutService, semua dependency diganti implementasi palsu — tanpa database dan tanpa jaringan. Struktur inilah yang akan kamu temui di hampir setiap project Spring Boot; latihan bab ini (OrderNotifier) melatih pola yang sama untuk notifikasi.
Pertanyaan interview
1. Jelaskan SOLID secara singkat.
S: satu class satu alasan untuk berubah. O: tambah perilaku lewat kode baru, bukan mengubah kode lama. L: subclass harus bisa menggantikan induknya tanpa merusak pemanggil. I: interface kecil yang fokus, jangan paksa implementasi method yang tidak dipakai. D: kode bisnis bergantung pada abstraksi; implementasi konkret disuntikkan dari luar.
2. Apa beda Dependency Inversion dan Dependency Injection?
Dependency Inversion adalah prinsip desain: modul tingkat tinggi dan detail sama-sama bergantung pada abstraksi. Dependency Injection adalah teknik untuk mewujudkannya: dependency diberikan dari luar (biasanya lewat constructor), bukan dibuat sendiri dengan new. Spring menyediakan container yang melakukan injection secara otomatis.
3. Bisakah prinsip SOLID diterapkan berlebihan?
Bisa. Abstraksi punya biaya: lebih banyak file, lebih sulit dilacak. Terapkan saat ada kebutuhan nyata — perubahan yang sering, lebih dari satu implementasi, atau kebutuhan testing — dan pertahankan kode tetap sederhana (KISS, YAGNI) di bagian lain.
Jebakan umum
- Menghafal singkatan tanpa bisa memberi contoh. Pewawancara biasanya meminta contoh nyata dari pekerjaan atau project-mu.
- Membuat interface untuk setiap class tanpa alasan. Interface berguna di batas yang bisa berubah (database, layanan eksternal, aturan bisnis yang bervariasi).
- Subclass yang melempar
UnsupportedOperationExceptionuntuk method induk — tanda pewarisan atau interface yang salah (LSP/ISP). - Membuat dependency sendiri dengan
newdi dalam service sehingga tidak bisa diganti saat test. - Refactoring besar tanpa test. Perubahan struktur tanpa jaring pengaman sering mengubah perilaku diam-diam.
Ringkasan
- SRP: satu alasan berubah per class; OCP: perluasan lewat kode baru; LSP: subclass aman menggantikan induk.
- ISP: interface kecil dan fokus; DIP: bergantung pada abstraksi dan terima dependency lewat constructor.
- Targetkan coupling rendah dan cohesion tinggi; utamakan composition.
- DRY, KISS, dan YAGNI menyeimbangkan SOLID agar tidak over-engineering.
- Kenali code smell (long method, god class, primitive obsession, feature envy, magic number) dan refactor bertahap dengan test.