Merancang Satuan Dan Harga


Seorang teman beberapa kali ini menghubungi saya untuk berdiskusi mengenai masalah yang dihadapinya. Ia sedang merancang sistem retail dimana ia harus mengelola produk yang bisa memiliki harga yang berbeda di satuan yang berbeda. Apa yang harus saya lakukan? Tidak ada jawaban ‘terbaik’ karena seberapa efektif sebuah rancangan tidak hanya mengenai seberapa jelas dan mudah dipahami, tetapi juga kebutuhan bisnis, teknologi dan pengalaman programmer yang dimiliki. Seperti biasa, saya akan mencoba membuat rancangan yang diimplementasikan dengan Groovy, Griffon dan simple-jpa.

Saya akan mulai dengan merancangan satuan. Sebuah nilai dalam satuan tertentu harus bisa di-konversi ke nilai dalam satuan lainnya. Sebagai contoh, 1 m adalah 100 cm, 1.000 mm, dan 39,3701 inch. Contoh lainnya, 1 lusin terdiri atas 12 unit dan 1 gross terdiri atas 144 unit. Tidak semua satuan bisa saling dikonversi. Sebagai contoh, satuan seperti meter tidak boleh dikonversi menjadi lusin.

Rancangan awal saya yang saya buat terlihat seperti pada gambar berikut ini:

Kuantitas, Satuan dan Konversi

Kuantitas, Satuan dan Konversi

Pada rancangan di atas, saya berusaha memisahkan tanggung jawab dan peran sebisa mungkin menjadi sebuah class tersendiri. Tujuannya adalah agar suatu saat nanti setelah class semakin berkembang, tidak ada class ‘gendut’ yang sulit dimodifikasi. Yang dimaksud ‘sulit’ disini adalah penuh kekhawatiran akan dampak buruk bila di-‘senggol’ sedikit. Secara umum, rancangan yang baik menghasilkan class yang bebannya ditanggung secara bersama dan merata.

Selain itu, saya tidak lupa menambahkan perilaku (behavior /method). Sebuah rancangan yang hanya berisi class beserta attribut tanpa method adalah gejala rancangan yang tidak baik yang disebut sebagai anemic domain model. Mengapa anemic domain model adalah gejala rancangan yang buruk? Sebuah aplikasi PASTI memiliki operasi. Bila rancangan hanya berisi data dalam bentuk atribut, maka dimana letaknya operasi tersebut? Kenapa operasi tersebut tidak ada dalam diagram? Dimana letaknya kode program seperti if, for, while yang mengolah data? Mereka tentunya harus punya ‘tempat’ di class yang sedang dirancang.

Salah satu prinsip yang dapat dipakai untuk mengukur loose coupling dari rancangan adalah Law of Demeter (LoD). Pada LoD, sebuah class hanya boleh berkomunikasi dengan class yang berhubungan langsung dengannya. Sebagai contoh, class Kuantitas akan memanggil method milik class Satuan, tapi tidak boleh mengakses Konversi milik Satuan tersebut secara langsung. Oleh sebab itu, class Satuan menyediakan wrapper yang mewakili nilai milik Konversi. Dengan demikian, bila suatu saat nanti class Konversi perlu berubah drastis, maka dampak terbesarnya hanya pada class Satuan karena class lain tidak mengakses Konversi secara langsung.

Seperti apa contoh implementasinya di Groovy? Class SatuanTidakCocok adalah sebuah Exception yang mewakili kesalahan sehingga saya bisa membuatnya seperti berikut ini:

import ...

@Canonical
class SatuanTidakCocok extends RuntimeException {

    Satuan dari
    Satuan ke

    @Override
    String getMessage() {
        "Tidak dapat melakukan konversi dari $dari ke $ke"
    }
}

Class Konversi adalah sebuah value object sehingga pada JPA, ia perlu memakai annotation @Embeddable seperti yang terlihat berikut ini:

import ...

@Embeddable @Canonical
class Konversi {

    @NotNull
    BigDecimal faktorSatuanBaku

    @ManyToOne
    Satuan satuanBaku

}

Class Satuan adalah sebuan entity yang implementasinya bisa berupa:

import ...

@DomainClass @Entity @Canonical
class Satuan {

    @NotEmpty
    String nama

    @Embedded
    Konversi konversi

    BigDecimal faktor(Satuan satuanLain) {
        if (satuanLain.equals(this)) {
            return 1
        } else if (satuanLain.equals(satuanBaku())) {
            return konversi.faktorSatuanBaku
        } else if (satuanLain.satuanBaku().equals(satuanBaku())) {
            return faktor(satuanBaku()) / satuanLain.faktor(satuanBaku())
        } else {
            throw new SatuanTidakCocok(satuanLain, this)
        }
    }

    Satuan satuanBaku() {
        konversi? konversi.satuanBaku: this
    }

    boolean equals(o) {
        if (this.is(o)) return true
        if (getClass() != o.class) return false

        Satuan satuan = (Satuan) o

        if (id != satuan.id) return false
        if (nama != satuan.nama) return false

        return true
    }

    int hashCode() {
        return nama.hashCode() + id
    }

}

Karena Satuan memiliki operasi yang cukup rumit, maka saya akan membuat sebuah unit test untuk menguji class tersebut, misalnya:

import ...

class SatuanTests {

    @Test
    void testSatuanBaku() {
        Satuan satuanM = new Satuan('Meter')
        Satuan satuanCM = new Satuan('Centimeter', new Konversi(1/100, satuanM))
        Satuan satuanMM = new Satuan('Milimeter', new Konversi(1/1000, satuanM))
        Satuan satuanInch = new Satuan('Inch', new Konversi(1/39.3701, satuanM))

        Satuan satuanPcs = new Satuan('Pcs')
        Satuan satuanLusin = new Satuan('Lusin', new Konversi(12, satuanPcs))
        Satuan satuanGross = new Satuan('Gross', new Konversi(144, satuanPcs))

        assertEquals(satuanM, satuanM.satuanBaku())
        assertEquals(satuanM, satuanCM.satuanBaku())
        assertEquals(satuanM, satuanMM.satuanBaku())
        assertEquals(satuanM, satuanInch.satuanBaku())

        assertEquals(satuanPcs, satuanPcs.satuanBaku())
        assertEquals(satuanPcs, satuanLusin.satuanBaku())
        assertEquals(satuanPcs, satuanGross.satuanBaku())
    }

    @Test
    public void testFaktorUntukSatuanSama() {
        Satuan satuanM = new Satuan('Meter')
        Satuan satuanCM = new Satuan('Centimeter', new Konversi(1/100, satuanM))

        assertEquals(0, satuanM.faktor(satuanM).compareTo(1))
        assertEquals(0, satuanCM.faktor(satuanCM).compareTo(1))
    }

    @Test
    public void testFaktor() {
        Satuan satuanM = new Satuan('Meter')
        Satuan satuanCM = new Satuan('Centimeter', new Konversi(1/100, satuanM))
        Satuan satuanMM = new Satuan('Milimeter', new Konversi(1/1000, satuanM))
        Satuan satuanInch = new Satuan('Inch', new Konversi(1/39.3701, satuanM))

        assertEquals('100,000', String.format("%.3f",satuanM.faktor(satuanCM)))
        assertEquals('0,010', String.format("%.3f", satuanCM.faktor(satuanM)))
        assertEquals('2,540', String.format("%.3f", satuanInch.faktor(satuanCM)))
        assertEquals('0,039', String.format("%.3f", satuanMM.faktor(satuanInch)))

        Satuan satuanPcs = new Satuan('Pcs')
        Satuan satuanLusin = new Satuan('Lusin', new Konversi(12, satuanPcs))
        Satuan satuanGross = new Satuan('Gross', new Konversi(144, satuanPcs))

        assertEquals(12, satuanLusin.faktor(satuanPcs).intValue())
        assertEquals(144, satuanGross.faktor(satuanPcs).intValue())
    }

    @Test
    public void testSatuanTidakCocok() {
        Satuan satuanM = new Satuan('Meter')
        Satuan satuanPcs = new Satuan('Pcs')

        GroovyAssert.shouldFail(SatuanTidakCocok) {
            satuanM.faktor(satuanPcs)
        }
    }
}

Pada pengujian diatas, saya memakai salah satu fasilitas unik di Groovy yaitu GroovyAssert.shouldFail(). Method tersebut akan menggagalkan pengujian bila seandainya Exception yang diharapkan tidak terjadi.

Saya kemudian menjalankan pengujian dan memastikan bahwa semua method di atas lulus, seperti yang terlihat pada gambar berikut ini:

Hasil unit test yang sukses

Hasil unit test yang sukses

Berikutnya, saya akan membuat implementasi untuk class Kuantitas seperti berikut ini:

import ...

@Embeddable @Canonical
class Kuantitas {

    @NotNull
    BigDecimal jumlah

    @ManyToOne @NotNull
    Satuan satuan

    Kuantitas to(Satuan satuanLain) {
        new Kuantitas(jumlah * satuan.faktor(satuanLain), satuanLain)
    }

    Kuantitas plus(Kuantitas kuantitas) {
        new Kuantitas(jumlah + kuantitas.to(satuan).jumlah, satuan)
    }

    Kuantitas minus(Kuantitas kuantitas) {
        plus(new Kuantitas(-kuantitas.jumlah, kuantitas.satuan))
    }

}

Pada kode program di atas, method minus() akan memanggil method plus() dengan nilai negatif karena pada dasarnya a-b adalah a+(-b). Saya juga bisa melakukannya untuk pembagian dimana a/b adalah a*(1/b).

Berikutnya, saya membuat unit test baru untuk menguji class Kuantitas seperti berikut ini:

import ...

class KuantitasTests {

    @Test
    void testTo() {
        Satuan satuanM = new Satuan('Meter')
        Satuan satuanCM = new Satuan('Centimeter', new Konversi(1/100, satuanM))
        Satuan satuanMM = new Satuan('Milimeter', new Konversi(1/1000, satuanM))
        Satuan satuanInch = new Satuan('Inch', new Konversi(1/39.3701, satuanM))

        Kuantitas nilai1 = new Kuantitas(10, satuanM)
        assertEquals(1000, nilai1.to(satuanCM).jumlah.intValue())
        assertEquals(10000, nilai1.to(satuanMM).jumlah.intValue())
        assertEquals('393,701', String.format('%.3f', nilai1.to(satuanInch).jumlah))

        Satuan satuanPcs = new Satuan('Pcs')
        Satuan satuanLusin = new Satuan('Lusin', new Konversi(12, satuanPcs))
        Satuan satuanGross = new Satuan('Gross', new Konversi(144, satuanPcs))

        Kuantitas nilai2 = new Kuantitas(15, satuanLusin)
        assertEquals(180, nilai2.to(satuanPcs).jumlah.intValue())
        assertEquals('1,250', String.format('%.3f', nilai2.to(satuanGross).jumlah))

        Kuantitas nilai3 = new Kuantitas(2, satuanGross)
        assertEquals(288, nilai3.to(satuanPcs).jumlah.intValue())
        assertEquals(24, nilai3.to(satuanLusin).jumlah.intValue())
    }

    @Test
    void testPlus() {
        Satuan satuanM = new Satuan('Meter')
        Satuan satuanCM = new Satuan('Centimeter', new Konversi(1/100, satuanM))
        Satuan satuanInch = new Satuan('Inch', new Konversi(1/39.3701, satuanM))

        Kuantitas nilai1 = new Kuantitas(5, satuanM)
        Kuantitas nilai2 = new Kuantitas(10, satuanCM)
        Kuantitas nilai3 = new Kuantitas(2, satuanInch)
        Kuantitas hasil = nilai1 + nilai2 + nilai3

        assertEquals('5,1508', String.format('%.4f', hasil.jumlah))
        assertEquals(satuanM, hasil.satuan)

        hasil += nilai1
        assertEquals('10,1508', String.format('%.4f', hasil.jumlah))
        assertEquals(satuanM, hasil.satuan)
    }

    @Test
    void testMinus() {
        Satuan satuanPcs = new Satuan('Pcs')
        Satuan satuanLusin = new Satuan('Lusin', new Konversi(12, satuanPcs))
        Satuan satuanGross = new Satuan('Gross', new Konversi(144, satuanPcs))

        Kuantitas nilai1 = new Kuantitas(2, satuanGross)
        nilai1 -= new Kuantitas(3, satuanLusin)
        nilai1 -= new Kuantitas(10, satuanPcs)

        assertEquals(242, nilai1.to(satuanPcs).jumlah.intValue())
        assertEquals(satuanGross, nilai1.satuan)
    }

    @Test
    void testSatuanTidakSama() {
        Satuan satuanPcs = new Satuan('Pcs')
        Satuan satuanM = new Satuan('Meter')

        GroovyAssert.shouldFail(SatuanTidakCocok) {
            new Kuantitas(2,satuanPcs) + new Kuantitas(3,satuanM)
        }
    }

}

Kode program pada unit test akan mewakili seperti apa class saat dipakai (misalnya saat dipanggil di controller atau dipanggil dari class lain). Pada contoh ini, saya memanfaatkan fasilitas khusus dari Groovy untuk melakukan operator overloading. Method dengan nama plus() milik Kuantitas akan dikerjakan bila terdapat ekspresi dengan tanda tambah seperti kuantitas1 + kuantitas2. Begitu juga dengan method minus() yang akan dikerjakan bila terdapat ekspresi seperti kuantitas1 - kuantitas2. Ini salah satu contoh dimana operator overloading sangat berguna: ia membuat class Kuantitas lebih mudah dipakai dan dipahami.

Berikutnya, saya perlu merancang class yang mewakili produk dan harganya. Setiap produk memiliki jumlah dalam satuan tertentu. Mereka juga bisa dijual dalam bentuk satuan yang berbeda dengan harga yang berbeda. Saya tidak tahu seperti apa pricing model yang dipakai karena saya tidak memiliki akses ke domain expert. Oleh sebab itu saya mencari informasi di buku atau web mengenai pricing model. Agar sederhana, saya akan menganggap klien ingin memakai model all-units discounts dimana bila pembelian mencapai kuantitas tertentu (atau pada satuan tertentu), maka harga seluruh unit akan mendapatkan diskon yang sama. Sebagai contoh, harga sebuah produk Snack A bila dibeli secara eceran adalah Rp 3.000,-/pcs. Bila dibeli secara lusin, maka harga Snack A menjadi Rp. 2.800,-/pcs sehingga harga 1 lusin adalah Rp 33.600,-/lusin (dimana tanpa diskon adalah Rp 36.000,-/lusin). Bila dibeli secara gross, maka harga Snack A menjadi Rp 2.600,-/pcs sehingga harga 1 gross adalah Rp Rp 374.400,-/gross (dimana tanpa diskon adalah Rp. 432.000,-/gross). Dengan kata lain, saya dapat menyimpulkan bahwa harga Snack A mendapat diskon Rp 200,- dari harga eceran bila dibeli per lusin dan mendapat diskon Rp 400,- dari harga eceran bila dibeli per gross.

Saya kemudian mengubah rancangan sehingga menjadi seperti berikut ini:

Hasil akhir setelah penambahan Produk, Diskon dan Harga

Hasil akhir setelah penambahan Produk, Diskon dan Harga

Pada rancangan di atas, saya membuat sebuah value object baru yang diwakili class Harga. Pada dasarnya Harga dan Kuantitas hampir sama; perbedaannya adalah mereka saling terbalik dalam melakukan konversi. Untuk Kuantitas, 2 lusin adalah 2 x 12= 24 pieces. Untuk Harga, Rp 12.000,-/lusin adalah 12.000 / 12 = Rp 1.000,-/pieces. Karena kesamaannya, hubungan antara Kuantitas dan Harga dapat berupa spesialisasi/inheritance. Akan tetapi, karena saya memakai Hibernate, saya menjumpai keterbatasan teknologi dimana sebuah class @Embeddable tidak dapat diturunkan dari class lain. Keputusan seperti ini perlu diperhatikan sehingga tidak menghasilkan rancangan yang tidak dapat diimplementasikan pada teknologi yang dipakai.

Saya memakai qualifier Satuan pada hubungan antara Produk dan Diskon untuk memperlihatkan bahwa setiap Produk memiliki Diskon per Satuan. Saya meniru teknik ini setelah membacanya di buku Domain Driven Design (DDD). Implementasi untuk rancangan tersebut dilakukan dengan menggunakan Map yang memiliki asosiasi keyvalue.

Mengapa ada method addDiskonByHargaSatuan(Harga) di Produk? Teman saya menjelaskan bahwa terkadang pengguna hanya ingin memasukkan harga per satuan seperti Rp 33.600,- per lusin (dimana harga per piece adalah Rp 3.000,-). Padahal, sistem harus menyimpannya dengan nilai berupa diskon Rp 200,- per piece. Ia kemudian mengemukakan idenya bahwa sebaiknya pengguna menghitung nilai diskon secara manual melalui kalkulator. Atau alternatif lainnya, biarkan programmer menghitung sendiri nilai ini di view. Tapi, sebuah solusi yang lebih baik adalah memasukkan proses perhitungan ini pada rancangan dalam bentuk sebuah method karena perhitungan ini adalah bagian dari business logic.

Contoh implementasi yang mungkin untuk class Diskon adalah:

import ...

@Embeddable @Canonical
class Diskon {

    BigDecimal potonganLangsung

    BigDecimal potonganPersen

    BigDecimal hasilDiskon(BigDecimal nilai) {
        nilai - jumlahDiskon(nilai)
    }

    BigDecimal jumlahDiskon(BigDecimal nilai) {
        (potonganPersen?:0) / 100 * nilai + (potonganLangsung?:0)
    }
}

Untuk menguji class ini, saya membuat unit test seperti:

import ...

class DiskonTests {

    @Test
    void testHasilDiskon() {
        Diskon diskon = new Diskon(1000)
        assertEquals(49000, diskon.hasilDiskon(50000).intValue())

        diskon = new Diskon(potonganPersen: 5)
        assertEquals(47500, diskon.hasilDiskon(50000).intValue())

        diskon = new Diskon(1000, 5)
        assertEquals(46500, diskon.hasilDiskon(50000).intValue())
    }

    @Test
    void testJumlahDiskon() {
        Diskon diskon = new Diskon(1000)
        assertEquals(1000, diskon.jumlahDiskon(50000).intValue())

        diskon = new Diskon(potonganPersen: 5)
        assertEquals(2500, diskon.jumlahDiskon(50000).intValue())

        diskon = new Diskon(1000, 5)
        assertEquals(3500, diskon.jumlahDiskon(50000).intValue())
    }
}

Setelah memastikan bahwa Diskon sudah bisa bekerja baik dan benar, saya lanjut membuat implementasi untuk Harga seperti yang terlihat berikut ini:

import ...

@Embeddable @Canonical
class Harga {

    @NotNull
    BigDecimal jumlah

    @NotNull @ManyToOne
    Satuan satuan

    public Harga to(Satuan satuanLain) {
        new Harga(jumlah / satuan.faktor(satuanLain), satuanLain)
    }

    public Harga plus(Harga hargaLain) {
        if (!hargaLain.satuan.equals(satuan)) {
            throw new SatuanTidakCocok(hargaLain.satuan, satuan)
        }
        new Harga(jumlah + hargaLain.to(satuan).jumlah, satuan)
    }

    public Harga minus(Harga hargaLain) {
        plus(new Harga(-hargaLain.jumlah, hargaLain.satuan))
    }

    public Harga multiply(Number angka) {
        new Harga(angka * jumlah, satuan)
    }

    public Harga multiply(Kuantitas kuantitas) {
        if (!kuantitas.satuan.equals(satuan)) {
            throw new SatuanTidakCocok(kuantitas.satuan, satuan)
        }
        multiply(kuantitas.jumlah)
    }

    public Harga diskon(Diskon diskon) {
        new Harga(diskon.hasilDiskon(jumlah), satuan)
    }

}

Class Harga memiliki cukup banyak operasi, sehingga saya perlu lebih rajin membuat unit test untuk menguji kebenarannya:

import ...

class HargaTests {

    @Test
    void testTo() {
        Satuan satuanM = new Satuan('Meter')
        Satuan satuanCM = new Satuan('Centimeter', new Konversi(1/100, satuanM))
        Satuan satuanMM = new Satuan('Milimeter', new Konversi(1/1000, satuanM))

        Harga harga = new Harga(1000, satuanM)
        assertEquals(1000, harga.to(satuanM).jumlah.intValue())
        assertEquals(10, harga.to(satuanCM).jumlah.intValue())
        assertEquals(1, harga.to(satuanMM).jumlah.intValue())

        harga = new Harga(2000, satuanCM)
        assertEquals(200000, harga.to(satuanM).jumlah.intValue())
        assertEquals(2000, harga.to(satuanCM).jumlah.intValue())
        assertEquals(200, harga.to(satuanMM).jumlah.intValue())
    }

    @Test
    void testPlus() {
        Satuan satuanM = new Satuan('Meter')
        Satuan satuanCM = new Satuan('Centimeter', new Konversi(1/100, satuanM))

        Harga harga1 = new Harga(1000, satuanM)
        Harga harga2 = new Harga(1100, satuanM)
        Harga harga3 = new Harga(2100, satuanM)
        assertEquals(harga3, harga1+harga2)

        GroovyAssert.shouldFail(SatuanTidakCocok) {
            harga1 + new Harga(1100, satuanCM)
        }
    }

    @Test
    void testMinus() {
        Satuan satuanM = new Satuan('Meter')
        Satuan satuanCM = new Satuan('Centimeter', new Konversi(1/100, satuanM))

        Harga harga1 = new Harga(1100, satuanM)
        Harga harga2 = new Harga(1000, satuanM)
        Harga harga3 = new Harga(100, satuanM)
        assertEquals(harga3, harga1-harga2)

        GroovyAssert.shouldFail(SatuanTidakCocok) {
            harga1 - new Harga(1100, satuanCM)
        }
    }

    @Test
    void testMultiplyNumber() {
        Satuan satuanM = new Satuan('Meter')

        Harga harga1 = new Harga(1000, satuanM)
        Harga harga2 = new Harga(5000, satuanM)
        assertEquals(harga2, harga1 * 5)

        harga2 = new Harga(3300, satuanM)
        assertEquals(harga2, harga1 * 3.3)
    }

    @Test
    void testMultiplyKuantitas() {
        Satuan satuanM = new Satuan('Meter')
        Satuan satuanCM = new Satuan('Centimeter', new Konversi(1/100, satuanM))

        Harga harga = new Harga(1000, satuanM)
        Kuantitas kuantitas = new Kuantitas(5, satuanM)
        Harga hasil = new Harga(5000, satuanM)
        assertEquals(hasil, harga * kuantitas)

        GroovyAssert.shouldFail(SatuanTidakCocok) {
            kuantitas = new Kuantitas(5, satuanCM)
            harga * kuantitas
        }
    }

    @Test
    void testDiskon() {
        Satuan satuanM = new Satuan('Meter')
        Harga harga = new Harga(1000, satuanM)

        Diskon diskon = new Diskon(100)
        Harga hasil = new Harga(900, satuanM)
        assertEquals(hasil, harga.diskon(diskon))

        diskon = new Diskon(potonganPersen: 1.5)
        hasil = new Harga(985, satuanM)
        assertEquals(hasil, harga.diskon(diskon))

        diskon = new Diskon(100, 3)
        hasil = new Harga(870, satuanM)
        assertEquals(hasil, harga.diskon(diskon))
    }
}

Berikutnya, saya membuat implementasi untuk class Produk yang terlihat seperti berikut ini:

import ...

@DomainClass @Entity @Canonical(excludes='diskon')
class Produk {

    @NotBlank
    String kode

    @NotBlank
    String nama

    @NotNull @Embedded
    Kuantitas jumlah

    @NotNull @Embedded
    Harga harga

    @ElementCollection(fetch=FecthType.EAGER)
    Map<Long, Diskon> diskon = [:]

    void setDiskon(Satuan satuan, Diskon jumlahDiskon) {
        diskon[satuan.id] = jumlahDiskon
    }

    Diskon getDiskon(Satuan satuan) {
        diskon[satuan.id]
    }

    void addDiskonByHargaSatuan(Harga hargaSatuan) {
        Satuan satuanBaku = harga.satuan.satuanBaku()
        Harga selisih = harga.to(satuanBaku) - hargaSatuan.to(satuanBaku)
        diskon[hargaSatuan.satuan.id] = new Diskon(selisih.jumlah)
    }

    Harga harga(Satuan satuan) {
        Satuan satuanBaku = harga.satuan.satuanBaku()
        Harga hargaSatuan = harga.to(satuanBaku)
        if (diskon[satuan.id]) {
            hargaSatuan = hargaSatuan.diskon(diskon[satuan.id])
        }
        hargaSatuan.to(satuan)
    }

}

Salah satu keterbatasan pada @Embeddable Map di JPA Hibernate saat ini adalah bila key berupa entity maka eager loading tidak dapat dilakukan! Ini adalah sebuah bug yang masih belum diselesaikan hingga saat ini. Karena saya ingin fasilitas eager loading, saya tidak bisa mendefinisikan Map. Sebagai gantinya, saya memakai Map dimana Long adalah id dari Satuan yang hendak dijadikan sebagai key. Untuk mempermudah mengakses map tersebut, saya menyediakan setDiskon(Satuan, Diskon) dan Diskon getDiskon(Satuan).

Hal lain yang harus diperhatikan bila memakai Hibernate dalam penggunaan @Embeddable adalah kemungkinan dua atribut di @Embeddable berbeda dengan nama yang sama. Hibernate akan gagal membuat tabel bila terdapat kolom dengan nama yang sama, seperti kolom satuan di Kuantitas dan kolom satuan di Harga dimana baik Kuantitas dan Harga dipakai oleh Produk. Untuk menghindari hal ini, saya menambahkan baris berikut ini pada persistence.xml:

...
<property name="hibernate.ejb.naming_strategy" value="org.hibernate.cfg.DefaultComponentSafeNamingStrategy" />
...

Memakai Hibernate rasanya ribet sekali, bukan? Tapi sejujurnya kompleksitas ini terlahir dari ketidaksesuaian antara OOP dan tabel relasional. Bila seandainya saya harus membuat sebuah framework ORM dari awal, saya bisa menciptakan sesuatu yang sangat sederhana untuk operasi CRUD. Namun seiring berbagai permasalahan yang muncul, pada akhirnya mungkin akan kompleks seperti Hibernate. Hal ini akibat masalah-masalah relasional yang memang tidak dapat dihindari. Jadi, untuk saat ini, lebih baik memakai sesuatu yang sudah matang seperti Hibernate.

Saya kemudian membuat unit test untuk menguji kebenaran class Produk:

import ...

class ProdukTests {

    @Test
    void testSetDiskonByHargaSatuan() {
        Satuan satuanPcs = new Satuan(id: 1, nama: 'Pcs')
        Satuan satuanLusin = new Satuan(id: 2, nama: 'Lusin', konversi: new Konversi(12, satuanPcs))
        Satuan satuanGross = new Satuan(id: 3, nama: 'Gross', konversi: new Konversi(144, satuanPcs))

        Produk produk = new Produk('SA', 'Snack A')
        produk.harga = new Harga(3000, satuanPcs)

        produk.addDiskonByHargaSatuan(new Harga(3000, satuanPcs))
        produk.addDiskonByHargaSatuan(new Harga(33600, satuanLusin))
        produk.addDiskonByHargaSatuan(new Harga(374400, satuanGross))

        assertEquals(0, produk.getDiskon(satuanPcs).potonganLangsung.intValue())
        assertEquals(200, produk.getDiskon(satuanLusin).potonganLangsung.intValue())
        assertEquals(400, produk.getDiskon(satuanGross).potonganLangsung.intValue())
    }

    @Test
    void testHarga() {
        Satuan satuanPcs = new Satuan(id: 1, nama: 'Pcs')
        Satuan satuanLusin = new Satuan(id: 2, nama: 'Lusin', konversi: new Konversi(12, satuanPcs))
        Satuan satuanGross = new Satuan(id: 3, nama: 'Gross', konversi: new Konversi(144, satuanPcs))

        Produk produk = new Produk('SA', 'Snack A')
        produk.harga = new Harga(3000, satuanPcs)
        produk.setDiskon(satuanLusin, new Diskon(200))
        produk.setDiskon(satuanGross, new Diskon(400))
        assertEquals(3000, produk.harga(satuanPcs).jumlah.intValue())
        assertEquals(satuanPcs, produk.harga(satuanPcs).satuan)
        assertEquals(33600, produk.harga(satuanLusin).jumlah.intValue())
        assertEquals(satuanLusin, produk.harga(satuanLusin).satuan)
        assertEquals(374400, produk.harga(satuanGross).jumlah.intValue())
        assertEquals(satuanGross, produk.harga(satuanGross).satuan)

        produk = new Produk('SB', 'Snack B')
        produk.harga = new Harga(150000, satuanLusin)
        produk.setDiskon(satuanGross, new Diskon(0, 0.5))
        assertEquals(12500, produk.harga(satuanPcs).jumlah.intValue())
        assertEquals(satuanPcs, produk.harga(satuanPcs).satuan)
        assertEquals(150000, produk.harga(satuanLusin).jumlah.intValue())
        assertEquals(satuanLusin, produk.harga(satuanLusin).satuan)
        assertEquals(1791000, produk.harga(satuanGross).jumlah.intValue())
        assertEquals(satuanGross, produk.harga(satuanGross).satuan)
    }
}

Salah satu konsekuensi dari perancangan class Produk seperti ini adalah ia terus mengingat diskon per satuan dasar walaupun harga dimodifikasi ke satuan lain. Sebagai contoh:

Produk produk = new Produk('SA', 'Snack A')
produk.harga = new Harga(3000, satuanPcs)
produk.addDiskonByHargaSatuan(new Harga(33600, satuanLusin))
assertEquals(33600, produk.harga(satuanLusin).jumlah.intValue())

// Perubahan harga dasar
produk.harga = new Harga(39000, satuanLusin)
assertEquals(3250, produk.harga(satuanPcs).jumlah.intValue())
assertEquals(36600, produk.harga(satuanLusin).jumlah.intValue())

Perilaku ini mungkin diharapkan dan mungkin tidak diharapkan tergantung pada kebutuhan pengguna. Bila diskon tidak perlu di-ingat, maka setiap kali harga baru di-set, nilai dari diskon perlu dikosongkan, seperti:

class Produk {

  ...

  void setHarga(Harga hargaBaru) {
     diskon.clear()
     this.harga = hargaBaru
  }

  ...

}

Lalu bagaimana dengan class ProdukRepository dan SatuanRepository? Mereka adalah repository yang bertugas menyimpan dan mencari entity Produk dan Satuan. Plugin simple-jpa telah menyediakan fasilitas tersebut sehingga saya tidak perlu membuat kode program yang mengerjakan SQL lagi. Contoh implementasinya terlihat seperti:

import ...

@Transaction
class ProdukRepository {

    public void create(Produk produk) {
        persist(produk)
    }

}
import ...

@Transaction
class SatuanRepository {

    public void create(Satuan satuan) {
        persist(satuan)
    }

}

Bagaimana cara menguji repository? Karena mereka mengambil dan menyimpan object dari dan ke database, maka pengujian sebaiknya dilakukan pada integration test. Perbedaan integration test dan unit test adalah unit test hanya melibatkan object yang terisolasi di memori (tanpa hubungan ke dunia luar seperti database, internet, dsb). Berbeda dengan unit test, pada integration test, saya harus mendefinisikan object secara lengkap karena kali ini validasi akan dilakukan pada saat menyimpan object ke database. Integration test juga lebih lambat dikerjakan bila dibandingkan dengan unit test.

Contoh integration test yang saya buat untuk menguji penyimpanan di database adalah:

import ...

class ProdukTest {

    @Test
    public void testCreate() {
        SatuanRepository satuanRepository = new SatuanRepository()
        Satuan satuanPcs = new Satuan('Pcs')
        Satuan satuanLusin = new Satuan('Lusin', new Konversi(12, satuanPcs))
        Satuan satuanGross = new Satuan('Gross', new Konversi(144, satuanPcs))
        satuanRepository.create(satuanPcs)
        satuanRepository.create(satuanLusin)
        satuanRepository.create(satuanGross)

        ProdukRepository produkRepository = new ProdukRepository()
        Produk produkA = new Produk(kode: 'SA', nama: 'Snack A', harga: new Harga(3000, satuanPcs), jumlah: new Kuantitas(10, satuanPcs))
        Produk produkB = new Produk(kode: 'SB', nama: 'Snack B', harga: new Harga(2000, satuanPcs), jumlah: new Kuantitas(10, satuanPcs))
        produkB.setDiskon(satuanLusin, new Diskon(200))
        produkRepository.create(produkA)
        produkRepository.create(produkB)

        Produk p = produkRepository.findProdukByNama('Snack A')
        assertEquals(produkA, p)
        assertEquals(36000, p.harga(satuanLusin).jumlah.intValue())

        p = produkRepository.findProdukByNama('Snack B')
        assertEquals(produkB, p)
        assertEquals(21600, p.harga(satuanLusin).jumlah.intValue())

        assertEquals(2, produkRepository.findAllProdukByNamaLike('Snack%').size())
    }
}

Bila saya menjalankan integration test di atas, Hibernate akan membuat struktur tabel yang terlihat seperti berikut ini:

Struktur tabel yang dihasilkan Hibernate

Struktur tabel yang dihasilkan Hibernate

Mereka yang terbiasa mengakses tabel secara langsung melalui SQL biasanya sering kali menganggap bahwa sebuah class adalah padanan dari sebuah tabel di database. Tapi tidak selalu demikian. Sebagai contoh, pada rancangan saya terdapat 9 class tetapi tabel yang dibutuhkan hanya 3 tabel (plus 1 tabel khusus untuk keperluan hibernate). Tidak semua dari class tersebut perlu disimpan nilainya dalam tabel. Beberapa bahkan bisa digabung, seperti class yang mengandung inheritance dan embeddable. Selain itu, perbedaan antara ERD dan class diagram terlihat secara jelas: tidak ada operasi di ERD sehingga saya tidak tahu bagaimana proses dan interaksi yang ada dalam program bila hanya melihat ERD!

Pada integration test di atas, tidak ada query SQL yang dilakukan karena repository memakai simple-jpa yang menyediakan dynamic finders. Lalu seperti ada SQL yang dihasilkan di balik layar? Berikut adalah cuplikan log yang saya peroleh:

insert into hibernate_sequences(sequence_name, sequence_next_hi_value) values('Satuan', ?)

update hibernate_sequences set sequence_next_hi_value = ? where sequence_next_hi_value = ? and sequence_name = 'Satuan'

insert into Satuan (createddate, deleted, konversi_faktorsatuanbaku, konversi_satuanbaku_id, modifieddate, nama, id) values (?, ?, ?, ?, ?, ?, ?)

insert into Satuan (createddate, deleted, konversi_faktorsatuanbaku, konversi_satuanbaku_id, modifieddate, nama, id) values (?, ?, ?, ?, ?, ?, ?)

insert into Satuan (createddate, deleted, konversi_faktorsatuanbaku, konversi_satuanbaku_id, modifieddate, nama, id) values (?, ?, ?, ?, ?, ?, ?)

select sequence_next_hi_value from hibernate_sequences where sequence_name = 'Produk' for update

insert into hibernate_sequences(sequence_name, sequence_next_hi_value) values('Produk', ?)

update hibernate_sequences set sequence_next_hi_value = ? where sequence_next_hi_value = ? and sequence_name = 'Produk'

insert into Produk (createddate, deleted, harga_jumlah, harga_satuan_id, jumlah_jumlah, jumlah_satuan_id, kode, modifieddate, nama, id) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)

insert into Produk (createddate, deleted, harga_jumlah, harga_satuan_id, jumlah_jumlah, jumlah_satuan_id, kode, modifieddate, nama, id) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)

insert into Produk_diskon (Produk_id, diskon_KEY, diskon_value_potonganlangsung, diskon_value_potonganpersen) values (?, ?, ?, ?)

select distinct produk0_.id as id1_4_, produk0_.createddate as createdd2_4_, produk0_.deleted as deleted3_4_, produk0_.harga_jumlah as harga_ju4_4_, produk0_.harga_satuan_id as harga_sa9_4_, produk0_.jumlah_jumlah as jumlah_j5_4_, produk0_.jumlah_satuan_id as jumlah_10_4_, produk0_.kode as kode6_4_, produk0_.modifieddate as modified7_4_, produk0_.nama as nama8_4_ from Produk produk0_ where produk0_.nama=?

select satuan0_.id as id1_6_0_, satuan0_.createddate as createdd2_6_0_, satuan0_.deleted as deleted3_6_0_, satuan0_.konversi_faktorsatuanbaku as konversi4_6_0_, satuan0_.konversi_satuanbaku_id as konversi7_6_0_, satuan0_.modifieddate as modified5_6_0_, satuan0_.nama as nama6_6_0_, satuan1_.id as id1_6_1_, satuan1_.createddate as createdd2_6_1_, satuan1_.deleted as deleted3_6_1_, satuan1_.konversi_faktorsatuanbaku as konversi4_6_1_, satuan1_.konversi_satuanbaku_id as konversi7_6_1_, satuan1_.modifieddate as modified5_6_1_, satuan1_.nama as nama6_6_1_ from Satuan satuan0_ left outer join Satuan satuan1_ on satuan0_.konversi_satuanbaku_id=satuan1_.id where satuan0_.id=?

select diskon0_.Produk_id as Produk_i1_4_0_, diskon0_.diskon_value_potonganlangsung as diskon_v2_5_0_, diskon0_.diskon_value_potonganpersen as diskon_v3_5_0_, diskon0_.diskon_KEY as diskon_K4_0_ from Produk_diskon diskon0_ where diskon0_.Produk_id=?

select distinct produk0_.id as id1_4_, produk0_.createddate as createdd2_4_, produk0_.deleted as deleted3_4_, produk0_.harga_jumlah as harga_ju4_4_, produk0_.harga_satuan_id as harga_sa9_4_, produk0_.jumlah_jumlah as jumlah_j5_4_, produk0_.jumlah_satuan_id as jumlah_10_4_, produk0_.kode as kode6_4_, produk0_.modifieddate as modified7_4_, produk0_.nama as nama8_4_ from Produk produk0_ where produk0_.nama=?

select satuan0_.id as id1_6_0_, satuan0_.createddate as createdd2_6_0_, satuan0_.deleted as deleted3_6_0_, satuan0_.konversi_faktorsatuanbaku as konversi4_6_0_, satuan0_.konversi_satuanbaku_id as konversi7_6_0_, satuan0_.modifieddate as modified5_6_0_, satuan0_.nama as nama6_6_0_, satuan1_.id as id1_6_1_, satuan1_.createddate as createdd2_6_1_, satuan1_.deleted as deleted3_6_1_, satuan1_.konversi_faktorsatuanbaku as konversi4_6_1_, satuan1_.konversi_satuanbaku_id as konversi7_6_1_, satuan1_.modifieddate as modified5_6_1_, satuan1_.nama as nama6_6_1_ from Satuan satuan0_ left outer join Satuan satuan1_ on satuan0_.konversi_satuanbaku_id=satuan1_.id where satuan0_.id=?

select diskon0_.Produk_id as Produk_i1_4_0_, diskon0_.diskon_value_potonganlangsung as diskon_v2_5_0_, diskon0_.diskon_value_potonganpersen as diskon_v3_5_0_, diskon0_.diskon_KEY as diskon_K4_0_ from Produk_diskon diskon0_ where diskon0_.Produk_id=?

select distinct produk0_.id as id1_4_, produk0_.createddate as createdd2_4_, produk0_.deleted as deleted3_4_, produk0_.harga_jumlah as harga_ju4_4_, produk0_.harga_satuan_id as harga_sa9_4_, produk0_.jumlah_jumlah as jumlah_j5_4_, produk0_.jumlah_satuan_id as jumlah_10_4_, produk0_.kode as kode6_4_, produk0_.modifieddate as modified7_4_, produk0_.nama as nama8_4_ from Produk produk0_ where produk0_.nama like ?

select satuan0_.id as id1_6_0_, satuan0_.createddate as createdd2_6_0_, satuan0_.deleted as deleted3_6_0_, satuan0_.konversi_faktorsatuanbaku as konversi4_6_0_, satuan0_.konversi_satuanbaku_id as konversi7_6_0_, satuan0_.modifieddate as modified5_6_0_, satuan0_.nama as nama6_6_0_, satuan1_.id as id1_6_1_, satuan1_.createddate as createdd2_6_1_, satuan1_.deleted as deleted3_6_1_, satuan1_.konversi_faktorsatuanbaku as konversi4_6_1_, satuan1_.konversi_satuanbaku_id as konversi7_6_1_, satuan1_.modifieddate as modified5_6_1_, satuan1_.nama as nama6_6_1_ from Satuan satuan0_ left outer join Satuan satuan1_ on satuan0_.konversi_satuanbaku_id=satuan1_.id where satuan0_.id=?

select diskon0_.Produk_id as Produk_i1_4_0_, diskon0_.diskon_value_potonganlangsung as diskon_v2_5_0_, diskon0_.diskon_value_potonganpersen as diskon_v3_5_0_, diskon0_.diskon_KEY as diskon_K4_0_ from Produk_diskon diskon0_ where diskon0_.Produk_id=?

select diskon0_.Produk_id as Produk_i1_4_0_, diskon0_.diskon_value_potonganlangsung as diskon_v2_5_0_, diskon0_.diskon_value_potonganpersen as diskon_v3_5_0_, diskon0_.diskon_KEY as diskon_K4_0_ from Produk_diskon diskon0_ where diskon0_.Produk_id=?

Terlihat bahwa terdapat permasalahan N+1 query dimana setelah mengerjakan query SELECT untuk memperoleh sebuah Produk dari tabel, Hibernate akan mengerjakan query terpisah untuk membaca Produk.Kuantitas.satuan dan masing-masing Produk.Diskon. Perilaku ini bisa dioptimalkan lagi dengan menggunakan left join atau dengan memberikan query secara manual, tapi selama tidak ada penalti yang siginifikan pada kinerja (seperti pada contoh di atas), ini adalah sesuatu yang bisa diterima.

Langkah berikutnya adalah membuat presentation untuk rancangan. Designer yang baik biasanya tidak merancang berdasarkan patokan presentation (tampilan). Oleh sebab itu, saya tidak membatasi jenis presentation yang bisa dipakai untuk rancangan ini. Selama merancang, saya hanya memikirkan bagaimana programmer nantinya memakai object yang ada. Bisa saja presentation berupa komponen Swing untuk aplikasi desktop atau tampilan HTML berbasis web. Atau bisa saja tidak ada presentation sama sekali, misalnya terdapat services layer yang mempublikasikan domain model dalam bentuk web services. Apapun jenis presentation yang dipakai, cara kerjanya tetap sama, controller di presentation akan memanggil repository untuk mengambil dan membuat root aggregate (seperti Produk dan Satuan), lalu mengerjakan business logic yang ada di domain object yang diperolehnya (seperti to(), addDiskon(), harga() dan sebagainya).

Perihal Solid Snake
I'm nothing...

Apa komentar Anda?

Please log in using one of these methods to post your comment:

Logo WordPress.com

You are commenting using your WordPress.com account. Logout / Ubah )

Gambar Twitter

You are commenting using your Twitter account. Logout / Ubah )

Foto Facebook

You are commenting using your Facebook account. Logout / Ubah )

Foto Google+

You are commenting using your Google+ account. Logout / Ubah )

Connecting to %s

%d blogger menyukai ini: