Tampilan
Kenapa penting
Saat kamu bergabung dengan tim backend, hal pertama yang kamu lakukan adalah membuka project dan mencari "kode untuk fitur X ada di mana?". Project yang ditata mengikuti konvensi bisa dipahami dalam hitungan menit: IDE, Maven, CI, dan rekan setim semuanya tahu di mana kode, test, dan konfigurasi berada. Project yang ditata asal-asalan membuat setiap perubahan kecil terasa seperti menjelajah labirin, dan batas antar-bagian kode mudah dilanggar. Bab ini membahas layout standar Maven, project multi-module, cara membagi package, konvensi penamaan, dan sekilas Gradle — fondasi yang nanti dipakai langsung oleh project Spring Boot.
Konsep inti
Layout standar Maven
Maven memakai prinsip convention over configuration: bila kamu menaruh file di tempat standar, tidak perlu konfigurasi apa pun.
text
toko-app/
├── pom.xml
├── mvnw, mvnw.cmd, .mvn/wrapper/ ← Maven Wrapper
├── src/
│ ├── main/
│ │ ├── java/ ← kode produksi (package id/toko/...)
│ │ └── resources/ ← konfigurasi, template, file SQL
│ └── test/
│ ├── java/ ← test (package sama dengan yang dites)
│ └── resources/ ← data/konfigurasi khusus test
└── target/ ← hasil build, jangan di-commitStruktur folder di bawah src/main/java mengikuti nama package: class id.toko.app.Main berada di src/main/java/id/toko/app/Main.java. Test diletakkan di package yang sama (src/test/java/id/toko/app/) agar bisa mengakses anggota package-private.
Resources disalin apa adanya ke target/classes lalu ikut dikemas ke .jar, sehingga berada di classpath. Bacalah lewat getResourceAsStream, jangan lewat path file — path src/main/resources/... tidak ada lagi setelah aplikasi dikemas:
java
package id.toko.app;
import id.toko.domain.Money;
import java.io.IOException;
import java.io.InputStream;
import java.util.Properties;
public class Main {
public static void main(String[] args) throws IOException {
Properties config = new Properties();
try (InputStream in = Main.class.getResourceAsStream("/application.properties")) {
if (in == null) {
throw new IllegalStateException("application.properties tidak ada di classpath");
}
config.load(in);
}
Money subtotal = new Money(85_000).plus(new Money(30_000));
System.out.println(config.getProperty("app.name") + " · subtotal " + subtotal.rupiah()
+ " · PPN " + config.getProperty("tax.percent") + "%");
}
}Dengan src/main/resources/application.properties berisi app.name=Toko Kopi dan tax.percent=11, menjalankan .jar hasil build mencetak:
text
Toko Kopi · subtotal 115000 · PPN 11%Garis miring di awal "/application.properties" berarti "dari akar classpath". Tanpa garis miring, path dicari relatif terhadap package class Main. Spring Boot nanti membaca application.properties dari lokasi yang sama.
Project multi-module: parent dan aggregator
Ketika project membesar, kamu bisa memecahnya menjadi beberapa modul Maven, misalnya toko-domain (aturan bisnis murni) dan toko-app (aplikasi yang memakainya). Ada dua peran yang sering digabung di satu POM ber-packagingpom:
| Peran | Elemen | Fungsi |
|---|---|---|
| Aggregator | <modules> di POM induk | membangun banyak modul dengan satu perintah |
| Parent | <parent> di POM anak | anak mewarisi properties, dependencyManagement, plugin |
xml
<!-- toko/pom.xml — parent sekaligus aggregator -->
<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>
<groupId>id.toko</groupId>
<artifactId>toko-parent</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>pom</packaging>
<modules>
<module>toko-app</module>
<module>toko-domain</module>
</modules>
<properties>
<maven.compiler.release>25</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>id.toko</groupId>
<artifactId>toko-domain</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
</project>xml
<!-- toko/toko-app/pom.xml -->
<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>
<parent>
<groupId>id.toko</groupId>
<artifactId>toko-parent</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>toko-app</artifactId>
<dependencies>
<dependency>
<groupId>id.toko</groupId>
<artifactId>toko-domain</artifactId>
</dependency>
</dependencies>
</project>Anak cukup menulis artifactId; groupId dan version diwarisi dari parent. Perhatikan bahwa <modules> menulis toko-app lebih dulu, tetapi output nyata ./mvnw package menunjukkan reactor mengurutkan ulang berdasarkan dependensi:
text
[INFO] Reactor Build Order:
[INFO] toko-parent [pom]
[INFO] toko-domain [jar]
[INFO] toko-app [jar]Untuk membangun satu modul saja, pakai -pl (projects list) bersama -am (also make): ./mvnw -pl toko-app -am verify. Tanpa -am, modul yang dibutuhkan dicari di ~/.m2 dan build gagal bila belum pernah di-install.
Folder exercises/ di Jalur Java memakai pola persis ini: exercises/pom.xml adalah exercises-parent ber-packagingpom dengan <modules> m01-fondasi-java, m02-oop, dan seterusnya. POM setiap modul latihan hanya berisi <parent> dan artifactId, karena versi Java, BOM JUnit, AssertJ, dan versi plugin diwarisi dari parent.
TIP
Jangan memecah modul terlalu dini. Satu modul dengan package yang tertata rapi sudah cukup untuk kebanyakan service. Pecah menjadi modul bila memang ada kebutuhan nyata: kode yang dipakai bersama beberapa aplikasi, atau batas yang ingin dipaksa oleh build (modul domain tidak boleh bergantung pada modul web).
Layering package: by layer vs by feature
Kode backend biasanya punya beberapa layer: controller (menerima request), service (logika bisnis), repository (akses data), dan domain (entity/record dan aturannya). Arah dependensinya satu arah: controller → service → repository, dan semuanya boleh memakai domain — tidak boleh sebaliknya. Ada dua cara menata package:
text
By layer By feature
id/tokokopi/ id/tokokopi/
├── controller/ ├── order/
│ ├── OrderController.java │ ├── OrderController.java
│ └── PaymentController.java │ ├── OrderService.java
├── service/ │ ├── OrderRepository.java
│ ├── OrderService.java │ └── Order.java
│ └── PaymentService.java ├── payment/
├── repository/ │ ├── PaymentController.java
│ ├── OrderRepository.java │ ├── PaymentService.java
│ └── PaymentRepository.java │ └── Payment.java
└── domain/ └── common/
├── Order.java └── Money.java
└── Payment.java| Aspek | By layer | By feature |
|---|---|---|
| Mengubah satu fitur | menyentuh 3–4 package | biasanya satu package |
| Menyembunyikan detail | sulit: semua harus public lintas package | mudah: package-private untuk detail fitur |
| Cocok untuk | project sangat kecil, tutorial | project yang tumbuh, tim lebih dari satu orang |
| Risiko | package service membengkak ratusan class | kode bersama perlu tempat jelas (common/shared) |
Untuk project yang akan tumbuh, by feature umumnya lebih tahan lama. Layer tetap ada — sebagai class dengan akhiran Controller, Service, Repository di dalam package fitur. Fitur yang sangat besar boleh dipecah lagi menjadi sub-package per layer.
Konvensi penamaan
| Hal | Konvensi | Contoh |
|---|---|---|
groupId / package | domain terbalik, huruf kecil semua, tanpa _ atau - | id.tokokopi.order |
artifactId | huruf kecil, kebab-case | order-service, toko-domain |
| Class | PascalCase, kata benda, akhiran sesuai peran | OrderService, OrderRepository |
| Exception | akhiran Exception | OrderNotFoundException |
| Test unit | nama class + Test | OrderServiceTest |
| Integration test | nama class + IT (dijalankan terpisah dari unit test) | OrderRepositoryIT |
| Konstanta | UPPER_SNAKE_CASE | MAX_ITEMS_PER_ORDER |
Akhiran Test penting: plugin surefire secara default menjalankan class yang namanya diawali Test atau diakhiri Test, Tests, atau TestCase. Test bernama OrderServiceCheck tidak akan dijalankan dan tidak ada yang sadar.
.gitignore project Java
Commit hanya sumber, bukan hasil build atau pengaturan pribadi IDE:
text
# Hasil build Maven
target/
# IDE
.idea/
*.iml
.vscode/
# OS & log
.DS_Store
*.log
# Konfigurasi lokal berisi secret
.envFile wrapper (mvnw, mvnw.cmd, .mvn/wrapper/maven-wrapper.properties) justru wajib di-commit.
Gradle sekilas
Gradle adalah build tool populer lain (dipakai di Android dan banyak project Spring). Konsepnya sama — koordinat GAV, repository Maven Central, dependency transitif — tetapi build ditulis sebagai skrip Kotlin atau Groovy, bukan XML. Padanan sebagian order-service di build.gradle.kts:
kotlin
// Potongan build.gradle.kts
plugins {
java
}
group = "id.tokokopi"
version = "0.3.0-SNAPSHOT"
java {
toolchain {
languageVersion = JavaLanguageVersion.of(25)
}
}
repositories {
mavenCentral()
}
dependencies {
implementation("tools.jackson.core:jackson-databind:3.2.4")
runtimeOnly("org.postgresql:postgresql:42.7.14")
testImplementation(platform("org.junit:junit-bom:6.1.3"))
testImplementation("org.junit.jupiter:junit-jupiter")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
tasks.test {
useJUnitPlatform()
}| Maven | Gradle |
|---|---|
scope compile | implementation |
scope runtime / test | runtimeOnly / testImplementation |
BOM <scope>import</scope> | platform(...) |
./mvnw verify | ./gradlew build |
folder target/ | folder build/ |
Layout src/main/java dan src/test/java sama persis. Jalur Java memakai Maven, tetapi kamu kini bisa membaca project Gradle tanpa bingung.
Contoh di dunia backend
Fitur pemesanan di toko kopi online, ditata by feature. Hanya OrderService, Order, OrderStatus, dan OrderNotFoundException yang public; repository adalah detail internal fitur order.
java
// src/main/java/id/tokokopi/order/Order.java
package id.tokokopi.order;
public record Order(String id, String customerEmail, long total, OrderStatus status) {
Order markPaid() {
if (status != OrderStatus.NEW) {
throw new IllegalStateException("Pesanan " + id + " berstatus " + status);
}
return new Order(id, customerEmail, total, OrderStatus.PAID);
}
}java
// src/main/java/id/tokokopi/order/OrderRepository.java
package id.tokokopi.order;
import java.util.Optional;
interface OrderRepository { // package-private: detail milik fitur order
Optional<Order> findById(String id);
void save(Order order);
}java
// src/main/java/id/tokokopi/order/OrderService.java
package id.tokokopi.order;
public class OrderService { // satu-satunya pintu masuk fitur order
private final OrderRepository repository;
OrderService(OrderRepository repository) {
this.repository = repository;
}
public static OrderService inMemory() {
return new OrderService(new InMemoryOrderRepository());
}
public Order place(String id, String customerEmail, long total) {
Order order = new Order(id, customerEmail, total, OrderStatus.NEW);
repository.save(order);
return order;
}
public Order pay(String id) {
Order paid = repository.findById(id)
.orElseThrow(() -> new OrderNotFoundException(id))
.markPaid();
repository.save(paid);
return paid;
}
}OrderStatus adalah enum NEW, PAID, SHIPPED; InMemoryOrderRepository adalah class package-private yang mengimplementasikan OrderRepository dengan HashMap; OrderNotFoundException meng-extend RuntimeException. Class id.tokokopi.App di luar fitur memakainya seperti ini:
java
// src/main/java/id/tokokopi/App.java
package id.tokokopi;
import id.tokokopi.order.OrderNotFoundException;
import id.tokokopi.order.OrderService;
public class App {
public static void main(String[] args) {
OrderService orders = OrderService.inMemory();
orders.place("ORD-001", "rina@example.com", 115_000);
System.out.println(orders.pay("ORD-001"));
try {
orders.pay("ORD-999");
} catch (OrderNotFoundException e) {
System.out.println(e.getMessage());
}
}
}Output:
text
Order[id=ORD-001, customerEmail=rina@example.com, total=115000, status=PAID]
Pesanan tidak ditemukan: ORD-999Bila fitur lain, misalnya id.tokokopi.catalog, mencoba meng-import InMemoryOrderRepository, compiler langsung menolak:
text
error: InMemoryOrderRepository is not public in id.tokokopi.order; cannot be accessed from outside packageBatas fitur dijaga oleh compiler, bukan sekadar kesepakatan. Konstruktor OrderService sengaja package-private: test di package yang sama (src/test/java/id/tokokopi/order/) bisa menyuntikkan repository palsu — teknik yang akan kamu pakai di bab JUnit dan Mockito. Nanti Spring Boot mengambil alih peran inMemory() dengan menyuntikkan dependency secara otomatis.
Pertanyaan interview
1. Apa beda parent POM dan aggregator POM?
Aggregator mendaftar modul di <modules> agar semuanya bisa dibangun dengan satu perintah. Parent adalah POM yang dirujuk anak lewat <parent> agar anak mewarisi properties, dependencyManagement, dan konfigurasi plugin. Keduanya sering digabung di satu POM ber-packaging pom, tetapi bisa dipisah — misalnya parent perusahaan yang dipakai banyak repository tanpa mendaftar modul mereka.
2. Kenapa banyak tim memilih package by feature dibanding by layer?
Perubahan pada satu fitur terkumpul di satu package, sehingga lebih mudah dipahami dan di-review. Detail implementasi (repository, mapper, helper) bisa dibuat package-private sehingga fitur lain tidak bisa bergantung padanya. By layer memaksa hampir semua class public dan membuat package service atau repository membengkak seiring waktu.
3. Kenapa membaca konfigurasi dengan getResourceAsStream, bukan new FileInputStream("src/main/resources/...")?
Setelah dikemas, resources berada di dalam .jar, bukan di folder src. Path file hanya kebetulan bekerja saat dijalankan dari IDE di folder project. getResourceAsStream membaca dari classpath, sehingga bekerja baik dari IDE, dari target/classes, maupun dari .jar di server.
Jebakan umum
- Folder tidak cocok dengan package.
package id.toko.app;harus berada disrc/main/java/id/toko/app/; bila tidak, IDE dan compiler bingung. - Membaca resources lewat path file. Bekerja di laptop, gagal setelah dikemas. Pakai
getResourceAsStream. -pltanpa-amdi project multi-module →Could not find artifactuntuk modul saudara.- Nama class test tidak berakhiran
Test→ surefire diam-diam tidak menjalankannya. - Arah dependensi terbalik. Repository yang memanggil service, atau domain yang meng-import class controller, membuat siklus dan menyulitkan test.
- Memecah modul terlalu dini. Banyak modul kecil menambah POM dan waktu build tanpa manfaat nyata.
Ringkasan
- Layout standar Maven:
src/main/java,src/main/resources,src/test/java,src/test/resources, hasil ditarget/; folder mengikuti nama package. - Resources masuk classpath dan dibaca dengan
getResourceAsStream. - Multi-module: aggregator (
<modules>) membangun banyak modul, parent (<parent>) mewariskan konfigurasi; reactor mengurutkan modul berdasarkan dependensi,-pl ... -ammembangun satu modul beserta kebutuhannya. - Package by feature memudahkan perubahan dan menyembunyikan detail lewat package-private; arah dependensi layer: controller → service → repository.
- Ikuti konvensi penamaan (akhiran
Service,Repository,Test,IT), commit wrapper, abaikantarget/. - Gradle memakai konsep dan layout yang sama dengan sintaks skrip;
implementation/runtimeOnly/platformsetara scope dan BOM Maven.