Tampilan
Kenapa penting
"Tolong import 20.000 produk dari file Excel supplier" adalah tugas yang sangat sering mampir ke backend engineer — data awal saat migrasi sistem, update harga mingguan, sinkronisasi stok dari gudang. Tugas ini tampak sepele tetapi menguji banyak keterampilan sekaligus: membaca file dengan encoding yang benar, menolak data rusak tanpa menggagalkan semuanya, menyimpan ribuan baris secara efisien dan atomik, lalu melaporkan hasilnya dengan jelas. Proyek ini merangkai materi modul M06 — NIO, Jackson, SQL, desain tabel, dan JDBC — menjadi satu alur kerja nyata, lalu menambahkan satu alat yang dipakai hampir semua tim Java modern: Testcontainers.
Konsep inti
Gambaran proyek
Alur yang akan kamu bangun:
text
products.csv ──▶ CsvProductParser ──▶ ParsedFile (baris valid + RowError)
│
▼
ProductImportRepository ──▶ PostgreSQL (batch, 1 transaksi)
│
▼
ImportReport ──▶ import-report.json (Jackson)| Class | Tanggung jawab |
|---|---|
CsvProductParser | membaca file, memvalidasi baris, mengumpulkan error per baris |
ProductImportRepository | menyimpan baris valid dengan batch upsert dalam satu transaksi |
ImportService | merangkai parser dan repository, menyusun ImportReport |
ImportApp | titik masuk: menyiapkan DataSource, mencetak laporan JSON |
Struktur Maven yang disarankan (paket id.jalurjava.m06.importer):
text
src/main/java/id/jalurjava/m06/importer/ ← semua class di bawah
src/test/java/id/jalurjava/m06/importer/ ← ImportServiceIT
src/test/resources/schema.sql ← DDL tabel productDependency yang dibutuhkan, dengan versi diatur lewat BOM:
xml
<dependencyManagement>
<dependencies>
<dependency>
<groupId>tools.jackson</groupId>
<artifactId>jackson-bom</artifactId>
<version>3.2.4</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-bom</artifactId>
<version>2.0.5</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>tools.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
<version>7.1.0</version>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.14</version>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<!-- plus JUnit Jupiter dan AssertJ seperti di modul M05 -->
</dependencies>Driver PostgreSQL di sini sengaja berscope default (compile), karena test memakai PGSimpleDataSource milik driver secara langsung.
Langkah 1 — Tabel dan data contoh
Tabel product memakai constraint dari bab desain database: sku unik, harga dan stok tidak negatif. Constraint adalah garis pertahanan terakhir — validasi di Java memberi pesan yang ramah, database menjamin data tetap benar.
sql
CREATE TABLE IF NOT EXISTS product (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
sku VARCHAR(30) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
price NUMERIC(12,2) NOT NULL CHECK (price >= 0),
stock INT NOT NULL CHECK (stock >= 0)
);File CSV contoh sengaja berisi berbagai kerusakan: stok bukan angka, harga negatif, baris kosong, SKU duplikat, SKU berformat salah, dan kolom yang kurang.
text
sku,name,price,stock
KOPI-250,Kopi Arabika 250 g,85000,10
TEH-100,Teh Melati 100 g,18500.00,40
GULA-1KG,Gula Pasir 1 kg,17000,abc
SUSU-1L,Susu UHT 1 L,-2000,12
MADU-500,Madu Hutan 500 ml,99000.50,5
KOPI-250,Kopi Arabika 250 g (lagi),86000,3
roti tawar,Roti Tawar,15000,8
BERAS-5KG,Beras Premium 5 kg,78000Langkah 2 — Model data
Tiga record kecil membawa data antarlapisan. Record cocok karena datanya tidak berubah dan langsung bisa diserialisasi Jackson.
java
package id.jalurjava.m06.importer;
import java.math.BigDecimal;
public record ProductRow(String sku, String name, BigDecimal price, int stock) {}java
package id.jalurjava.m06.importer;
public record RowError(int line, String reason) {}java
package id.jalurjava.m06.importer;
import java.util.List;
public record ParsedFile(int totalRows, List<ProductRow> validRows, List<RowError> errors) {}Langkah 3 — Parser CSV dengan validasi per baris
Prinsip utamanya: satu baris rusak tidak boleh menggagalkan import. Setiap baris divalidasi di parseLine; pelanggaran apa pun dilempar sebagai IllegalArgumentException berpesan jelas, lalu loop di parse menangkapnya dan mencatat RowError beserta nomor barisnya — informasi yang dibutuhkan orang yang memperbaiki file.
java
package id.jalurjava.m06.importer;
import java.io.BufferedReader;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
public class CsvProductParser {
private static final String HEADER = "sku,name,price,stock";
private static final Pattern SKU = Pattern.compile("[A-Z0-9-]{3,30}");
public ParsedFile parse(Path file) throws IOException {
List<ProductRow> valid = new ArrayList<>();
List<RowError> errors = new ArrayList<>();
Map<String, Integer> firstLineBySku = new HashMap<>();
int totalRows = 0;
try (BufferedReader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) {
String header = reader.readLine();
if (header == null || !header.strip().equals(HEADER)) {
throw new IllegalArgumentException("Header harus '" + HEADER + "'");
}
String line;
int lineNo = 1;
while ((line = reader.readLine()) != null) {
lineNo++;
if (line.isBlank()) {
continue;
}
totalRows++;
try {
ProductRow row = parseLine(line);
Integer firstLine = firstLineBySku.putIfAbsent(row.sku(), lineNo);
if (firstLine != null) {
throw new IllegalArgumentException("SKU duplikat (pertama di baris " + firstLine + ")");
}
valid.add(row);
} catch (IllegalArgumentException e) {
errors.add(new RowError(lineNo, e.getMessage()));
}
}
}
return new ParsedFile(totalRows, valid, errors);
}
// Sengaja sederhana: tidak mendukung koma di dalam tanda kutip.
ProductRow parseLine(String line) {
String[] cols = line.split(",", -1);
if (cols.length != 4) {
throw new IllegalArgumentException("Harus 4 kolom, ditemukan " + cols.length);
}
String sku = cols[0].strip();
String name = cols[1].strip();
if (!SKU.matcher(sku).matches()) {
throw new IllegalArgumentException("SKU tidak valid: '" + sku + "'");
}
if (name.isEmpty()) {
throw new IllegalArgumentException("Nama wajib diisi");
}
BigDecimal price;
int stock;
try {
price = new BigDecimal(cols[2].strip());
stock = Integer.parseInt(cols[3].strip());
} catch (NumberFormatException e) {
throw new IllegalArgumentException("Harga/stok bukan angka");
}
if (price.signum() < 0 || price.scale() > 2) {
throw new IllegalArgumentException("Harga harus >= 0 dengan maks. 2 desimal");
}
if (stock < 0) {
throw new IllegalArgumentException("Stok tidak boleh negatif");
}
return new ProductRow(sku, name, price, stock);
}
}Hal yang perlu diperhatikan:
Files.newBufferedReader(file, StandardCharsets.UTF_8)membaca baris demi baris, jadi file 200 MB tidak dimuat sekaligus ke memori, dan encoding ditulis eksplisit.- Nomor baris dihitung termasuk header dan baris kosong, sehingga cocok dengan nomor baris di editor teks.
split(",", -1)mempertahankan kolom kosong di akhir; tanpa-1, barisA,B,,dianggap hanya punya 2 kolom.putIfAbsentsekaligus mendeteksi SKU duplikat di dalam file dan mengingat baris pertamanya.- Header yang salah adalah kesalahan seluruh file, bukan satu baris — maka langsung dilempar.
Batasan parser sederhana
split(",") tidak memahami nilai berkutip seperti "Kopi, Arabika". Untuk file dari pihak luar, pakai library CSV yang memahami format RFC 4180 (misalnya Apache Commons CSV atau modul CSV Jackson) — tetap dengan validasi per baris yang sama.
Langkah 4 — Simpan dengan batch dalam satu transaksi
Repository memakai teknik dari bab JDBC: PreparedStatement dengan batch, dikirim per 500 baris, dan semuanya dibungkus satu transaksi. ON CONFLICT (sku) DO UPDATE membuat import idempotent — menjalankan file yang sama dua kali tidak menggandakan data, hanya memperbarui harga dan stok.
java
package id.jalurjava.m06.importer;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.util.List;
import javax.sql.DataSource;
public class ProductImportRepository {
private static final int BATCH_SIZE = 500;
private static final String UPSERT = """
INSERT INTO product (sku, name, price, stock) VALUES (?, ?, ?, ?)
ON CONFLICT (sku) DO UPDATE
SET name = EXCLUDED.name, price = EXCLUDED.price, stock = EXCLUDED.stock""";
private final DataSource dataSource;
public ProductImportRepository(DataSource dataSource) {
this.dataSource = dataSource;
}
/** Menyimpan semua baris dalam satu transaksi: semua tersimpan atau tidak sama sekali. */
public int saveAll(List<ProductRow> rows) throws SQLException {
try (Connection conn = dataSource.getConnection()) {
conn.setAutoCommit(false);
try (PreparedStatement ps = conn.prepareStatement(UPSERT)) {
int pending = 0;
for (ProductRow row : rows) {
ps.setString(1, row.sku());
ps.setString(2, row.name());
ps.setBigDecimal(3, row.price());
ps.setInt(4, row.stock());
ps.addBatch();
if (++pending == BATCH_SIZE) {
ps.executeBatch();
pending = 0;
}
}
if (pending > 0) {
ps.executeBatch();
}
conn.commit();
return rows.size();
} catch (SQLException | RuntimeException e) {
conn.rollback();
throw e;
}
}
}
}Keputusan "satu transaksi untuk seluruh file" berarti bila database menolak satu baris (misalnya nama melebihi 100 karakter), tidak ada yang tersimpan. Itu pilihan yang aman untuk data master seperti katalog produk: lebih baik gagal total dengan pesan jelas daripada katalog yang setengah ter-update.
Langkah 5 — Service, laporan JSON, dan titik masuk
ImportService merangkai parser dan repository; ImportReport adalah hasil yang dikembalikan ke pemanggil.
java
package id.jalurjava.m06.importer;
import java.util.List;
public record ImportReport(String file, int totalRows, int imported, int rejected, List<RowError> errors) {}java
package id.jalurjava.m06.importer;
import java.io.IOException;
import java.nio.file.Path;
import java.sql.SQLException;
public class ImportService {
private final CsvProductParser parser;
private final ProductImportRepository repository;
public ImportService(CsvProductParser parser, ProductImportRepository repository) {
this.parser = parser;
this.repository = repository;
}
public ImportReport importFile(Path csv) throws IOException, SQLException {
ParsedFile parsed = parser.parse(csv);
int imported = repository.saveAll(parsed.validRows());
return new ImportReport(
csv.getFileName().toString(),
parsed.totalRows(),
imported,
parsed.errors().size(),
parsed.errors());
}
}ImportApp menyiapkan pool HikariCP kecil, menjalankan import, lalu menulis laporan JSON dengan Jackson 3 (JsonMapper dari paket tools.jackson). Exception Jackson 3 bersifat unchecked, jadi tidak perlu catch khusus.
java
package id.jalurjava.m06.importer;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import java.nio.file.Files;
import java.nio.file.Path;
import tools.jackson.databind.SerializationFeature;
import tools.jackson.databind.json.JsonMapper;
public class ImportApp {
public static void main(String[] args) throws Exception {
Path csv = Path.of(args.length > 0 ? args[0] : "products.csv");
HikariConfig config = new HikariConfig();
config.setJdbcUrl(System.getenv().getOrDefault("DB_URL", "jdbc:postgresql://localhost:5432/toko"));
config.setUsername(System.getenv().getOrDefault("DB_USER", "app"));
config.setPassword(System.getenv().getOrDefault("DB_PASSWORD", "rahasia"));
config.setMaximumPoolSize(2); // proses batch tunggal tidak butuh banyak koneksi
try (HikariDataSource dataSource = new HikariDataSource(config)) {
ImportService service = new ImportService(
new CsvProductParser(), new ProductImportRepository(dataSource));
ImportReport report = service.importFile(csv);
JsonMapper mapper = JsonMapper.builder().enable(SerializationFeature.INDENT_OUTPUT).build();
String json = mapper.writeValueAsString(report);
Files.writeString(Path.of("import-report.json"), json);
System.out.println(json);
}
}
}Setelah tabel dibuat dengan schema.sql, jalankan dengan file CSV di atas. Output nyata:
text
{
"file" : "products.csv",
"totalRows" : 8,
"imported" : 3,
"rejected" : 5,
"errors" : [ {
"line" : 4,
"reason" : "Harga/stok bukan angka"
}, {
"line" : 5,
"reason" : "Harga harus >= 0 dengan maks. 2 desimal"
}, {
"line" : 8,
"reason" : "SKU duplikat (pertama di baris 2)"
}, {
"line" : 9,
"reason" : "SKU tidak valid: 'roti tawar'"
}, {
"line" : 10,
"reason" : "Harus 4 kolom, ditemukan 3"
} ]
}Isi tabel setelah import:
text
sku | name | price | stock
----------+--------------------+----------+-------
KOPI-250 | Kopi Arabika 250 g | 85000.00 | 10
TEH-100 | Teh Melati 100 g | 18500.00 | 40
MADU-500 | Madu Hutan 500 ml | 99000.50 | 5
(3 rows)Dari 8 baris data, 3 tersimpan dan 5 ditolak dengan alasan yang bisa langsung ditindaklanjuti. Baris kosong tidak dihitung sebagai data.
Langkah 6 — Test integrasi dengan Testcontainers
Parser bisa dites dengan unit test biasa, tetapi repository hanya benar-benar teruji bila dijalankan ke PostgreSQL sungguhan — sintaks ON CONFLICT, constraint, dan perilaku transaksi tidak bisa dipalsukan dengan mock atau database in-memory yang berbeda dialek. Testcontainers menjalankan container Docker dari dalam test JUnit, memberi URL JDBC-nya, lalu membuangnya setelah test selesai.
java
// Potongan — belum dijalankan; butuh Docker
package id.jalurjava.m06.importer;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.postgresql.ds.PGSimpleDataSource;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.postgresql.PostgreSQLContainer;
@Testcontainers
class ImportServiceIT {
@Container
static PostgreSQLContainer postgres =
new PostgreSQLContainer("postgres:17-alpine").withInitScript("schema.sql");
PGSimpleDataSource dataSource;
ImportService service;
@BeforeEach
void setUp() throws SQLException {
dataSource = new PGSimpleDataSource();
dataSource.setURL(postgres.getJdbcUrl());
dataSource.setUser(postgres.getUsername());
dataSource.setPassword(postgres.getPassword());
try (Connection conn = dataSource.getConnection(); Statement st = conn.createStatement()) {
st.execute("TRUNCATE product RESTART IDENTITY"); // setiap test mulai dari tabel kosong
}
service = new ImportService(new CsvProductParser(), new ProductImportRepository(dataSource));
}
@Test
@DisplayName("baris valid tersimpan, baris rusak masuk laporan")
void importsValidRowsAndReportsBrokenOnes(@TempDir Path dir) throws Exception {
Path csv = dir.resolve("products.csv");
Files.writeString(csv, """
sku,name,price,stock
KOPI-250,Kopi Arabika 250 g,85000,10
TEH-100,Teh Melati 100 g,abc,40
""");
ImportReport report = service.importFile(csv);
assertThat(report.imported()).isEqualTo(1);
assertThat(report.errors()).extracting(RowError::line).containsExactly(3);
assertThat(countProducts()).isEqualTo(1);
}
@Test
@DisplayName("import ulang file yang sama tidak menggandakan data")
void reimportIsIdempotent(@TempDir Path dir) throws Exception {
Path csv = dir.resolve("products.csv");
Files.writeString(csv, "sku,name,price,stock\nKOPI-250,Kopi Arabika 250 g,85000,10\n");
service.importFile(csv);
service.importFile(csv);
assertThat(countProducts()).isEqualTo(1);
}
@Test
@DisplayName("satu baris ditolak database membatalkan seluruh import")
void rollsBackWholeImportOnDatabaseError() throws Exception {
var repository = new ProductImportRepository(dataSource);
List<ProductRow> rows = List.of(
new ProductRow("KOPI-250", "Kopi Arabika 250 g", new BigDecimal("85000"), 10),
new ProductRow("TEH-100", "x".repeat(101), new BigDecimal("18500"), 40));
assertThatThrownBy(() -> repository.saveAll(rows)).isInstanceOf(SQLException.class);
assertThat(countProducts()).isZero();
}
private int countProducts() throws SQLException {
try (Connection conn = dataSource.getConnection();
Statement st = conn.createStatement();
ResultSet rs = st.executeQuery("SELECT count(*) FROM product")) {
rs.next();
return rs.getInt(1);
}
}
}Cara kerjanya:
@Testcontainersmengaktifkan extension JUnit; fieldstaticber-@Containerdijalankan sekali sebelum semua test di class dan dihentikan setelahnya (field non-static akan dibuat ulang per test — jauh lebih lambat).withInitScript("schema.sql")menjalankan DDL darisrc/test/resourcessetelah container siap.getJdbcUrl()mengembalikan URL dengan port acak di host, jadi test tidak bentrok dengan PostgreSQL lokal di 5432.- Nama class berakhiran
ITadalah konvensi test integrasi; di Maven, plugin Failsafe menjalankannya pada faseintegration-test/verify, terpisah dari unit test Surefire yang cepat.
Kapan memakai Testcontainers
Pakai untuk kode yang berbicara dengan infrastruktur: repository, migrasi skema, consumer pesan. Logika murni seperti CsvProductParser cukup unit test biasa — lebih cepat dan tidak butuh Docker.
Contoh di dunia backend
Di tim sungguhan, alur yang sama biasanya dibungkus endpoint upload atau job terjadwal, dengan beberapa tambahan:
| Kebutuhan | Cara umum |
|---|---|
| File sangat besar (jutaan baris) | commit per potongan (misalnya per 5.000 baris) + catat posisi terakhir |
| Laporan untuk pengguna | simpan ImportReport ke tabel import_job agar bisa dilihat ulang |
| Import berjalan bersamaan | kunci per jenis import agar dua upload tidak saling menimpa |
| Kecepatan maksimum | perintah COPY PostgreSQL ke tabel staging, lalu validasi dengan SQL |
| Jejak audit | simpan nama file, pengunggah, waktu, dan jumlah baris sukses/gagal |
Pilihan "satu transaksi untuk semua" di proyek ini cocok untuk katalog berukuran ribuan baris. Begitu data mencapai jutaan baris, transaksi raksasa menahan lock dan memori terlalu lama — saat itulah strategi per potongan dan tabel staging dipakai. Mengetahui kapan berpindah strategi adalah bagian dari kematangan backend engineer.
Latihan kode
Versi latihan proyek ini ada di exercises/m06-io-database/src/main/java/id/jalurjava/m06/importer/. Mulai dari ProductImporter.java (tugas lengkap di Javadoc-nya), lalu isi CsvProductParser, ProductImportRepository, dan ProductImporter — di latihan, ImportService dari bab ini bernama ProductImporter. Record pendukung sudah jadi. Pastikan Docker berjalan, lalu dari folder exercises/:
bash
./mvnw -pl m06-io-database test -Dtest=ProductImporterTest # parser & laporan JSON, tanpa Docker
./mvnw -pl m06-io-database test -Dtest=ProductImportRepositoryTest # PostgreSQL via TestcontainersTabel product dibuat oleh test itu sendiri di container postgres:17-alpine, jadi tidak perlu schema.sql.
Pertanyaan interview
1. Bagaimana kamu merancang import file besar agar data rusak tidak merusak seluruh proses?
Validasi setiap baris secara terpisah dan kumpulkan error beserta nomor barisnya, bukan berhenti di error pertama. Baris valid disimpan dengan batch; keputusan transaksinya disesuaikan kebutuhan — satu transaksi untuk data master kecil, commit per potongan untuk data besar. Hasilnya dilaporkan dalam format terstruktur (misalnya JSON) agar pengirim file bisa memperbaiki dan mengirim ulang. Constraint database tetap dipasang sebagai jaminan terakhir.
2. Apa itu operasi idempotent, dan kenapa penting untuk import?
Operasi idempotent memberi hasil akhir yang sama walau dijalankan berkali-kali. Import sering diulang — job gagal di tengah, pengguna mengunggah ulang file yang sama. Dengan upsert (INSERT ... ON CONFLICT ... DO UPDATE) berdasarkan kunci bisnis seperti sku, pengulangan tidak menggandakan data.
3. Kenapa memakai Testcontainers, bukan database in-memory seperti H2, untuk test repository?
H2 punya dialek dan perilaku yang berbeda dari PostgreSQL: fitur seperti ON CONFLICT, tipe khusus, atau detail transaksi bisa lulus di H2 tetapi gagal di production, atau sebaliknya. Testcontainers menjalankan versi PostgreSQL yang sama dengan production, sehingga test membuktikan perilaku yang sebenarnya. Biayanya: butuh Docker dan test lebih lambat, maka dipakai untuk lapisan yang menyentuh infrastruktur saja.
Jebakan umum
- Berhenti di baris rusak pertama. Pengguna harus memperbaiki file berulang kali. Kumpulkan semua error.
executeUpdate()per baris dengan auto-commit. Ribuan round trip dan commit — sangat lambat. Pakai batch dalam transaksi.- Lupa encoding. File dari Excel kadang bukan UTF-8 atau diawali BOM; tentukan encoding secara eksplisit dan sepakati format dengan pengirim file.
- Import tidak idempotent. Tanpa upsert, upload ulang menggandakan data atau gagal di constraint
UNIQUE. @Containerpada field non-static tanpa alasan. Container dibuat ulang per test sehingga suite menjadi lambat.- Mengandalkan validasi Java saja. Tetap pasang constraint di tabel; kode lain bisa saja menulis ke tabel yang sama.
Ringkasan
- Baca CSV baris demi baris dengan
Files.newBufferedReaderdan encoding eksplisit; validasi per baris dan catatRowErrorbeserta nomor barisnya. - Simpan baris valid dengan batch
PreparedStatementdalam satu transaksi; upsertON CONFLICTmembuat import idempotent. - Pisahkan parser, repository, dan service agar masing-masing mudah dites.
- Laporkan hasil dalam JSON dengan Jackson 3 (
JsonMapper, pakettools.jackson). - Testcontainers menjalankan PostgreSQL sungguhan di Docker untuk test integrasi repository; logika murni cukup unit test.