DOMAIN-DRIVEN-DESIGN DDD SOFTWARE-ARCHITECTURE DESIGN-PATTERNS CLEAN-ARCHITECTURE BACKEND-DEVELOPMENT MAINTAINABILITY BUSINESS-LOGIC CODE-QUALITY SYSTEM-DESIGN

Mengimplementasikan Tactical Domain-Driven Design: Membangun Blok Bangunan Aplikasi Berbasis Bisnis yang Kuat

⏱️ 16 menit baca
👨‍💻

Mengimplementasikan Tactical Domain-Driven Design: Membangun Blok Bangunan Aplikasi Berbasis Bisnis yang Kuat

1. Pendahuluan

Pernahkah Anda mengerjakan aplikasi dengan logika bisnis yang begitu kompleks hingga kode terasa seperti “spaghetti” yang sulit dipahami, diubah, apalagi diuji? Atau mungkin Anda sering menemukan data dalam kondisi inkonsisten karena aturan bisnis tidak diterapkan dengan benar di setiap sudut aplikasi?

Jika ya, Anda tidak sendirian. Mengelola kompleksitas logika bisnis adalah salah satu tantangan terbesar dalam pengembangan perangkat lunak. Di sinilah Domain-Driven Design (DDD) datang sebagai penyelamat. Anda mungkin sudah familiar dengan aspek Strategic DDD yang berfokus pada pemetaan domain bisnis ke dalam Bounded Context dan penggunaan Ubiquitous Language. Namun, bagaimana kita menerjemahkan pemahaman strategis itu ke dalam kode nyata?

Artikel ini akan membawa Anda menyelami Tactical Domain-Driven Design, yaitu serangkaian pola desain yang membantu kita mengimplementasikan model domain secara konkret dalam kode. Kita akan membahas blok bangunan utama seperti Entity, Value Object, dan Aggregate, serta bagaimana mereka bekerja sama untuk menciptakan aplikasi yang lebih bersih, konsisten, dan mudah dirawat. Tujuannya adalah memberikan panduan praktis agar Anda bisa mulai menerapkan prinsip-prinsip ini di proyek Anda sendiri.

Mari kita mulai membangun fondasi kode yang kuat untuk domain bisnis Anda!

2. Domain-Driven Design: Strategic vs. Tactical

Sebelum melangkah lebih jauh, mari kita sedikit menyegarkan ingatan tentang perbedaan antara Strategic dan Tactical DDD:

Dalam artikel ini, kita akan fokus pada bagian Tactical, yaitu bagaimana mengubah konsep bisnis menjadi struktur kode yang solid.

3. Entity: Identitas adalah Kunci

📌 Apa itu Entity? Dalam Tactical DDD, sebuah Entity adalah objek dalam domain Anda yang memiliki identitas unik yang persisten sepanjang waktu. Identitas inilah yang membedakan satu Entity dari Entity lain, terlepas dari atribut atau nilainya. Entity seringkali merepresentasikan hal-hal “hidup” atau berumur panjang dalam bisnis Anda.

Karakteristik Utama Entity:

Contoh Konkret:

💡 Mengapa Penting? Entity memungkinkan kita melacak objek bisnis yang sama meskipun keadaannya berubah. Bayangkan User yang mengubah alamat emailnya. Itu masih User yang sama, hanya atribut email yang berbeda. Identitas memastikan kita selalu merujuk pada User yang benar.

// Contoh implementasi Entity (TypeScript)
class User {
    private id: string;
    private name: string;
    private email: string;

    constructor(id: string, name: string, email: string) {
        if (!id || !name || !email) {
            throw new Error("User attributes cannot be empty.");
        }
        this.id = id;
        this.name = name;
        this.email = email;
    }

    getId(): string {
        return this.id;
    }

    getName(): string {
        return this.name;
    }

    getEmail(): string {
        return this.email;
    }

    // Metode untuk mengubah state Entity
    changeEmail(newEmail: string): void {
        if (!newEmail || !newEmail.includes('@')) {
            throw new Error("Invalid email format.");
        }
        this.email = newEmail;
        console.log(`Email for user ${this.id} changed to ${newEmail}`);
    }

    // Perbandingan berdasarkan identitas
    equals(other: User): boolean {
        return this.id === other.id;
    }
}

// Penggunaan
const user1 = new User("user-123", "Alice", "alice@example.com");
const user2 = new User("user-123", "Alice Smith", "alice.smith@example.com"); // Meskipun nama dan email beda, ID sama

console.log(user1.equals(user2)); // Output: true (karena ID sama)

user1.changeEmail("alice.new@example.com"); // State berubah, identitas tetap

Dalam contoh di atas, User adalah Entity karena memiliki id yang unik. Metode seperti changeEmail memodifikasi state internal User tanpa mengubah identitasnya.

4. Value Object: Data Tanpa Identitas

📌 Apa itu Value Object? Berbeda dengan Entity, Value Object adalah objek yang tidak memiliki identitas unik. Ia didefinisikan sepenuhnya oleh nilai-nilainya (atribut-atributnya). Dua Value Object dianggap sama jika semua nilainya sama. Mereka adalah deskripsi dari sesuatu.

Karakteristik Utama Value Object:

Contoh Konkret:

💡 Mengapa Penting? Value Object membantu kita:

  1. Meningkatkan Keterbacaan dan Ekspresivitas: Kode menjadi lebih jelas. Daripada menggunakan string untuk alamat, lebih baik Address.
  2. Menjaga Integritas Data: Karena immutable, Value Object secara inheren lebih aman dari perubahan tak terduga. Aturan validasi bisa langsung diterapkan saat pembuatan.
  3. Mengurangi Duplikasi Logika: Logika yang terkait dengan nilai (misalnya, validasi format email, penambahan uang) dienkapsulasi dalam Value Object itu sendiri.
// Contoh implementasi Value Object (TypeScript)
class Address {
    private street: string;
    private city: string;
    private zipCode: string;

    constructor(street: string, city: string, zipCode: string) {
        if (!street || !city || !zipCode) {
            throw new Error("Address attributes cannot be empty.");
        }
        // Lakukan validasi lebih lanjut di sini jika perlu
        this.street = street;
        this.city = city;
        this.zipCode = zipCode;
    }

    getStreet(): string { return this.street; }
    getCity(): string { return this.city; }
    getZipCode(): string { return this.zipCode; }

    // Perbandingan berdasarkan nilai
    equals(other: Address): boolean {
        return this.street === other.street &&
               this.city === other.city &&
               this.zipCode === other.zipCode;
    }

    // Karena immutable, jika perlu "mengubah", buat instance baru
    withNewStreet(newStreet: string): Address {
        return new Address(newStreet, this.city, this.zipCode);
    }
}

class Money {
    private amount: number;
    private currency: string;

    constructor(amount: number, currency: string) {
        if (amount < 0) {
            throw new Error("Amount cannot be negative.");
        }
        if (!currency) {
            throw new Error("Currency cannot be empty.");
        }
        this.amount = amount;
        this.currency = currency;
    }

    getAmount(): number { return this.amount; }
    getCurrency(): string { return this.currency; }

    equals(other: Money): boolean {
        return this.amount === other.amount && this.currency === other.currency;
    }

    add(other: Money): Money {
        if (this.currency !== other.currency) {
            throw new Error("Cannot add money with different currencies.");
        }
        return new Money(this.amount + other.amount, this.currency);
    }
}

// Penggunaan
const address1 = new Address("Jl. Contoh No. 1", "Jakarta", "12345");
const address2 = new Address("Jl. Contoh No. 1", "Jakarta", "12345");
const address3 = new Address("Jl. Lain No. 2", "Bandung", "54321");

console.log(address1.equals(address2)); // Output: true
console.log(address1.equals(address3)); // Output: false

const price = new Money(100, "USD");
const tax = new Money(10, "USD");
const total = price.add(tax);
console.log(total.getAmount(), total.getCurrency()); // Output: 110 USD

Dalam contoh di atas, Address dan Money adalah Value Object. Mereka tidak memiliki ID. Perbandingan dilakukan berdasarkan nilai-nilai internalnya, dan mereka bersifat immutable.

5. Aggregate: Menjaga Konsistensi Transaksional

🎯 Apa itu Aggregate? Aggregate adalah kluster Entity dan Value Object yang diperlakukan sebagai satu unit untuk tujuan perubahan data dan menjaga konsistensi transaksional. Setiap Aggregate memiliki satu Aggregate Root, yaitu Entity yang menjadi gerbang utama untuk semua operasi di dalam Aggregate tersebut.

Analogi yang bagus adalah benteng pertahanan. Aggregate Root adalah gerbang utama benteng. Anda tidak bisa masuk ke dalam benteng kecuali melalui gerbang. Semua Entity dan Value Object di dalam benteng adalah bagian dari benteng tersebut. Konsistensi semua yang ada di dalam benteng harus dijaga oleh gerbang utama.

Karakteristik Utama Aggregate:

Contoh Konkret:

⚠️ Perangkap Umum: Salah satu kesalahan terbesar adalah membuat Aggregate terlalu besar (terlalu banyak Entity dan Value Object di dalamnya), yang menyebabkan masalah performa dan konkurensi. Usahakan Aggregate sekecil mungkin, hanya mencakup hal-hal yang benar-benar perlu konsisten dalam satu transaksi.

// Contoh implementasi Aggregate (TypeScript)
class ProductItem { // Ini bisa jadi Value Object jika tidak perlu identitas unik di konteks ini
    constructor(public productId: string, public name: string, public price: Money, public quantity: number) {
        if (quantity <= 0) {
            throw new Error("Quantity must be positive.");
        }
    }

    getTotalPrice(): Money {
        return new Money(this.price.getAmount() * this.quantity, this.price.getCurrency());
    }
}

class Order { // Aggregate Root
    private id: string;
    private customerId: string;
    private items: ProductItem[];
    private status: 'PENDING' | 'PAID' | 'SHIPPED' | 'CANCELLED';
    private createdAt: Date;

    constructor(id: string, customerId: string, items: ProductItem[]) {
        if (!id || !customerId || items.length === 0) {
            throw new Error("Order cannot be created with empty details.");
        }
        this.id = id;
        this.customerId = customerId;
        this.items = items;
        this.status = 'PENDING';
        this.createdAt = new Date();
        this.ensureTotalAmountIsPositive(); // Menjaga invariant
    }

    getId(): string { return this.id; }
    getCustomerId(): string { return this.customerId; }
    getItems(): ProductItem[] { return [...this.items]; } // Return copy for immutability
    getStatus(): string { return this.status; }
    getCreatedAt(): Date { return this.createdAt; }

    getTotalAmount(): Money {
        const total = this.items.reduce((sum, item) => sum.add(item.getTotalPrice()), new Money(0, "USD"));
        this.ensureTotalAmountIsPositive(); // Pastikan invariant tetap terjaga setelah operasi
        return total;
    }

    addItem(item: ProductItem): void {
        this.items.push(item);
        this.ensureTotalAmountIsPositive();
    }

    markAsPaid(): void {
        if (this.status !== 'PENDING') {
            throw new Error("Order can only be marked as paid if it's pending.");
        }
        this.status = 'PAID';
        console.log(`Order ${this.id} marked as paid.`);
    }

    private ensureTotalAmountIsPositive(): void {
        if (this.getTotalAmount().getAmount() <= 0) {
            throw new Error("Order total amount must be positive.");
        }
    }
}

// Penggunaan
const productA = new ProductItem("prod-001", "Laptop", new Money(1200, "USD"), 1);
const productB = new ProductItem("prod-002", "Mouse", new Money(25, "USD"), 2);

const order = new Order("order-456", "cust-789", [productA, productB]);
console.log(order.getTotalAmount().getAmount()); // Output: 1250

order.markAsPaid(); // Mengubah state melalui Aggregate Root
// order.items[0].quantity = -1; // Ini tidak boleh dilakukan langsung dari luar!
// Kita harus mengubah melalui metode di Order Aggregate Root jika ada.

Di sini, Order adalah Aggregate Root. Semua operasi yang memengaruhi Order dan ProductItem di dalamnya harus melalui Order itu sendiri. Aturan bisnis seperti ensureTotalAmountIsPositive dienkapsulasi di dalam Order untuk menjaga konsistensinya.

6. Domain Services: Logika Bisnis yang Tidak Cocok di Entity/VO

💡 Kapan Menggunakan Domain Service? Kadang kala, ada logika bisnis yang secara alami tidak cocok ditempatkan di Entity atau Value Object manapun. Ini biasanya terjadi ketika logika tersebut:

Untuk kasus seperti ini, kita menggunakan Domain Service. Domain Service adalah stateless dan berfungsi sebagai orkestrator atau fasilitator untuk operasi domain yang kompleks.

Contoh Konkret:

Perangkap Umum: Jangan membuat Domain Service hanya karena Anda tidak tahu di mana harus meletakkan suatu fungsi. Prioritaskan menempatkan perilaku di Entity atau Value Object terlebih dahulu. Domain Service harus menjadi pilihan terakhir untuk logika domain.

// Contoh Domain Service (TypeScript - pseudo-code untuk Account Aggregate)
class Account { // Ini akan menjadi Aggregate Root
    private id: string;
    private balance: Money; // Money adalah Value Object

    constructor(id: string, initialBalance: Money) {
        this.id = id;
        this.balance = initialBalance;
    }

    getId(): string { return this.id; }
    getBalance(): Money { return this.balance; }

    deposit(amount: Money): void {
        this.balance = this.balance.add(amount);
    }

    withdraw(amount: Money): void {
        if (this.balance.getAmount() < amount.getAmount()) {
            throw new Error("Insufficient funds.");
        }
        this.balance = new Money(this.balance.getAmount() - amount.getAmount(), this.balance.getCurrency());
    }
}

interface AccountRepository {
    findById(id: string): Account | null;
    save(account: Account): void;
}

class TransferService {
    private accountRepository: AccountRepository;

    constructor(accountRepository: AccountRepository) {
        this.accountRepository = accountRepository;
    }

    transferFunds(fromAccountId: string, toAccountId: string, amount: Money): boolean {
        const fromAccount = this.accountRepository.findById(fromAccountId);
        const toAccount = this.accountRepository.findById(toAccountId);

        if (!fromAccount || !toAccount) {
            throw new Error("One or both accounts not found.");
        }
        if (fromAccount.getBalance().getCurrency() !== amount.getCurrency() ||
            toAccount.getBalance().getCurrency() !== amount.getCurrency()) {
            throw new Error("Currencies must match for transfer.");
        }

        fromAccount.withdraw(amount); // Logika di dalam Aggregate Root Account
        toAccount.deposit(amount);    // Logika di dalam Aggregate Root Account

        this.accountRepository.save(fromAccount); // Simpan perubahan pada kedua Aggregate
        this.accountRepository.save(toAccount);
        
        console.log(`Transferred ${amount.getAmount()} ${amount.getCurrency()} from ${fromAccountId} to ${toAccountId}`);
        return true;
    }
}

// Penggunaan (ilustrasi)
// const accountRepo = new MockAccountRepository(); // Implementasi repo dummy
// const transferService = new TransferService(accountRepo);
// transferService.transferFunds("acc-001", "acc-002", new Money(50, "USD"));

TransferService mengkoordinasikan interaksi antara dua Account Aggregate. Logika withdraw dan deposit tetap berada di dalam Account Entity (Aggregate Root), sementara TransferService hanya memanggil metode-metode tersebut dan mengelola persistensinya.

7. Repository: Jembatan Antara Domain dan Persistensi

Apa itu Repository? Repository adalah pola yang menyediakan antarmuka (interface) untuk mengakses dan menyimpan Aggregate Root. Repository bertindak sebagai koleksi objek domain yang tersimpan di memori, meskipun pada kenyataannya ia ber