Gudang kantor menyimpan puluhan jenis barang — kertas, tinta, alat tulis — dan setiap hari barangnya berpindah masuk dan keluar. Mengelola arus itu lewat catatan manual cepat menemui batas: angka stok perlahan tidak selaras dengan isi rak, tidak jelas siapa yang mengubah, dan tidak ada jejak mengapa stok berubah. Bagian 1 memulai proyek aplikasi inventaris dengan merancang fondasi datanya: tabel barang yang menyimpan keadaan persediaan terkini dan tabel mutasi yang mencatat setiap pergerakan stok sebagai jurnal. Kedua tabel lalu diterjemahkan ke tipe Java — record Barang, record MutasiStok, enum JenisMutasi — dengan validasi yang menjaga data sahih sejak objek dibuat.
Dua Tabel: barang dan mutasi #
Rancangan inventaris yang sehat memisahkan dua jenis data yang berbeda sifat. Tabel barang menyimpan keadaan terkini: kapan pun ditanya, jawabannya satu angka stok dan satu angka harga. Tabel mutasi menyimpan kejadian: barang mana yang bergerak, berapa banyak, kapan, oleh petugas siapa, dengan catatan apa. Baris mutasi tidak pernah diubah atau dihapus setelah dicatat — jurnal bersifat append-only — sehingga kejadian masa lalu tidak bisa ditulis ulang diam-diam.
Pemisahan ini memberi jejak audit — “siapa mengeluarkan 25 rim kertas pada 8 Januari?” terjawab dari satu baris jurnal — sekaligus kemampuan verifikasi: stok selalu bisa dihitung ulang dari penjumlahan mutasi. Skema berikut memakai dialek SQLite. Lingkungan artikel ini tidak menyertakan driver JDBC, sehingga seluruh kode SQL pada proyek ini berstatus rancangan; padanan Java-nya yang dijalankan dan diuji sungguhan.
CREATE TABLE barang (
kode VARCHAR(16) PRIMARY KEY,
nama VARCHAR(100) NOT NULL,
satuan VARCHAR(16) NOT NULL,
harga DECIMAL(12,2) NOT NULL CHECK (harga >= 0),
stok INT NOT NULL CHECK (stok >= 0),
stok_minimum INT NOT NULL CHECK (stok_minimum >= 0)
);
CREATE TABLE mutasi (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kode_barang VARCHAR(16) NOT NULL REFERENCES barang (kode),
jenis VARCHAR(6) NOT NULL CHECK (jenis IN ('MASUK', 'KELUAR')),
jumlah INT NOT NULL CHECK (jumlah > 0),
tanggal DATE NOT NULL,
petugas VARCHAR(60) NOT NULL,
catatan VARCHAR(160) NOT NULL DEFAULT ''
);Kolom kode menjadi PRIMARY KEY sehingga satu barang hanya boleh terdaftar sekali. REFERENCES barang (kode) membuat mutasi hanya bisa menunjuk barang yang benar-benar ada. CHECK menolak nilai mustahil seperti stok negatif, harga negatif, atau jumlah mutasi nol. Constraint adalah aturan bisnis yang ditegakkan mesin database itu sendiri — kode aplikasi tidak bisa melanggarnya sekalipun programnya keliru.
Padanan Java: Record dan Enum #
Record Java tidak memiliki constraint database, tetapi punya mekanisme setara: compact constructor. Seluruh pemeriksaan berikut berjalan pada setiap pembuatan objek, sehingga objek Barang dan MutasiStok yang tidak sahih tidak pernah sempat ada. Pemetaan kolom SQL ke field Java:
| Kolom SQL | Field record | Jaminan di Java |
|---|---|---|
kode VARCHAR(16) PRIMARY KEY | String kode | constructor menolak nilai kosong; keunikan dijaga lapis penyimpanan |
harga DECIMAL CHECK (harga >= 0) | BigDecimal harga | harga.signum() < 0 melempar IllegalArgumentException |
stok INT CHECK (stok >= 0) | int stok | validasi constructor yang sama |
jenis CHECK (jenis IN (...)) | JenisMutasi jenis | enum: hanya dua nilai yang mungkin |
id INTEGER AUTOINCREMENT | int id | penomoran diserahkan ke lapis penyimpanan |
Berikut model barangnya — harga memakai BigDecimal dari string agar nilai desimal tersimpan persis, bukan hasil pembulatan biner double:
import java.math.BigDecimal;
// Satu baris tabel "barang": keadaan persediaan terkini satu jenis barang.
public record Barang(
String kode,
String nama,
String satuan,
BigDecimal harga,
int stok,
int stokMinimum) {
public Barang {
if (kode == null || kode.isBlank()) {
throw new IllegalArgumentException("kode barang tidak boleh kosong");
}
if (nama == null || nama.isBlank()) {
throw new IllegalArgumentException("nama barang tidak boleh kosong");
}
if (satuan == null || satuan.isBlank()) {
throw new IllegalArgumentException("satuan tidak boleh kosong");
}
if (harga == null || harga.signum() < 0) {
throw new IllegalArgumentException("harga tidak boleh negatif");
}
if (stok < 0) {
throw new IllegalArgumentException("stok tidak boleh negatif");
}
if (stokMinimum < 0) {
throw new IllegalArgumentException("stok minimum tidak boleh negatif");
}
}
// toString eksplisit: data domain saja, tanpa alamat objek.
@Override
public String toString() {
return "Barang[kode=" + kode + ", nama=" + nama + ", satuan=" + satuan
+ ", harga=" + harga.toPlainString() + ", stok=" + stok
+ ", minimum=" + stokMinimum + "]";
}
}Arah mutasi menjadi enum dengan satu anggota data: tanda() mengembalikan +1 untuk MASUK dan -1 untuk KELUAR, sehingga penjumlahan jurnal cukup satu rumus:
// Arah mutasi stok: MASUK menambah persediaan, KELUAR mengurangi.
public enum JenisMutasi {
MASUK(1),
KELUAR(-1);
private final int tanda;
JenisMutasi(int tanda) {
this.tanda = tanda;
}
/** +1 untuk MASUK dan -1 untuk KELUAR, dipakai menghitung stok dari jurnal. */
public int tanda() {
return tanda;
}
}Satu baris jurnal direpresentasikan record berikut. Perhatikan normalisasi catatan pada compact constructor: nilai null dirapikan menjadi string kosong, meniru DEFAULT '' pada kolom SQL:
import java.time.LocalDate;
// Satu baris jurnal stok: sekali dicatat, tidak pernah diubah (append-only).
public record MutasiStok(
int id,
String kodeBarang,
JenisMutasi jenis,
int jumlah,
LocalDate tanggal,
String petugas,
String catatan) {
public MutasiStok {
if (id < 0) {
throw new IllegalArgumentException("id mutasi tidak boleh negatif");
}
if (kodeBarang == null || kodeBarang.isBlank()) {
throw new IllegalArgumentException("kode barang tidak boleh kosong");
}
if (jenis == null) {
throw new IllegalArgumentException("jenis mutasi tidak boleh null");
}
if (jumlah <= 0) {
throw new IllegalArgumentException("jumlah mutasi harus positif: " + jumlah);
}
if (tanggal == null) {
throw new IllegalArgumentException("tanggal tidak boleh null");
}
if (petugas == null || petugas.isBlank()) {
throw new IllegalArgumentException("petugas tidak boleh kosong");
}
if (catatan == null) {
catatan = ""; // catatan opsional: null dirapikan menjadi string kosong
}
}
@Override
public String toString() {
return "MutasiStok[id=" + id + ", kode=" + kodeBarang + ", " + jenis
+ " " + jumlah + ", tanggal=" + tanggal + ", petugas=" + petugas
+ ", catatan=" + catatan + "]";
}
}Stok Dihitung dari Jurnal #
Demo Bagian 1 membuktikan hubungan kedua tabel: stok pada Barang harus sama dengan penjumlahan seluruh mutasi untuk kode barang itu. Kertas HVS A4 diterima 50 rim, keluar 25, masuk lagi 30, keluar 15 — hasilnya 40 rim:
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
// Bagian 1: memodelkan barang dan jurnal mutasi, lalu menghitung stok dari jurnal.
public class DemoGudang {
public static void main(String[] args) {
Barang kertas = new Barang("BRG-001", "Kertas HVS A4", "RIM",
new BigDecimal("47500"), 40, 10);
Barang tinta = new Barang("BRG-002", "Tinta Printer Hitam", "BOTOL",
new BigDecimal("185000"), 8, 4);
System.out.println("barang 1: " + kertas);
System.out.println("barang 2: " + tinta);
// Jurnal mutasi BRG-001 sejak barang pertama kali diterima.
List<MutasiStok> jurnal = List.of(
new MutasiStok(1, "BRG-001", JenisMutasi.MASUK, 50,
LocalDate.of(2026, 1, 5), "Budi", "penerimaan awal"),
new MutasiStok(2, "BRG-001", JenisMutasi.KELUAR, 25,
LocalDate.of(2026, 1, 8), "Siti", "permintaan divisi keuangan"),
new MutasiStok(3, "BRG-001", JenisMutasi.MASUK, 30,
LocalDate.of(2026, 1, 15), "Budi", "pembelian rutin"),
new MutasiStok(4, "BRG-001", JenisMutasi.KELUAR, 15,
LocalDate.of(2026, 1, 20), "Dewi", "permintaan divisi pemasaran"));
for (MutasiStok mutasi : jurnal) {
System.out.println("mutasi : " + mutasi);
}
// Stok saat ini = penjumlahan seluruh jurnal: 50 - 25 + 30 - 15.
int stokDariJurnal = 0;
for (MutasiStok mutasi : jurnal) {
stokDariJurnal += mutasi.jenis().tanda() * mutasi.jumlah();
}
System.out.println("stok dari jurnal : " + stokDariJurnal);
System.out.println("stok pada barang : " + kertas.stok());
System.out.println("jurnal dan stok selaras: " + (stokDariJurnal == kertas.stok()));
// Validasi record menolak jumlah mutasi yang tidak positif.
try {
new MutasiStok(5, "BRG-001", JenisMutasi.KELUAR, -10,
LocalDate.of(2026, 1, 21), "Siti", "salah ketik");
} catch (IllegalArgumentException e) {
System.out.println("ditolak : " + e.getMessage());
}
}
}Output yang Dihasilkan:
barang 1: Barang[kode=BRG-001, nama=Kertas HVS A4, satuan=RIM, harga=47500, stok=40, minimum=10]
barang 2: Barang[kode=BRG-002, nama=Tinta Printer Hitam, satuan=BOTOL, harga=185000, stok=8, minimum=4]
mutasi : MutasiStok[id=1, kode=BRG-001, MASUK 50, tanggal=2026-01-05, petugas=Budi, catatan=penerimaan awal]
mutasi : MutasiStok[id=2, kode=BRG-001, KELUAR 25, tanggal=2026-01-08, petugas=Siti, catatan=permintaan divisi keuangan]
mutasi : MutasiStok[id=3, kode=BRG-001, MASUK 30, tanggal=2026-01-15, petugas=Budi, catatan=pembelian rutin]
mutasi : MutasiStok[id=4, kode=BRG-001, KELUAR 15, tanggal=2026-01-20, petugas=Dewi, catatan=permintaan divisi pemasaran]
stok dari jurnal : 40
stok pada barang : 40
jurnal dan stok selaras: true
ditolak : jumlah mutasi harus positif: -10Mari kita bedah bagian-bagian pentingnya:
- Pembuatan
Barang: harga dibuat dari stringnew BigDecimal("47500")sehingga tersimpan persis tanpa galat biner;toPlainString()mencetaknya tanpa notasi ilmiah. - Jurnal
List.of(...): empat mutasi diberi id manual 1 sampai 4 — di Bagian 2 penomoran diserahkan ke lapisan penyimpanan sebagai padananAUTOINCREMENT. - Loop penjumlahan: method
tanda()mengubah jenis menjadi +1 atau -1, sehingga MASUK menambah dan KELUAR mengurangi tanpa percabangan. - Baris
jurnal dan stok selaras: true: hasil hitung ulang 50 - 25 + 30 - 15 sama persis dengan fieldstokbernilai 40 — invarian yang diuji otomatis pada Bagian 4. - Blok
tryterakhir: mutasi dengan jumlah-10ditolak compact constructor dengan pesanjumlah mutasi harus positif: -10, setaraCHECK (jumlah > 0).
Model sudah berdiri, tetapi objek-objeknya masih dibuat dan dipegang manual di method main. Bagian 2 mengangkatnya menjadi aplikasi: layanan Gudang dengan aturan bisnis, exception domain untuk setiap pelanggaran, dan lapisan penyimpanan abstrak yang kelak bisa diganti database sungguhan.
Referensi #
- Oracle: Class Record (Java SE 25 API)
- Oracle: Class BigDecimal (Java SE 25 API)
- Oracle: Class LocalDate (Java SE 25 API)
- Oracle: Package java.sql Summary (Java SE 25 API)
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
Barang.java
Unduh berkasimport java.math.BigDecimal;
// Satu baris tabel "barang": keadaan persediaan terkini satu jenis barang.
public record Barang(
String kode,
String nama,
String satuan,
BigDecimal harga,
int stok,
int stokMinimum) {
public Barang {
if (kode == null || kode.isBlank()) {
throw new IllegalArgumentException("kode barang tidak boleh kosong");
}
if (nama == null || nama.isBlank()) {
throw new IllegalArgumentException("nama barang tidak boleh kosong");
}
if (satuan == null || satuan.isBlank()) {
throw new IllegalArgumentException("satuan tidak boleh kosong");
}
if (harga == null || harga.signum() < 0) {
throw new IllegalArgumentException("harga tidak boleh negatif");
}
if (stok < 0) {
throw new IllegalArgumentException("stok tidak boleh negatif");
}
if (stokMinimum < 0) {
throw new IllegalArgumentException("stok minimum tidak boleh negatif");
}
}
// toString eksplisit: data domain saja, tanpa alamat objek.
@Override
public String toString() {
return "Barang[kode=" + kode + ", nama=" + nama + ", satuan=" + satuan
+ ", harga=" + harga.toPlainString() + ", stok=" + stok
+ ", minimum=" + stokMinimum + "]";
}
}
DemoGudang.java
Unduh berkasimport java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
// Bagian 1: memodelkan barang dan jurnal mutasi, lalu menghitung stok dari jurnal.
public class DemoGudang {
public static void main(String[] args) {
Barang kertas = new Barang("BRG-001", "Kertas HVS A4", "RIM",
new BigDecimal("47500"), 40, 10);
Barang tinta = new Barang("BRG-002", "Tinta Printer Hitam", "BOTOL",
new BigDecimal("185000"), 8, 4);
System.out.println("barang 1: " + kertas);
System.out.println("barang 2: " + tinta);
// Jurnal mutasi BRG-001 sejak barang pertama kali diterima.
List<MutasiStok> jurnal = List.of(
new MutasiStok(1, "BRG-001", JenisMutasi.MASUK, 50,
LocalDate.of(2026, 1, 5), "Budi", "penerimaan awal"),
new MutasiStok(2, "BRG-001", JenisMutasi.KELUAR, 25,
LocalDate.of(2026, 1, 8), "Siti", "permintaan divisi keuangan"),
new MutasiStok(3, "BRG-001", JenisMutasi.MASUK, 30,
LocalDate.of(2026, 1, 15), "Budi", "pembelian rutin"),
new MutasiStok(4, "BRG-001", JenisMutasi.KELUAR, 15,
LocalDate.of(2026, 1, 20), "Dewi", "permintaan divisi pemasaran"));
for (MutasiStok mutasi : jurnal) {
System.out.println("mutasi : " + mutasi);
}
// Stok saat ini = penjumlahan seluruh jurnal: 50 - 25 + 30 - 15.
int stokDariJurnal = 0;
for (MutasiStok mutasi : jurnal) {
stokDariJurnal += mutasi.jenis().tanda() * mutasi.jumlah();
}
System.out.println("stok dari jurnal : " + stokDariJurnal);
System.out.println("stok pada barang : " + kertas.stok());
System.out.println("jurnal dan stok selaras: " + (stokDariJurnal == kertas.stok()));
// Validasi record menolak jumlah mutasi yang tidak positif.
try {
new MutasiStok(5, "BRG-001", JenisMutasi.KELUAR, -10,
LocalDate.of(2026, 1, 21), "Siti", "salah ketik");
} catch (IllegalArgumentException e) {
System.out.println("ditolak : " + e.getMessage());
}
}
}
JenisMutasi.java
Unduh berkas// Arah mutasi stok: MASUK menambah persediaan, KELUAR mengurangi.
public enum JenisMutasi {
MASUK(1),
KELUAR(-1);
private final int tanda;
JenisMutasi(int tanda) {
this.tanda = tanda;
}
/** +1 untuk MASUK dan -1 untuk KELUAR, dipakai menghitung stok dari jurnal. */
public int tanda() {
return tanda;
}
}
MutasiStok.java
Unduh berkasimport java.time.LocalDate;
// Satu baris jurnal stok: sekali dicatat, tidak pernah diubah (append-only).
public record MutasiStok(
int id,
String kodeBarang,
JenisMutasi jenis,
int jumlah,
LocalDate tanggal,
String petugas,
String catatan) {
public MutasiStok {
if (id < 0) {
throw new IllegalArgumentException("id mutasi tidak boleh negatif");
}
if (kodeBarang == null || kodeBarang.isBlank()) {
throw new IllegalArgumentException("kode barang tidak boleh kosong");
}
if (jenis == null) {
throw new IllegalArgumentException("jenis mutasi tidak boleh null");
}
if (jumlah <= 0) {
throw new IllegalArgumentException("jumlah mutasi harus positif: " + jumlah);
}
if (tanggal == null) {
throw new IllegalArgumentException("tanggal tidak boleh null");
}
if (petugas == null || petugas.isBlank()) {
throw new IllegalArgumentException("petugas tidak boleh kosong");
}
if (catatan == null) {
catatan = ""; // catatan opsional: null dirapikan menjadi string kosong
}
}
@Override
public String toString() {
return "MutasiStok[id=" + id + ", kode=" + kodeBarang + ", " + jenis
+ " " + jumlah + ", tanggal=" + tanggal + ", petugas=" + petugas
+ ", catatan=" + catatan + "]";
}
}