CQRS EVENT-DRIVEN READ-MODEL MICROSERVICES DATA-CONSISTENCY SCALABILITY PERFORMANCE DATA-MODELING ARCHITECTURE EVENT-SOURCING DENORMALIZATION BACKEND

Membangun Read Model yang Efektif: Fondasi Query Cepat dan Konsisten di Arsitektur CQRS dan Event-Driven

⏱️ 19 menit baca
👨‍💻

1. Pendahuluan

Di dunia pengembangan aplikasi modern, terutama dengan maraknya microservices dan arsitektur event-driven, kita sering dihadapkan pada tantangan yang menarik: bagaimana cara menyajikan data dengan cepat dan efisien untuk berbagai kebutuhan tampilan atau laporan, tanpa mengorbankan integritas data transaksional?

Bayangkan Anda memiliki sistem e-commerce. Ketika seorang pelanggan melihat daftar produk, mencari pesanan lama, atau melihat total belanja, aplikasi perlu melakukan query data. Jika semua query ini harus mengakses database transaksional yang kompleks dan dinormalisasi (yang dioptimalkan untuk operasi tulis seperti membuat pesanan baru), performa bisa jadi masalah. Query yang kompleks bisa lambat, membebani database, dan bahkan menyebabkan deadlock jika berbenturan dengan operasi tulis.

Inilah mengapa konsep Read Model menjadi sangat penting. Read Model adalah salah satu pilar utama dalam arsitektur Command Query Responsibility Segregation (CQRS) dan arsitektur event-driven. Jika Anda sudah familiar dengan artikel kami tentang Menggali Lebih Dalam Event Sourcing dan CQRS, Anda mungkin sudah tahu bahwa CQRS memisahkan model untuk operasi “perintah” (menulis data) dan “query” (membaca data). Nah, Read Model adalah sisi “query” yang dioptimalkan secara spesifik.

Artikel ini akan membawa Anda lebih dalam ke dunia Read Model. Kita akan membahas apa itu Read Model, mengapa kita membutuhkannya, bagaimana cara membangunnya, dan tantangan apa saja yang mungkin Anda hadapi. Tujuannya? Agar aplikasi Anda tidak hanya tangguh dan konsisten, tetapi juga super cepat dalam menyajikan informasi kepada pengguna. 🚀

2. Apa Itu Read Model dan Mengapa Kita Membutuhkannya?

Secara sederhana, Read Model adalah representasi data yang dioptimalkan secara spesifik untuk kebutuhan pembacaan (query) tertentu. Berbeda dengan model data transaksional (sering disebut Write Model atau Command Model) yang biasanya dinormalisasi untuk memastikan integritas data dan efisiensi operasi tulis, Read Model sengaja didenormalisasi dan disusun sedemikian rupa agar query yang spesifik bisa dilakukan dengan sangat cepat dan mudah.

Ilustrasi Perbedaan Write Model dan Read Model

📌 Write Model (Command Model):

📌 Read Model (Query Model):

Mengapa Read Model Sangat Berguna?

  1. Performa Query yang Superior: Karena data sudah didenormalisasi dan dioptimalkan untuk query tertentu, tidak perlu lagi melakukan join tabel yang kompleks atau agregasi yang mahal saat runtime. Ini mengurangi beban pada database transaksional dan mempercepat respons aplikasi.

  2. Fleksibilitas Skema Data Baca: Anda tidak terikat pada skema database transaksional. Read Model bisa memiliki skema yang sama sekali berbeda, bahkan menggunakan jenis database yang berbeda, yang paling cocok untuk kebutuhan querynya.

  3. Mendukung Beragam Jenis Database: Anda bisa menggunakan database relasional untuk Write Model, tetapi menggunakan Elasticsearch untuk Read Model pencarian produk, atau MongoDB untuk Read Model dashboard analitik. Ini memberikan kebebasan arsitektur yang luar biasa.

  4. Skalabilitas Independen: Operasi baca dan tulis dapat diskalakan secara terpisah. Jika Anda memiliki jutaan pembaca tetapi hanya ribuan penulis, Anda bisa menambahkan lebih banyak instance untuk Read Model tanpa memengaruhi Write Model.

  5. Pemisahan Concern: Kode untuk menulis dan membaca data menjadi lebih sederhana dan fokus pada tanggung jawabnya masing-masing, mengurangi kompleksitas secara keseluruhan.

🎯 Contoh Kasus Nyata:

3. Strategi Membangun Read Model: Dari Event ke Proyeksi

Inti dari membangun Read Model dalam arsitektur event-driven adalah “mendengarkan” perubahan status (events) yang terjadi di Write Model, lalu menggunakan events tersebut untuk memperbarui Read Model. Proses ini sering disebut proyeksi.

Alur Kerja Proyeksi

  1. Write Model Menerbitkan Event: Setiap kali ada perubahan signifikan pada Write Model (misalnya, pesanan dibuat, item ditambahkan, status pesanan berubah), sebuah event yang mendeskripsikan perubahan tersebut akan diterbitkan. Event ini bersifat faktual, tidak dapat diubah, dan mencerminkan apa yang telah terjadi.

  2. Event Dikirim ke Message Queue: Event-event ini biasanya dikirim ke Message Queue (seperti Apache Kafka atau RabbitMQ) agar dapat diproses secara asynchronous dan reliable oleh berbagai consumer.

  3. Event Handler/Projector Memproses Event: Sebuah komponen khusus, yang kita sebut Event Handler atau Projector, akan “mendengarkan” event dari message queue. Ketika event diterima, handler ini akan mengekstrak informasi yang relevan dan menggunakannya untuk memperbarui Read Model yang sesuai.

  4. Read Model Diperbarui: Berdasarkan logika bisnis di Event Handler, data di Read Model akan diubah. Ini bisa berarti membuat entri baru, memperbarui entri yang sudah ada, atau menghapus entri.

Contoh Sederhana: Read Model Daftar Pesanan

Mari kita ambil contoh daftar pesanan pelanggan. Kita ingin Read Model yang bernama CustomerOrderList yang berisi ringkasan setiap pesanan untuk ditampilkan di UI.

Event yang Diterbitkan oleh Write Model:

Struktur Read Model CustomerOrderList (misalnya di MongoDB):

{
  "_id": "order-123",
  "customerId": "cust-456",
  "orderDate": "2023-10-26T10:00:00Z",
  "status": "PENDING",
  "totalAmount": 150000,
  "itemCount": 2,
  "items": [
    { "productId": "prod-A", "name": "Buku Algoritma", "quantity": 1, "price": 100000 },
    { "productId": "prod-B", "name": "Pulpen Set", "quantity": 1, "price": 50000 }
  ],
  "shippingAddress": { /* ... */ }
}

Bagaimana Event Handler Bekerja:

💡 Dengan cara ini, setiap kali frontend ingin menampilkan daftar pesanan pelanggan, ia hanya perlu melakukan satu query sederhana ke koleksi CustomerOrderList dan mendapatkan semua data yang dibutuhkan secara langsung, tanpa perlu join atau agregasi kompleks.

4. Pilihan Teknologi untuk Read Model

Keindahan Read Model adalah kebebasan untuk memilih teknologi database yang paling sesuai dengan kebutuhan query Anda.

  1. Database Relasional (SQL - PostgreSQL, MySQL, SQL Server):

    • Kapan Digunakan: Jika Read Model Anda masih membutuhkan query yang cukup kompleks dengan join antar tabel kecil di dalam Read Model itu sendiri, atau jika Anda sudah familiar dan nyaman dengan SQL.
    • Kelebihan: Konsistensi yang kuat (ACID), tooling yang matang, dukungan transaksi.
    • Kekurangan: Skalabilitas horizontal mungkin lebih sulit dan mahal dibandingkan NoSQL untuk volume baca yang sangat tinggi.
  2. NoSQL Databases (MongoDB, Cassandra, DynamoDB):

    • Kapan Digunakan: Sangat cocok untuk Read Model yang didenormalisasi sepenuhnya, di mana setiap dokumen/entri sudah mengandung semua informasi yang dibutuhkan untuk query spesifik. Ideal untuk data yang berubah cepat dan volume baca tinggi.
    • Kelebihan: Skalabilitas horizontal yang sangat baik, performa baca yang cepat untuk lookup berdasarkan kunci atau range, skema fleksibel (schema-less).
    • Kekurangan: Fitur join biasanya terbatas atau tidak ada, konsistensi data bisa eventual.
  3. Search Engines (Elasticsearch, Apache Solr):

    • Kapan Digunakan: Pilihan utama untuk Read Model yang melayani fitur pencarian full-text, faceted search (filter berdasarkan kategori/atribut), atau agregasi data yang kompleks untuk analitik.
    • Kelebihan: Sangat cepat untuk pencarian teks bebas, agregasi data real-time, skalabilitas horizontal.
    • Kekurangan: Tidak cocok untuk transaksi kompleks atau sebagai sumber kebenaran utama.
  4. Key-Value Stores (Redis, Memcached):

    • Kapan Digunakan: Untuk Read Model yang sangat sederhana, di mana Anda hanya perlu menyimpan dan mengambil data berdasarkan sebuah kunci dengan performa sangat tinggi (misalnya, menyimpan profil pengguna yang sering diakses atau cache hasil komputasi).
    • Kelebihan: Kecepatan baca/tulis yang ekstrem, sangat efisien untuk kasus penggunaan caching.
    • Kekurangan: Model data sangat sederhana, tidak cocok untuk query kompleks.

Memilih teknologi yang tepat adalah keputusan krusial yang harus didasarkan pada karakteristik query Read Model Anda. Jangan takut untuk menggunakan kombinasi beberapa teknologi untuk Read Model yang berbeda dalam satu sistem!

5. Tantangan dan Pertimbangan dalam Implementasi Read Model

Meskipun Read Model menawarkan banyak keuntungan, ada beberapa tantangan yang perlu Anda pertimbangkan:

  1. ⚠️ Eventual Consistency: Ini adalah konsep paling penting. Data di Read Model mungkin tidak langsung sinkron dengan Write Model. Ada jeda waktu (latency) antara saat event diterbitkan dan saat Read Model diperbarui.

    • Implikasi: Pengguna mungkin tidak langsung melihat perubahan yang baru saja mereka lakukan.
    • Mitigasi: Desain UI Anda agar dapat menangani eventual consistency (misalnya, dengan menampilkan indikator “memproses” atau “data mungkin belum terbaru”). Edukasi pengguna juga penting.
  2. Idempotensi Event Handlers: Event dari message queue bisa saja terkirim beberapa kali (misalnya karena retry saat terjadi kegagalan sementara). Event Handler harus didesain agar memproses event yang sama berulang kali tidak menyebabkan efek samping yang tidak diinginkan.

    • Contoh: Jika event OrderCreated diproses dua kali, pastikan tidak membuat dua entri pesanan yang sama di Read Model. Anda bisa menggunakan orderId sebagai kunci unik dan melakukan upsert (insert jika tidak ada, update jika ada).
  3. Error Handling dan Retry: Apa yang terjadi jika Event Handler gagal memperbarui Read Model (misalnya, masalah koneksi database)?

  4. ⚙️ Schema Evolution: Seiring berjalannya waktu, skema event atau skema Read Model bisa berubah.

    • Strategi: Terapkan versioning pada event dan Read Model. Desain Event Handler agar kompatibel mundur (backward compatible) atau memiliki logika untuk menangani versi event yang berbeda. Untuk perubahan skema Read Model yang signifikan, mungkin perlu proses migrasi atau bahkan membangun ulang Read Model.
  5. ♻️ Rebuild Read Model: Terkadang, Anda perlu membangun ulang seluruh Read Model dari awal (misalnya, jika ada bug di Event Handler, atau jika Anda ingin memperkenalkan Read Model baru dengan skema yang berbeda).

    • Strategi: Jika Anda menggunakan Event Sourcing, Anda dapat “memutar ulang” semua event dari Event Store untuk membangun ulang Read Model. Jika tidak, Anda mungkin perlu mengambil data dari Write Model, memprosesnya, dan mengisi Read Model yang baru.

6. Contoh Praktis: Membangun Read Model Pesanan dengan Node.js dan MongoDB

Mari kita wujudkan konsep ini dengan contoh sederhana menggunakan Node.js dan MongoDB sebagai database Read Model.

Skenario: Kita ingin Read Model yang menampilkan ringkasan pesanan pelanggan, yang akan di-query oleh aplikasi frontend.

Asumsi:

// order-read-model-handler.js
const { MongoClient } = require('mongodb');

// Konfigurasi MongoDB
const uri = "mongodb://localhost:27017"; // Ganti dengan URI MongoDB Anda
const client = new MongoClient(uri);
let db;
let customerOrdersCollection;

async function connectToMongoDB() {
    try {
        await client.connect();
        db = client.db('ecom_read_models'); // Nama database untuk Read Models
        customerOrdersCollection = db.collection('customer_orders'); // Koleksi untuk Read Model Pesanan
        console.log("Connected to MongoDB for Read Models");
    } catch (error) {
        console.error("Failed to connect to MongoDB:", error);
        process.exit(1);
    }
}

// Fungsi helper untuk menghitung total
function calculateTotal(items) {
    return items.reduce((sum, item) => sum + (item.quantity * item.price), 0);
}

// Event Handler Utama
async function handleEvent(event) {
    console.log(`Processing event: ${event.type} for Order ID: ${event.payload.orderId}`);
    try {
        switch (event.type) {
            case 'OrderCreated':
                await handleOrderCreated(event.payload);
                break;
            case 'OrderItemAdded':
                await handleOrderItemAdded(event.payload);
                break;
            case 'OrderStatusUpdated':
                await handleOrderStatusUpdated(event.payload);
                break;
            case 'OrderShipped':
                await handleOrderShipped(event.payload);
                break;
            default:
                console.warn(`Unknown event type: ${event.type}`);
        }
    } catch (error) {
        console.error(`Error processing event ${event.type} for order ${event.payload.orderId}:`, error);
        // Di sini Anda bisa mengirim ke DLQ atau log untuk investigasi
    }
}

// Handler spesifik untuk event OrderCreated
async function handleOrderCreated(payload) {
    const { orderId, customerId, orderDate, initialItems, initialTotal } = payload;
    
    // Upsert: Insert jika tidak ada, update jika ada (untuk idempotensi)
    await customerOrdersCollection.updateOne(
        { _id: orderId },
        {
            $set: {
                customerId: customerId,
                orderDate: new Date(orderDate),
                status: 'PENDING',
                totalAmount: initialTotal,
                itemCount: initialItems.length,
                items: initialItems.map(item => ({ 
                    productId: item.productId, 
                    name: item.productName, // Asumsi nama produk juga ada di event
                    quantity: item.quantity, 
                    price: item.price 
                }))
            }
        },
        { upsert: true } // Penting untuk idempotensi
    );
    console.log(`Read Model updated for new order: ${orderId}`);
}

// Handler spesifik untuk event OrderItemAdded
async function handleOrderItemAdded(payload) {
    const { orderId, productId, productName, quantity, price } = payload;
    
    await customerOrdersCollection.updateOne(
        { _id: orderId },
        {
            $push: { items: { productId, name: productName, quantity, price } },
            $inc: { 
                totalAmount: quantity * price,
                itemCount: quantity // Asumsi itemCount adalah total kuantitas item
            }
        }
    );
    console.log(`Read Model updated for item added to order: ${orderId}`);
}

// Handler spesifik untuk event OrderStatusUpdated
async function handleOrderStatusUpdated(payload) {
    const { orderId, newStatus, timestamp } = payload;
    
    await customerOrdersCollection.updateOne(
        { _id: orderId },
        { $set: { status: newStatus, lastStatusUpdate: new Date(timestamp) } }
    );
    console.log(`Read Model updated for order status: ${orderId} -> ${newStatus}`);
}

// Handler spesifik untuk event OrderShipped
async function handleOrderShipped(payload) {
    const { orderId, shippingDate } = payload;
    
    await customerOrdersCollection.updateOne(
        { _id: orderId },
        { $set: { status: 'SHIPPED', shippingDate: new Date(shippingDate) } }
    );
    console.log(`Read Model updated for order shipped: ${orderId}`);
}

// --- Simulasi Penerimaan Event (dalam aplikasi nyata, ini dari message broker) ---
async function simulateEvents() {
    await connectToMongoDB();

    const events = [
        {
            type: 'OrderCreated',
            payload: {
                orderId: 'ORD-001',
                customerId: 'CUST-A',
                orderDate: new Date().toISOString(),
                initialItems: [
                    { productId: 'PROD-X', productName: 'Laptop Gaming', quantity: 1, price: 15000000 },
                    { productId: 'PROD-Y', productName: 'Mouse Wireless', quantity: 1, price: 250000 }
                ],
                initialTotal: 15250000
            }
        },
        {
            type: 'OrderItemAdded',
            payload: {
                orderId: 'ORD-001',
                productId: 'PROD-Z',
                productName: 'Keyboard Mekanik',
                quantity: 1,
                price: 1000000
            }
        },
        {
            type: 'OrderStatusUpdated',
            payload: {
                orderId: 'ORD-001',
                newStatus: 'PACKED',
                timestamp: new Date().toISOString()
            }
        },
        {
            type: 'OrderShipped',
            payload: {
                orderId: 'ORD-001',
                shippingDate: new Date(Date.now() + 86400000).toISOString() // Sehari setelahnya
            }
        }
    ];

    for (const event of events) {
        await handleEvent(event);
        await new Promise(resolve => setTimeout(resolve, 100)); // Simulasi delay
    }

    console.log("\nFinished processing simulated events. Check your MongoDB 'ecom_read_models' database.");
    // Contoh query dari Read Model
    const orderSummary = await customerOrdersCollection.findOne({ _id: 'ORD-0