L
Node.js β€’ Literally Web ID

Arsitektur REST API Berskala Tinggi dengan Pola CQRS di Node.js

⏱️ 18 menit baca β€’ πŸ“… 15 Oct 2026 β€’ πŸ‘οΈ 1 views
Arsitektur REST API Berskala Tinggi dengan Pola CQRS di Node.js

1. Konteks Industri & Analisis Akar Masalah

Dalam lanskap pengembangan aplikasi web modern, terutama pada sistem skala perusahaan (enterprise) dengan beban traffic tinggi, arsitektur monolitik tradisional yang menggunakan pola CRUD (Create, Read, Update, Delete) konvensional sering kali menemui jalan buntu performa. Pada arsitektur tradisional, satu model data atau ORM entity digunakan secara bersamaan untuk menangani operasi penulisan (write) yang kompleksβ€”melibatkan validasi transaksional, pemicuan trigger, pembaruan relasi database yang ketatβ€”sekaligus operasi pembacaan (read) yang membutuhkan latensi rendah dan agregasi data lintas tabel.

Akar Masalah: Kontensi Sumber Daya dan Bottleneck Database

Ketika volume permintaan melonjak hingga ribuan requests per second (RPS), basis data relasional seperti MySQL atau MariaDB mengalami kontensi (contention) yang parah pada tingkat kunci baris (row-level locking) dan tabel (table locking). Operasi tulis yang memakan waktu lama akibat kompleksitas logika bisnis akan memblokir koneksi dan memperlambat antrean kueri baca. Indikasi kegagalan sistem yang paling sering muncul di lingkungan produksi meliputi:

  1. Keterbatasan Koneksi Pool (Connection Pool Exhaustion): Kueri baca yang kompleks menghabiskan koneksi database aktif, menyebabkan request tulis baru mengalami penolakan (timeout).
  2. Inersia Latensi (Latency Drift): Waktu respons API meningkat secara eksponensial karena CPU database terbebani oleh operasi JOIN multitable yang masif pada satu mesin basis data tunggal.
  3. Kompleksitas Skalabilitas Asimetris: Kebutuhan sumber daya untuk membaca data (biasanya rasio 80:20 atau bahkan 95:5 dibandingkan penulisan) tidak dapat dioptimalkan secara mandiri tanpa membebani infrastruktur tulis.

Perbandingan Kondisi: Sebelum vs Sesudah Penerapan CQRS

Parameter Sistem Arsitektur Tradisional (Monolithic CRUD) Arsitektur CQRS dengan Node.js & Redis Streams
Skalabilitas Skala vertikal (Upgrade CPU/RAM DB tunggal) Skala horizontal independen (Read Replica & Write Node)
Latensi Puncak (p99) Tinggi (> 800ms) di bawah beban berat Sangat Rendah (< 45ms) melalui read model terdenormalisasi
Kompleksitas Kode Rendah di awal, sangat tinggi di kemudian hari Terstruktur ketat, pemisahan domain murni (Domain-Driven Design)
Konsistensi Data Konsistensi kuat instan (Strong Consistency) Konsistensi eventual (Eventual Consistency via Async Streams)

[!WARNING] Penerapan CQRS memperkenalkan kompleksitas tambahan berupa Eventual Consistency. Pastikan produk Anda dapat mentolerir jeda milidetik hingga detik antara saat data ditulis dan saat data tersebut terbaca di Read Model.


2. Konsep Inti & Deep Dive Arsitektur

Command Query Responsibility Segregation (CQRS) memisahkan model yang memperbarui data (Command) dari model yang membaca data (Query). Dalam ekosistem Node.js, pemisahan ini diwujudkan dengan membagi tanggung jawab penanganan HTTP request menjadi dua jalur logika yang sepenuhnya independen.

Alur Eksekusi (Under the Hood)

  1. Command Path (Write Side): Klien mengirimkan HTTP POST/PUT/DELETE. Permintaan diterima oleh Node.js Command Service, divalidasi, memproses logika bisnis, lalu mengeksekusi penyimpanan data ke Database Utama (Master). Setelah transaksi sukses, event perubahan data dipublikasikan ke broker pesan, dalam hal ini menggunakan Redis Streams.
  2. Event Synchronization: Layanan consumer membaca stream dari Redis dan melakukan sinkronisasi asinkron ke Read Database (bisa berupa Read Replica MySQL atau dokumen terdenormalisasi di Redis/MongoDB).
  3. Query Path (Read Side): Klien mengirimkan HTTP GET. Permintaan langsung diarahkan ke Query Service di Node.js, yang membaca data dari Read Replica atau Cache Layer tanpa melewati proses validasi domain yang rumit.
[Client App / SPA]
       β”‚
       β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Ί [POST / PUT / DELETE] ──► [Node.js Command Service]
       β”‚                                                      β”‚
       β”‚                                                      β–Ό
       β”‚                                            [Primary DB (MySQL Master)]
       β”‚                                                      β”‚
       β”‚                                               (Redis Streams)
       β”‚                                                      β”‚
       β”‚                                                      β–Ό
       β”‚                                            [Async Event Consumer]
       β”‚                                                      β”‚
       β–Ό                                                      β–Ό
[GET / Read API] ────► [Node.js Query Service] ────► [Read Replica / Read DB]

3. Perbandingan Komparatif / Analisis Pendekatan

Sebelum memutuskan menggunakan pola CQRS murni dengan Redis Streams dan Read Replica, arsitek sistem harus memahami trade-off teknis antara berbagai pendekatan:

  • Pendekatan A: Monolithic CRUD dengan DB Read Replica

    • Kelebihan: Implementasi sederhana, tidak perlu perubahan pola pikir domain.
    • Kekurangan: Masih terikat dengan struktur tabel relasional yang kaku untuk pembacaan; ORM sering menghasilkan kueri JOIN yang tidak efisien.
    • Sumber Daya: Penggunaan RAM sedang pada aplikasi, beban tinggi pada CPU database.
  • Pendekatan B: CQRS dengan Redis Streams & Denormalized Read Store

    • Kelebihan: Performa baca sangat tinggi, skalabilitas horizontal tak terbatas untuk layanan query, isolasi beban total.
    • Kekurangan: Kurva belajar tinggi, membutuhkan penanganan kegagalan pesan (dead-letter queue), konsistensi eventual.
    • Sumber Daya: Konsumsi RAM tinggi untuk caching dan streams, penggunaan CPU terdistribusi dengan baik.

4. Prasyarat Sistem & Persiapan Lingkungan

Untuk menjalankan implementasi praktis ini, pastikan infrastruktur lokal atau server pengembangan Anda memenuhi spesifikasi berikut:

  • Node.js: Versi 20.x LTS atau lebih baru.
  • MySQL/MariaDB: Versi 8.x (Master) dengan konfigurasi Binary Logging diaktifkan untuk replikasi.
  • Redis Server: Versi 7.x (mendukung fitur Redis Streams secara optimal).
  • Nginx: Sebagai reverse proxy dan SSL terminator di depan aplikasi Node.js.

Verifikasi Instalasi Lingkungan

Jalankan perintah terminal berikut untuk memastikan dependensi dasar siap:

node -v
redis-cli --version
mysql --version

5. Panduan Implementasi Langkah demi Langkah

Berikut adalah implementasi nyata aplikasi Node.js menggunakan TypeScript, Express, MySQL2, dan ioredis untuk menangani Redis Streams.

Struktur Direktori Proyek

β”œβ”€β”€ src/
β”‚   β”œβ”€β”€ commands/
β”‚   β”‚   β”œβ”€β”€ createUserCommand.ts
β”‚   β”‚   └── userCommandHandler.ts
β”‚   β”œβ”€β”€ queries/
β”‚   β”‚   └── getUserQuery.ts
β”‚   β”œβ”€β”€ infrastructure/
β”‚   β”‚   β”œβ”€β”€ database.ts
β”‚   β”‚   β”œβ”€β”€ redis.ts
β”‚   β”‚   └── eventConsumer.ts
β”‚   └── server.ts
β”œβ”€β”€ package.json
└── tsconfig.json

1. Konfigurasi Koneksi Database (src/infrastructure/database.ts)

import mysql from 'mysql2/promise';

// Master Database untuk Operasi Tulis (Command)
export const writePool = mysql.createPool({
  host: process.env.DB_MASTER_HOST || '127.0.0.1',
  user: process.env.DB_USER || 'root',
  password: process.env.DB_PASSWORD || 'secret',
  database: process.env.DB_NAME || 'cqrs_demo',
  waitForConnections: true,
  connectionLimit: 20,
  queueLimit: 0
});

// Read Replica Database untuk Operasi Baca (Query)
export const readPool = mysql.createPool({
  host: process.env.DB_REPLICA_HOST || '127.0.0.1',
  user: process.env.DB_USER || 'root',
  password: process.env.DB_PASSWORD || 'secret',
  database: process.env.DB_NAME || 'cqrs_demo',
  waitForConnections: true,
  connectionLimit: 50,
  queueLimit: 0
});

2. Konfigurasi Redis (src/infrastructure/redis.ts)

import Redis from 'ioredis';

export const redisClient = new Redis({
  host: process.env.REDIS_HOST || '127.0.0.1',
  port: parseInt(process.env.REDIS_PORT || '6379'),
  maxRetriesPerRequest: null
});

3. Command Handler & Redis Stream Producer (src/commands/userCommandHandler.ts)

import { writePool } from '../infrastructure/database';
import { redisClient } from '../infrastructure/redis';
import { ResultSetHeader } from 'mysql2';

export interface CreateUserDTO {
  name: string;
  email: string;
}

export async function handleCreateUser(dto: CreateUserDTO): Promise<number> {
  const connection = await writePool.getConnection();
  try {
    await connection.beginTransaction();

    // 1. Eksekusi tulis ke Database Master
    const [result] = await connection.execute<ResultSetHeader>(
      'INSERT INTO users (name, email, created_at) VALUES (?, ?, NOW())',
      [dto.name, dto.email]
    );
    
    const userId = result.insertId;

    await connection.commit();

    // 2. Publikasikan Event ke Redis Streams
    await redisClient.xadd(
      'user-stream',
      '*',
      'event', 'UserCreated',
      'userId', userId.toString(),
      'name', dto.name,
      'email', dto.email
    );

    return userId;
  } catch (error) {
    await connection.rollback();
    throw error;
  } finally {
    connection.release();
  }
}

4. Event Consumer untuk Sinkronisasi Read Model (src/infrastructure/eventConsumer.ts)

import { redisClient } from './redis';
import { readPool } from './database'; // Atau database terpisah untuk read model

const STREAM_NAME = 'user-stream';
const GROUP_NAME = 'read-model-group';
const CONSUMER_NAME = 'consumer-1';

export async function startEventConsumer() {
  try {
    await redisClient.xgroup('CREATE', STREAM_NAME, GROUP_NAME, '$', 'MKSTREAM');
  } catch (err) {
    // Group mungkin sudah ada
  }

  console.log('Event consumer started, listening to Redis Streams...');

  while (true) {
    try {
      const response = await redisClient.xreadgroup(
        'GROUP', GROUP_NAME, CONSUMER_NAME,
        'BLOCK', 5000,
        'COUNT', 10,
        'STREAMS', STREAM_NAME, '>'
      );

      if (response) {
        for (const [stream, events] of response) {
          for (const [id, fields] of events) {
            // fields berbentuk array [key1, val1, key2, val2, ...]
            const eventData: Record<string, string> = {};
            for (let i = 0; i < fields.length; i += 2) {
              eventData[fields[i]] = fields[i + 1];
            }

            if (eventData.event === 'UserCreated') {
              // Lakukan sinkronisasi atau pembaruan pada tabel Read Model / Cache
              console.log(`Synchronizing User ID ${eventData.userId} to Read Store...`);
              
              // Contoh update materialized view / tabel khusus read
              await readPool.execute(
                'INSERT INTO user_read_models (id, name, email) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE name=?, email=?',
                [eventData.userId, eventData.name, eventData.email, eventData.name, eventData.email]
              );
            }

            // ACK pesan
            await redisClient.xack(STREAM_NAME, GROUP_NAME, id);
          }
        }
      }
    } catch (error) {
      console.error('Error processing stream event:', error);
      await new Promise((resolve) => setTimeout(resolve, 1000));
    }
  }
}

5. Query Handler (src/queries/getUserQuery.ts)

import { readPool } from '../infrastructure/database';
import { RowDataPacket } from 'mysql2';

export async function handleGetUser(userId: number) {
  // Membaca langsung dari Read Model / Read Replica tanpa melalui proses domain
  const [rows] = await readPool.execute<RowDataPacket[]>(
    'SELECT id, name, email FROM user_read_models WHERE id = ?',
    [userId]
  );

  if (rows.length === 0) {
    throw new Error('User not found');
  }

  return rows[0];
}

6. Aplikasi Utama (src/server.ts)

import express, { Request, Response } from 'express';
import { handleCreateUser } from './commands/userCommandHandler';
import { handleGetUser } from './queries/getUserQuery';
import { startEventConsumer } from './infrastructure/eventConsumer';

const app = express();
app.use(express.json());

// Command Route (Write)
app.post('/api/users', async (req: Request, res: Response) => {
  try {
    const { name, email } = req.body;
    const userId = await handleCreateUser({ name, email });
    res.status(201).json({ status: 'success', userId });
  } catch (error: any) {
    res.status(500).json({ status: 'error', message: error.message });
  }
});

// Query Route (Read)
app.get('/api/users/:id', async (req: Request, res: Response) => {
  try {
    const userId = parseInt(req.params.id);
    const user = await handleGetUser(userId);
    res.status(200).json({ status: 'success', data: user });
  } catch (error: any) {
    res.status(404).json({ status: 'error', message: error.message });
  }
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
  // Jalankan consumer secara background
  startEventConsumer();
});

6. Bedah Kode & Penjelasan Detail Baris Kunci

  • connection.beginTransaction() & commit(): Pada Command Handler, transaksi database sangat penting untuk memastikan bahwa entitas data utama berhasil disimpan secara atomik sebelum event dipublikasikan ke Redis Streams.
  • redisClient.xadd('user-stream', ...): Perintah ini memasukkan pesan ke dalam Redis Stream. Penggunaan tanda bintang (*) menginstruksikan Redis untuk menghasilkan ID pesan berbasis timestamp secara otomatis.
  • redisClient.xreadgroup('GROUP', GROUP_NAME, CONSUMER_NAME, ...): Mengimplementasikan pola Consumer Groups. Ini memungkinkan beberapa instance Node.js berjalan secara paralel untuk memproses event tanpa menduplikasi pekerjaan, mendistribusikan beban sinkronisasi read model secara efisien.
  • readPool vs writePool: Pemisahan connection pool memastikan bahwa lonjakan trafik pembacaan data API tidak akan pernah menghabiskan jatah koneksi yang dibutuhkan oleh operasi transaksi tulis.

7. Skenario Produksi & Studi Kasus Dunia Nyata

Bayangkan sebuah platform E-Commerce Flash Sale yang melayani 50.000 RPS.

  • Tantangan: Halaman produk dan riwayat transaksi diserbu jutaan pengguna secara bersamaan, sementara proses checkout dan pembayaran harus tetap akurat tanpa kegagalan.
  • Solusi Arsitektur:
    • Layanan Checkout (Command) menggunakan database Master dengan penguncian ketat khusus transaksi stok.
    • Setiap transaksi sukses memicu event ke Redis Streams.
    • Ratusan Query Service berbasis Node.js membaca data dari Read Replicas yang dioptimalkan dengan indeks penuh dan Materialized Views, menghasilkan waktu tanggap API read di bawah 15ms.

8. Common Pitfalls, Anti-Patterns & Solusi

  1. Anti-Pattern: Menggunakan Satu Koneksi Database untuk Command dan Query
    • Masalah: Menyebabkan bottleneck yang sama seperti arsitektur monolitik tradisional.
    • Solusi: Selalu pisahkan variabel lingkungan dan pool instance antara writePool dan readPool.
  2. Pitfall: Mengabaikan Penanganan Kegagalan Consumer (Poison Messages)
    • Masalah: Jika sebuah event gagal diproses terus-menerus, consumer loop akan tersendat.
    • Solusi: Implementasikan mekanisme Dead Letter Queue (DLQ) menggunakan Redis setelah jumlah percobaan (retry count) melebihi batas maksimal.
  3. Kesalahan Asumsi Konsistensi Kuat (Strong Consistency)
    • Masalah: Klien langsung melakukan GET setelah melakukan POST dan mendapati data belum muncul karena jeda asinkron.
    • Solusi: Berikan respon Command berupa ID dan arahkan UI untuk melakukan optimistic UI update atau gunakan pola pengembalian data instan pada kasus tertentu (CQRS Hybrid Read).

9. Benchmark, Metrik Kinerja & Tuning Performa

Pengujian performa dapat dilakukan menggunakan k6 untuk menguji pemisahan beban read dan write secara bersamaan.

Skrip Pengujian k6 (load-test.js)

import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
  stages: [
    { duration: '30s', target: 100 },
    { duration: '1m', target: 500 },
    { duration: '30s', target: 0 },
  ],
};

export default function () {
  // Uji Query (Read Endpoint)
  const userId = Math.floor(Math.random() * 1000) + 1;
  const resRead = http.get(`http://localhost:3000/api/users/${userId}`);
  check(resRead, { 'read status was 200 or 404': (r) => r.status === 200 || r.status === 404 });

  sleep(0.1);
}

[!TIP] Gunakan parameter kernel Linux somaxconn yang lebih tinggi pada server produksi (sysctl -w net.core.somaxconn=65535) untuk mencegah kegagalan handshake TCP pada beban konkurensi tinggi.


10. Checklist Penerapan Produksi & Kesimpulan

Sebelum meluncurkan arsitektur CQRS berbasis Node.js ke lingkungan produksi, pastikan checklist berikut terpenuhi:

  • Pemisahan kredensial dan endpoint Database Master dan Read Replica telah dikonfigurasi dengan benar.
  • Redis Streams dikonfigurasi dengan Consumer Groups untuk mendukung horizontal scaling pada worker sinkronisasi.
  • Mekanisme retry dan dead-letter handling aktif pada event consumer.
  • Monitoring latensi p95/p99 pada kueri baca dan tulis terintegrasi dengan APM (Elastic APM / Prometheus + Grafana).
  • Pengujian beban (load testing) telah dilakukan untuk memvalidasi batas toleransi eventual consistency.

Dengan menerapkan pemisahan tanggung jawab melalui pola CQRS, Redis Streams, dan manajemen Read Replica, aplikasi Node.js Anda kini siap menembus batasan performa tradisional, memberikan skalabilitas tinggi, latensi rendah, dan ketahanan sistem yang optimal di tingkat enterprise.

🏷️ Tags: #Mysql #System Architecture #Node.js #Cqrs #Redis Streams