Kuasai desain berorientasi domain di JavaScript. Pelajari Pola Entitas Modul untuk membangun aplikasi yang skalabel, dapat diuji, dan mudah dipelihara dengan model objek domain yang kuat.
Pola Entitas Modul JavaScript: Penyelaman Mendalam ke dalam Pemodelan Objek Domain
Dalam dunia pengembangan perangkat lunak, terutama dalam ekosistem JavaScript yang dinamis dan terus berkembang, kita sering memprioritaskan kecepatan, kerangka kerja, dan fitur. Kita membangun antarmuka pengguna yang kompleks, terhubung ke banyak API, dan menyebarkan aplikasi dengan kecepatan yang memusingkan. Namun dalam kesibukan ini, terkadang kita mengabaikan inti dari aplikasi kita: domain bisnis. Hal ini dapat mengarah pada apa yang sering disebut "Big Ball of Mud"—sistem di mana logika bisnis tersebar, data tidak terstruktur, dan membuat perubahan sederhana dapat memicu serangkaian bug yang tidak terduga.
Di sinilah Pemodelan Objek Domain berperan. Ini adalah praktik menciptakan model yang kaya dan ekspresif dari ruang masalah yang sedang Anda kerjakan. Dan di JavaScript, Pola Entitas Modul adalah cara yang ampuh, elegan, dan agnostik-kerangka kerja untuk mencapai hal ini. Panduan komprehensif ini akan memandu Anda melalui teori, praktik, dan manfaat pola ini, memberdayakan Anda untuk membangun aplikasi yang lebih tangguh, skalabel, dan mudah dipelihara.
Apa itu Pemodelan Objek Domain?
Sebelum kita menyelami pola itu sendiri, mari kita perjelas istilah-istilah kita. Penting untuk membedakan konsep ini dari Document Object Model (DOM) browser.
- Domain: Dalam perangkat lunak, 'domain' adalah area subjek spesifik tempat bisnis pengguna berada. Untuk aplikasi e-commerce, domain mencakup konsep seperti Produk, Pelanggan, Pesanan, dan Pembayaran. Untuk platform media sosial, itu mencakup Pengguna, Posting, Komentar, dan Suka.
- Pemodelan Objek Domain: Ini adalah proses pembuatan model perangkat lunak yang merepresentasikan entitas, perilaku, dan hubungan mereka dalam domain bisnis tersebut. Ini tentang menerjemahkan konsep dunia nyata ke dalam kode.
Model domain yang baik bukan hanya kumpulan wadah data. Ini adalah representasi hidup dari aturan bisnis Anda. Objek Pesanan seharusnya tidak hanya menyimpan daftar item; ia harus tahu cara menghitung totalnya, cara menambahkan item baru, dan apakah itu dapat dibatalkan. Enkapsulasi data dan perilaku ini adalah kunci untuk membangun inti aplikasi yang tangguh.
Masalah Umum: Anarki dalam Lapisan "Model"
Di banyak aplikasi JavaScript, terutama yang tumbuh secara organik, lapisan 'model' seringkali menjadi pemikiran terakhir. Kita sering melihat anti-pola ini:
// Somewhere in an API controller or service...
async function createUser(req, res) {
const { email, password, firstName, lastName } = req.body;
// Business logic and validation is scattered here
if (!email || !email.includes('@')) {
return res.status(400).send({ error: 'A valid email is required.' });
}
if (!password || password.length < 8) {
return res.status(400).send({ error: 'Password must be at least 8 characters.' });
}
const user = {
email: email.toLowerCase(),
password: await hashPassword(password), // Some utility function
fullName: `${firstName} ${lastName}`, // Logic for derived data is here
createdAt: new Date()
};
// Now, what is `user`? It's just a plain object.
// Nothing stops another developer from doing this later:
// user.email = 'an-invalid-email';
// user.password = 'short';
await db.users.insert(user);
res.status(201).send(user);
}
Pendekatan ini menyajikan beberapa masalah krusial:
- Tidak Ada Sumber Kebenaran Tunggal: Aturan untuk apa yang constitutes 'user' yang valid didefinisikan di dalam satu kontroler ini. Bagaimana jika bagian lain dari sistem perlu membuat user? Apakah Anda menyalin-menempel logikanya? Ini menyebabkan inkonsistensi dan bug.
- Model Domain Anemik: Objek `user` hanyalah kumpulan data 'bodoh'. Ia tidak memiliki perilaku dan tidak memiliki kesadaran diri. Semua logika yang beroperasi padanya berada di luar.
- Kohesi Rendah: Logika untuk membuat nama lengkap user bercampur dengan penanganan permintaan/respons API dan hashing kata sandi.
- Sulit Diuji: Untuk menguji logika pembuatan user, Anda harus melakukan mock permintaan dan respons HTTP, database, dan fungsi hashing. Anda tidak bisa hanya menguji konsep 'user' secara terpisah.
- Kontrak Implisit: Sisa aplikasi hanya perlu 'mengasumsikan' bahwa objek apa pun yang merepresentasikan user memiliki bentuk tertentu dan datanya valid. Tidak ada jaminan.
Solusi: Pola Entitas Modul JavaScript
Pola Entitas Modul mengatasi masalah ini dengan menggunakan modul JavaScript standar (satu file) untuk mendefinisikan segala sesuatu tentang satu konsep domain. Modul ini menjadi sumber kebenaran definitif untuk entitas tersebut.
Sebuah Entitas Modul biasanya mengekspos fungsi pabrik. Fungsi ini bertanggung jawab untuk membuat instance entitas yang valid. Objek yang dikembalikannya bukan hanya data; ini adalah objek domain kaya yang mengenkapsulasi data, validasi, dan logika bisnisnya sendiri.
Karakteristik Utama Entitas Modul
- Enkapsulasi: Menggabungkan data dan fungsi-fungsi yang beroperasi pada data tersebut menjadi satu.
- Validasi di Batasan: Memastikan bahwa tidak mungkin membuat entitas yang tidak valid. Ini menjaga keadaannya sendiri.
- API yang Jelas: Mengekspos serangkaian fungsi yang bersih dan disengaja (API publik) untuk berinteraksi dengan entitas, sambil menyembunyikan detail implementasi internal.
- Immutabilitas: Seringkali menghasilkan objek yang tidak dapat diubah (immutable) atau hanya-baca (read-only) untuk mencegah perubahan status yang tidak disengaja dan memastikan perilaku yang dapat diprediksi.
- Portabilitas: Tidak memiliki dependensi pada kerangka kerja (seperti Express, React) atau sistem eksternal (seperti database, API). Ini adalah logika bisnis murni.
Komponen Inti Entitas Modul
Mari kita bangun kembali konsep `User` kita menggunakan pola ini. Kita akan membuat file, `user.js` (atau `user.ts` untuk pengguna TypeScript), dan membangunnya langkah demi langkah.
1. Fungsi Pabrik: Konstruktor Objek Anda
Alih-alih kelas, kita akan menggunakan fungsi pabrik (misalnya, `buildUser`). Pabrik menawarkan fleksibilitas yang besar, menghindari pergulatan dengan kata kunci `this`, dan membuat status pribadi serta enkapsulasi lebih alami di JavaScript.
Tujuan kita adalah membuat fungsi yang mengambil data mentah dan mengembalikan objek Pengguna yang terstruktur dengan baik dan andal.
// file: /domain/user.js
export default function buildMakeUser() {
// This inner function is the actual factory.
// It has access to any dependencies passed to buildMakeUser, if needed.
return function makeUser({
id = generateId(), // Let's assume a function to generate a unique ID
firstName,
lastName,
email,
passwordHash,
createdAt = new Date()
}) {
// ... validation and logic will go here ...
const user = {
getId: () => id,
getFirstName: () => firstName,
getLastName: () => lastName,
getEmail: () => email,
getPasswordHash: () => passwordHash,
getCreatedAt: () => createdAt
};
// Using Object.freeze to make the object immutable.
return Object.freeze(user);
}
}
Perhatikan beberapa hal di sini. Kita menggunakan fungsi yang mengembalikan fungsi (fungsi tingkat tinggi). Ini adalah pola yang kuat untuk menyuntikkan dependensi, seperti generator ID unik atau pustaka validator, tanpa mengikat entitas ke implementasi tertentu. Untuk saat ini, kita akan menjaganya tetap sederhana.
2. Validasi Data: Penjaga di Gerbang
Sebuah entitas harus melindungi integritasnya sendiri. Seharusnya tidak mungkin untuk membuat `User` dalam keadaan tidak valid. Kita menambahkan validasi langsung di dalam fungsi pabrik. Jika data tidak valid, pabrik harus melempar kesalahan, dengan jelas menyatakan apa yang salah.
// file: /domain/user.js
export default function buildMakeUser({ Id, isValidEmail, hashPassword }) {
return function makeUser({
id = Id.makeId(),
firstName,
lastName,
email,
password, // We now take a plain password and handle it inside
createdAt = new Date()
}) {
if (!Id.isValidId(id)) {
throw new Error('User must have a valid id.');
}
if (!firstName || firstName.length < 2) {
throw new Error('First name must be at least 2 characters long.');
}
if (!lastName || lastName.length < 2) {
throw new Error('Last name must be at least 2 characters long.');
}
if (!email || !isValidEmail(email)) {
throw new Error('User must have a valid email address.');
}
if (!password || password.length < 8) {
throw new Error('Password must be at least 8 characters long.');
}
// Data normalization and transformation happens here
const passwordHash = hashPassword(password);
const normalizedEmail = email.toLowerCase();
return Object.freeze({
getId: () => id,
getFirstName: () => firstName,
getLastName: () => lastName,
getEmail: () => normalizedEmail,
getPasswordHash: () => passwordHash,
getCreatedAt: () => createdAt
});
}
}
Sekarang, setiap bagian dari sistem kita yang ingin membuat `User` harus melalui pabrik ini. Kita mendapatkan validasi yang dijamin setiap saat. Kita juga telah mengenkapsulasi logika hashing kata sandi dan normalisasi alamat email. Sisa aplikasi tidak perlu tahu atau peduli tentang detail ini.
3. Logika Bisnis: Mengenkapsulasi Perilaku
Objek `User` kita masih sedikit anemik. Ia menyimpan data, tetapi tidak melakukan apa pun. Mari kita tambahkan perilaku—metode yang mewakili tindakan spesifik domain.
// ... inside the makeUser function ...
if (!password || password.length < 8) {
// ...
}
const passwordHash = hashPassword(password);
const normalizedEmail = email.toLowerCase();
return Object.freeze({
getId: () => id,
getFirstName: () => firstName,
getLastName: () => lastName,
getEmail: () => normalizedEmail,
getPasswordHash: () => passwordHash,
getCreatedAt: () => createdAt,
// Business Logic / Behavior
getFullName: () => `${firstName} ${lastName}`,
// A method that describes a business rule
canVote: () => {
// In some countries, voting age is 18. This is a business rule.
// Let's assume we have a dateOfBirth property.
const age = calculateAge(dateOfBirth);
return age >= 18;
}
});
// ...
Logika `getFullName` tidak lagi tersebar di beberapa kontroler acak; itu milik entitas `User` itu sendiri. Siapa pun dengan objek `User` sekarang dapat dengan Andal mendapatkan nama lengkap dengan memanggil `user.getFullName()`. Logika didefinisikan sekali, di satu tempat.
Membangun Contoh Praktis: Sistem E-commerce Sederhana
Mari kita terapkan pola ini pada domain yang lebih saling terhubung. Kita akan memodelkan `Product`, `OrderItem`, dan `Order`.
1. Memodelkan Entitas `Product`
Sebuah produk memiliki nama, harga, dan beberapa informasi stok. Ia harus memiliki nama, dan harganya tidak boleh negatif.
// file: /domain/product.js
export default function buildMakeProduct({ Id }) {
return function makeProduct({
id = Id.makeId(),
name,
description,
price,
stock = 0
}) {
if (!Id.isValidId(id)) {
throw new Error('Product must have a valid ID.');
}
if (!name || name.trim().length < 2) {
throw new Error('Product name must be at least 2 characters.');
}
if (isNaN(price) || price <= 0) {
throw new Error('Product must have a price greater than zero.');
}
if (isNaN(stock) || stock < 0) {
throw new Error('Stock must be a non-negative number.');
}
return Object.freeze({
getId: () => id,
getName: () => name,
getDescription: () => description,
getPrice: () => price,
getStock: () => stock,
// Business logic
isAvailable: () => stock > 0,
// A method that modifies state by returning a new instance
reduceStock: (amount) => {
if (amount > stock) {
throw new Error('Not enough stock available.');
}
// Return a NEW product object with the updated stock
return makeProduct({ id, name, description, price, stock: stock - amount });
}
});
}
}
Perhatikan metode `reduceStock`. Ini adalah konsep penting yang terkait dengan immutabilitas. Alih-alih mengubah properti `stock` pada objek yang ada, ia mengembalikan instance `Product` *baru* dengan nilai yang diperbarui. Ini membuat perubahan status eksplisit dan dapat diprediksi.
2. Memodelkan Entitas `Order` (Akar Agregat)
Sebuah `Order` lebih kompleks. Ini adalah apa yang disebut Domain-Driven Design (DDD) sebagai "Akar Agregat." Ini adalah entitas yang mengelola objek lain yang lebih kecil di dalam batasannya. Sebuah `Order` berisi daftar `OrderItem`s. Anda tidak menambahkan produk langsung ke pesanan; Anda menambahkan `OrderItem` yang berisi produk dan kuantitas.
// file: /domain/order.js
export const ORDER_STATUS = {
PENDING: 'PENDING',
PAID: 'PAID',
SHIPPED: 'SHIPPED',
CANCELLED: 'CANCELLED'
};
export default function buildMakeOrder({ Id, validateOrderItem }) {
return function makeOrder({
id = Id.makeId(),
customerId,
items = [],
status = ORDER_STATUS.PENDING,
createdAt = new Date()
}) {
if (!Id.isValidId(id)) {
throw new Error('Order must have a valid ID.');
}
if (!customerId) {
throw new Error('Order must have a customer ID.');
}
let orderItems = [...items]; // Create a private copy to manage
return Object.freeze({
getId: () => id,
getCustomerId: () => customerId,
getItems: () => [...orderItems], // Return a copy to prevent external modification
getStatus: () => status,
getCreatedAt: () => createdAt,
// Business Logic
calculateTotal: () => {
return orderItems.reduce((total, item) => {
return total + (item.getPrice() * item.getQuantity());
}, 0);
},
addItem: (item) => {
// validateOrderItem is a function that ensures the item is a valid OrderItem entity
validateOrderItem(item);
// Business rule: prevent adding duplicates, just increase quantity
const existingItemIndex = orderItems.findIndex(i => i.getProductId() === item.getProductId());
if (existingItemIndex > -1) {
const newQuantity = orderItems[existingItemIndex].getQuantity() + item.getQuantity();
// Here you'd update the quantity on the existing item
// (This requires items to be mutable or have an update method)
} else {
orderItems.push(item);
}
},
markPaid: () => {
if (status !== ORDER_STATUS.PENDING) {
throw new Error('Only pending orders can be marked as paid.');
}
// Return a new Order instance with the updated status
return makeOrder({ id, customerId, items: orderItems, status: ORDER_STATUS.PAID, createdAt });
}
});
}
}
Entitas `Order` ini sekarang menerapkan aturan bisnis yang kompleks:
- Mengelola daftar itemnya sendiri.
- Mengetahui cara menghitung totalnya sendiri.
- Menerapkan transisi status (misalnya, Anda hanya dapat menandai pesanan `PENDING` sebagai `PAID`).
Logika bisnis untuk pesanan kini telah terenkapsulasi dengan rapi di dalam modul ini, dapat diuji secara terpisah, dan dapat digunakan kembali di seluruh aplikasi Anda.
Pola dan Pertimbangan Lanjutan
Immutabilitas: Landasan Prediktabilitas
Kita telah membahas immutabilitas. Mengapa itu begitu penting? Ketika objek tidak dapat diubah (immutable), Anda dapat meneruskannya ke seluruh aplikasi Anda tanpa takut bahwa beberapa fungsi yang jauh akan mengubah keadaannya secara tidak terduga. Ini menghilangkan seluruh kelas bug dan membuat aliran data aplikasi Anda jauh lebih mudah untuk dipahami.
Object.freeze() menyediakan pembekuan dangkal (shallow freeze). Untuk entitas dengan objek atau array bersarang (seperti `Order` kita), Anda perlu lebih berhati-hati. Misalnya, di `order.getItems()`, kita mengembalikan salinan (`[...orderItems]`) untuk mencegah pemanggil mendorong item langsung ke dalam array internal pesanan.
Untuk aplikasi kompleks, pustaka seperti Immer dapat membuat bekerja dengan struktur bersarang yang tidak dapat diubah (immutable) menjadi jauh lebih mudah, tetapi prinsip intinya tetap: perlakukan entitas Anda sebagai nilai yang tidak dapat diubah. Ketika perubahan perlu terjadi, buat nilai baru.
Menangani Operasi Asinkron dan Persistensi
Anda mungkin telah memperhatikan bahwa entitas kita sepenuhnya sinkron. Mereka tidak tahu apa-apa tentang database atau API. Ini disengaja dan merupakan kekuatan utama dari pola ini!
Entitas tidak boleh menyimpan dirinya sendiri. Tugas entitas adalah menegakkan aturan bisnis. Tugas menyimpan data ke database termasuk dalam lapisan aplikasi Anda yang berbeda, sering disebut Lapisan Layanan, Lapisan Kasus Penggunaan, atau Pola Repositori.
Berikut adalah cara mereka berinteraksi:
// file: /use-cases/create-user.js
// This use case depends on the user entity factory and a database access function.
export default function makeCreateUser({ makeUser, usersDatabase }) {
return async function createUser(userInfo) {
// 1. Create a valid domain entity. This step validates the data.
const user = makeUser(userInfo);
// 2. Check for business rules that require external data (e.g., email uniqueness)
const exists = await usersDatabase.findByEmail({ email: user.getEmail() });
if (exists) {
throw new Error('Email address is already in use.');
}
// 3. Persist the entity. The database needs a plain object.
const persisted = await usersDatabase.insert({
id: user.getId(),
firstName: user.getFirstName(),
// ... and so on
});
return persisted;
}
}
Pemisahan perhatian ini sangat kuat:
- Entitas `User` murni, sinkron, dan mudah diuji unit.
- Kasus penggunaan `createUser` bertanggung jawab untuk orkestrasi dan dapat diuji integrasi dengan database tiruan.
- Modul `usersDatabase` bertanggung jawab untuk teknologi database spesifik dan dapat diuji secara terpisah.
Serialisasi dan Deserialisasi
Entitas Anda, dengan metode-metodenya, adalah objek yang kaya. Tetapi ketika Anda mengirim data melalui jaringan (misalnya, dalam respons JSON API) atau menyimpannya di database, Anda memerlukan representasi data biasa. Proses ini disebut serialisasi.
Pola umum adalah menambahkan metode `toJSON()` atau `toObject()` ke entitas Anda.
// ... inside the makeUser function ...
return Object.freeze({
getId: () => id,
// ... other getters
// Serialization method
toObject: () => ({
id,
firstName,
lastName,
email: normalizedEmail,
createdAt
// Notice we don't include the passwordHash
})
});
Proses kebalikannya, mengambil data biasa dari database atau API dan mengubahnya kembali menjadi entitas domain yang kaya, adalah persis untuk apa fungsi pabrik `makeUser` Anda. Ini adalah deserialisasi.
Penulisan Tipe dengan TypeScript atau JSDoc
Meskipun pola ini bekerja dengan sempurna di vanilla JavaScript, menambahkan tipe statis dengan TypeScript atau JSDoc membuatnya lebih hebat. Tipe memungkinkan Anda untuk secara formal mendefinisikan 'bentuk' entitas Anda, menyediakan pelengkapan otomatis yang sangat baik dan pemeriksaan waktu kompilasi.
// file: /domain/user.ts
// Define the entity's public interface
export type User = Readonly<{
getId: () => string;
getFirstName: () => string;
// ... etc
getFullName: () => string;
}>;
// The factory function now returns the User type
export default function buildMakeUser(...) {
return function makeUser(...): User {
// ... implementation
}
}
Manfaat Utama Pola Entitas Modul
Dengan mengadopsi pola ini, Anda mendapatkan banyak manfaat yang akan bertambah seiring pertumbuhan aplikasi Anda:
- Sumber Kebenaran Tunggal: Aturan bisnis dan validasi data terpusat dan tidak ambigu. Perubahan pada suatu aturan dilakukan di satu tempat.
- Kohesi Tinggi, Kopling Rendah: Entitas mandiri dan tidak bergantung pada sistem luar. Ini membuat basis kode Anda modular dan mudah untuk direfaktor.
- Uji Coba Superior: Anda dapat menulis tes unit yang sederhana dan cepat untuk logika bisnis Anda yang paling penting tanpa melakukan mocking seluruh dunia.
- Pengalaman Pengembang yang Lebih Baik: Ketika seorang pengembang perlu bekerja dengan `User`, mereka memiliki API yang jelas, dapat diprediksi, dan mendokumentasikan diri untuk digunakan. Tidak ada lagi menebak-nebak bentuk objek biasa.
- Fondasi untuk Skalabilitas: Pola ini memberi Anda inti yang stabil dan andal. Saat Anda menambahkan lebih banyak fitur, kerangka kerja, atau komponen UI, logika bisnis Anda tetap terlindungi dan konsisten.
Kesimpulan: Bangun Inti yang Solid untuk Aplikasi Anda
Dalam dunia kerangka kerja dan pustaka yang bergerak cepat, mudah untuk melupakan bahwa alat-alat ini bersifat sementara. Mereka akan berubah. Yang abadi adalah logika inti dari domain bisnis Anda. Menginvestasikan waktu untuk memodelkan domain ini dengan benar bukan hanya latihan akademis; ini adalah salah satu investasi jangka panjang paling signifikan yang dapat Anda lakukan untuk kesehatan dan umur panjang perangkat lunak Anda.
Pola Entitas Modul JavaScript menyediakan cara sederhana, kuat, dan native untuk mengimplementasikan ide-ide ini. Ini tidak memerlukan kerangka kerja yang berat atau penyiapan yang kompleks. Ini memanfaatkan fitur-fitur fundamental bahasa—modul, fungsi, dan closure—untuk membantu Anda membangun inti yang bersih, tangguh, dan mudah dipahami untuk aplikasi Anda. Mulailah dengan satu entitas kunci di proyek Anda berikutnya. Model propertinya, validasi pembuatannya, dan berikan perilakunya. Anda akan mengambil langkah pertama menuju arsitektur perangkat lunak yang lebih kuat dan profesional.