CQRS: Bağımsız Ölçeklendirme için Ayrı Okuma ve Yazma
Klasik bir REST API, verileri okumak ve yazmak için aynı modeli kullanır. Ama ihtiyaçlar okuma ve yazma genellikle çok farklıdır: yazmalar tutarlılığı garanti etmeli ve karmaşık iş kurallarını doğrulamak; okumalar hızlı, ölçeklenebilir olmalı ve geri dönmelidir Kullanıcı arayüzü için optimize edilmiş formatlardaki veriler. Her iki ihtiyacı da onunla gidermeye çalışın aynı model uzlaşmalara yol açar: görünümler için karmaşık sorgular veya "şişirilmiş" modeller her türlü okumayı desteklemek.
CQRS (Komut Sorgusu Sorumluluk Ayrımı) açıkça ayırır yazma modeli (komut tarafı) okuma modelinden (sorgu tarafı). Komut tarafı, durumu değiştiren işlemleri (oluşturma, güncelleme, silme) yönetir; sorgu tarafı, genellikle optimize edilmiş veri modelleriyle okuma işlemlerini gerçekleştirir belirli uygulama görünümleri için. İkisi arasındaki senkronizasyon şu şekilde gerçekleşir: eşzamansız olaylar veya projeksiyonlar.
Ne Öğreneceksiniz
- CQRS mimarisi: komut tarafı, sorgu tarafı, eşzamansız senkronizasyon
- TypeScript'te doğrulamayla bir Komut İşleyicisi uygulama
- Projeksiyonlar: görünümler için optimize edilmiş Okuma Modelleri oluşturun
- Olaylar aracılığıyla eşzamansız senkronizasyon (Event Sourcing + CQRS)
- Olay Kaynaklandırması olmayan CQRS: Basitleştirilmiş hibrit model
- Bağımsız okuma ve yazma ölçeklendirmesi
- Takas: olası tutarlılık ve operasyonel karmaşıklık
CQRS mimarisi
CQRS'de her işlem veya bir Emretmek (durumunu değiştir) veya bir Sorgular (durumu değiştirmeden okur). Asla ikisi aynı anda yöntem (Bertrand Meyer'in CQS ilkesi):
// WRONG: un metodo che fa sia command che query
async function reserveInventory(productId: string, quantity: number): Promise<Stock> {
const stock = await db.getStock(productId);
stock.reserved += quantity; // MODIFICA
await db.saveStock(stock);
return stock; // LETTURA
}
// RIGHT CQRS: separa command e query
async function reserveInventory(productId: string, quantity: number): Promise<void> {
// Command: solo modifica, nessun ritorno di stato
const stock = await stockRepo.findById(productId);
stock.reserve(quantity);
await stockRepo.save(stock);
// Pubblica evento per aggiornare il read model
await eventBus.publish(new InventoryReservedEvent(productId, quantity));
}
async function getStockLevel(productId: string): Promise<StockLevelDto> {
// Query: legge dal read model ottimizzato, ZERO side effects
return await stockReadModel.findById(productId);
}
Komut Tarafı: Komuta Yönetimi
Komut tarafı Komutu (bir amacı tanımlayan değişmez nesneler) alır. geçerlidir, Toplama üzerinde iş mantığını yürütür ve olayları yayınlar. Desen Komut İşleyicisi ve bu akışı koordine eden bileşen:
// ---- COMMANDS ----
// Ogni command e un DTO immutabile
class PlaceOrderCommand {
constructor(
public readonly orderId: string,
public readonly customerId: string,
public readonly items: ReadonlyArray<{
productId: string;
quantity: number;
}>,
public readonly shippingAddress: Readonly<Address>
) {}
}
// ---- COMMAND HANDLER ----
class PlaceOrderCommandHandler {
constructor(
private readonly orderRepo: OrderRepository,
private readonly productRepo: ProductRepository,
private readonly eventBus: EventBus
) {}
async handle(command: PlaceOrderCommand): Promise<void> {
// 1. Validazione: prodotti esistono?
const products = await Promise.all(
command.items.map((item) => this.productRepo.findById(item.productId))
);
if (products.some((p) => p === null)) {
throw new Error('One or more products not found');
}
// 2. Crea l'Aggregate (logica business)
const order = new OrderAggregate();
order.create(command.orderId, command.customerId);
for (let i = 0; i < command.items.length; i++) {
const product = products[i]!;
order.addItem(
command.items[i].productId,
command.items[i].quantity,
product.price
);
}
order.confirm();
// 3. Persisti gli eventi generati dall'Aggregate
await this.orderRepo.save(order);
// 4. Pubblica gli eventi per aggiornare il read model e notificare altri servizi
const events = order.getUncommittedEvents();
await this.eventBus.publishAll(events);
}
}
// ---- COMMAND BUS ----
// Dispatcher che instrada i command all'handler corretto
class CommandBus {
private handlers = new Map<string, (cmd: unknown) => Promise<void>>();
register<T>(commandType: string, handler: (cmd: T) => Promise<void>): void {
this.handlers.set(commandType, handler as (cmd: unknown) => Promise<void>);
}
async dispatch<T>(commandType: string, command: T): Promise<void> {
const handler = this.handlers.get(commandType);
if (!handler) {
throw new Error(`No handler registered for command: ${commandType}`);
}
await handler(command);
}
}
// Setup
const commandBus = new CommandBus();
commandBus.register('PlaceOrder', (cmd: PlaceOrderCommand) =>
placeOrderHandler.handle(cmd)
);
Sorgu Tarafı: Modeli ve Projeksiyonları Okuyun
Sorgu tarafı bir tutar Modeli Oku: verilerin bir temsili belirli uygulama sorguları için optimize edilmiştir. Komut tarafı ile çalışırken Toplama, sorgu tarafı aracılığıyla oluşturulan denormalize görünümlerle çalışır. Projeksiyonlar.
// ---- READ MODEL ----
// Viste denormalizzate ottimizzate per le query dell'UI
// View per la lista ordini: include dati del cliente + totale + stato
interface OrderListItemReadModel {
orderId: string;
customerName: string; // denormalizzato (non serve join)
customerEmail: string;
totalAmount: number;
currency: string;
status: string;
itemCount: number;
placedAt: string;
}
// View per il dettaglio ordine
interface OrderDetailReadModel {
orderId: string;
customerId: string;
customerName: string;
items: Array<{
productId: string;
productName: string; // denormalizzato
quantity: number;
unitPrice: number;
subtotal: number;
}>;
totalAmount: number;
shippingAddress: Address;
status: string;
statusHistory: Array<{ status: string; changedAt: string }>;
estimatedDelivery?: string;
}
// ---- PROIEZIONE ----
// Aggiorna il read model in risposta agli eventi del command side
class OrderReadModelProjection {
constructor(
private readonly readModelDb: ReadModelDatabase,
private readonly customerRepo: CustomerReadRepository
) {}
// Quando un ordine viene confermato, crea/aggiorna le view nel read model
async onOrderConfirmed(event: OrderConfirmedEvent): Promise<void> {
// Carica i dati aggiuntivi necessari per la view denormalizzata
const customer = await this.customerRepo.findById(event.customerId);
const orderState = await this.orderRepo.findById(event.orderId);
// Inserisce/aggiorna la view della lista ordini
await this.readModelDb.upsert('order_list_view', {
order_id: event.orderId,
customer_name: customer.fullName,
customer_email: customer.email,
total_amount: orderState.totalAmount,
currency: orderState.currency,
status: 'Confirmed',
item_count: orderState.items.size,
placed_at: orderState.createdAt,
});
// Inserisce la view di dettaglio
const itemsWithNames = await this.enrichItemsWithProductNames(orderState.items);
await this.readModelDb.upsert('order_detail_view', {
order_id: event.orderId,
// ... tutti i campi della view dettaglio
items: JSON.stringify(itemsWithNames),
status_history: JSON.stringify([
{ status: 'Confirmed', changedAt: event.occurredAt }
]),
});
}
async onOrderCancelled(event: OrderCancelledEvent): Promise<void> {
// Aggiorna solo lo status nella view
await this.readModelDb.update('order_list_view',
{ order_id: event.orderId },
{ status: 'Cancelled' }
);
// Aggiungi alla status history nella view dettaglio
await this.readModelDb.appendToJsonArray('order_detail_view',
{ order_id: event.orderId },
'status_history',
{ status: 'Cancelled', changedAt: event.occurredAt }
);
}
}
// ---- QUERY HANDLER ----
class OrderQueryHandler {
constructor(private readonly readModelDb: ReadModelDatabase) {}
// Query velocissima sul read model denormalizzato
async getOrderList(customerId: string, page: number, pageSize: number):
Promise<OrderListItemReadModel[]>
{
return this.readModelDb.query(
`SELECT * FROM order_list_view
WHERE customer_id = $1
ORDER BY placed_at DESC
LIMIT $2 OFFSET $3`,
[customerId, pageSize, page * pageSize]
);
}
async getOrderDetail(orderId: string): Promise<OrderDetailReadModel | null> {
return this.readModelDb.queryOne(
'SELECT * FROM order_detail_view WHERE order_id = $1',
[orderId]
);
}
}
Olay Kaynağı olmadan CQRS
CQRS ve Event Sourcing birbirine diktir: birlikte iyi çalışırlar ancak kullanılabilirler ayrı ayrı. Event Sourcing olmadan basitleştirilmiş CQRS modeli veritabanını kullanır yazma işlemleri için ana veritabanı ve okuma işlemleri için ayrı bir veritabanı (veya gerçekleştirilmiş görünümler):
// CQRS semplificato: stesso database, modelli separati
// Write side usa le entita ORM normali
// Read side usa query SQL ottimizzate o viste materializzate
// View materializzata PostgreSQL per la lista ordini
CREATE MATERIALIZED VIEW order_list_view AS
SELECT
o.id AS order_id,
c.full_name AS customer_name,
c.email AS customer_email,
o.total_amount,
o.currency,
o.status,
COUNT(oi.id) AS item_count,
o.created_at AS placed_at
FROM orders o
JOIN customers c ON c.id = o.customer_id
LEFT JOIN order_items oi ON oi.order_id = o.id
GROUP BY o.id, c.full_name, c.email;
-- Refresh automatico della vista materializzata
CREATE INDEX idx_order_list_customer ON order_list_view (customer_email);
-- Con PostgreSQL 17: INCREMENTAL REFRESH (solo le righe cambiate)
-- REFRESH MATERIALIZED VIEW CONCURRENTLY order_list_view;
-- Query sul read model: 100x piu veloce di una query con JOIN
SELECT * FROM order_list_view
WHERE customer_email = 'mario@example.com'
ORDER BY placed_at DESC
LIMIT 20;
Bağımsız Ölçeklendirme Okuma ve Yazma
CQRS ile okuma ve yazma taraflarını bağımsız olarak ölçeklendirebilirsiniz. Eğer sistem Yazma sayısından 100 kat daha fazla okuma varsa (bir e-ticaret için tipiktir), şunları elde edebilirsiniz:
- Tarafı yaz: PostgreSQL birincil veritabanına sahip 2 örnek
- Tarafı okuyun: Redis önbelleği + salt okunur PostgreSQL çoğaltması ile 10 örnek
// Architettura di scaling con Kubernetes
# command-side-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-command-service
spec:
replicas: 2 # write side: pochi ma consistenti
template:
spec:
containers:
- name: api
image: company/order-service:v1
env:
- name: DB_URL
value: "postgres://primary-db:5432/orders" # database primario
- name: SERVICE_MODE
value: "command"
---
# query-side-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-query-service
spec:
replicas: 10 # read side: molte repliche, stateless
template:
spec:
containers:
- name: api
image: company/order-service:v1
env:
- name: DB_URL
value: "postgres://read-replica:5432/orders" # replica read-only
- name: REDIS_URL
value: "redis://cache:6379"
- name: SERVICE_MODE
value: "query"
Ön Uçta Nihai Tutarlılığı Yönetin
Eşzamansız senkronizasyonlu CQRS'de kısa bir süre vardır (tipik olarak milisaniye) okuma modelinin bir yazma işleminden sonra henüz güncellenmediği yer. Ön ucun idare etmesi gerekiyor bu doğru:
// Pattern 1: Optimistic Update
// Il frontend aggiorna l'UI immediatamente, senza aspettare la query
async function placeOrder(orderData: PlaceOrderDto) {
// 1. Ottimisticamente aggiorna l'UI locale
dispatch({ type: 'ADD_ORDER_OPTIMISTIC', order: { ...orderData, status: 'Pending' } });
try {
// 2. Invia il command al backend
const response = await api.placeOrder(orderData);
// 3. Dopo N ms, ricarica dal read model (che dovrebbe essere aggiornato)
setTimeout(async () => {
const updatedOrder = await api.getOrder(response.orderId);
dispatch({ type: 'UPDATE_ORDER', order: updatedOrder });
}, 500);
} catch (error) {
// 4. In caso di errore, reverta l'ottimistic update
dispatch({ type: 'REVERT_ORDER_OPTIMISTIC' });
throw error;
}
}
// Pattern 2: Poll finche il read model non e aggiornato
async function pollUntilUpdated(orderId: string, expectedStatus: string) {
const MAX_ATTEMPTS = 10;
const DELAY_MS = 200;
for (let i = 0; i < MAX_ATTEMPTS; i++) {
const order = await api.getOrder(orderId);
if (order.status === expectedStatus) return order;
await new Promise(resolve => setTimeout(resolve, DELAY_MS));
}
throw new Error('Read model not updated within expected time');
}
CQRS takası
Faydalar
- Bağımsız ölçeklendirme: Gerçek yüke göre tarafı okuyun ve yan ölçeği ayrı ayrı yazın
- Optimize edilmiş şablonlar: Okuma modeli, karmaşık sorguları ortadan kaldırarak tam olarak görünüm için denormalize edilebilir
- Yüksek okuma performansı: Okuma modelindeki sorgular, önceden hesaplanmış tablolardaki basit SELECT'lerdir
- Sorumlulukların ayrılması: Kod yazma ve okuma birbirini kirletmez
Yönetilmesi Karmaşık
- Olası tutarlılık: Okuma modeli, yazma modellerinin biraz gerisinde kalabilir. Ön uç bunu halletmeli
- Mantık Çoğaltma: Bazı doğrulamaların hem komut işleyicide hem de okuma modelinde olması gerekebilir
- Dağıtılacak daha fazla bileşen: Komut hizmeti, sorgulama hizmeti, projeksiyonlar, model veritabanını okuma
- Basit CRUD için uygun değildir: Karmaşık iş mantığı olmayan operasyonlar için CQRS'in karmaşıklığı buna değmez
Sonuçlar ve Sonraki Adımlar
CQRS, okuma ve yazma yüklerine sahip sistemler için en etkili mimarilerden biridir çok farklı. Komut tarafının açık bir şekilde ayrılması (tutarlı, doğrulanmış, olaya dayalı) sorgu tarafından (hızlı, denormalize, ölçeklenebilir) tek modelin ödünleşimlerini çözer.
Sonraki makale Event Sourcing ve CQRS'yi bir araya getiriyor: projeksiyonların nasıl olduğunu göreceğiz okuma modelini oluşturmak için etkinlik mağazasından okurlar, yeniden denemeyle projeksiyonların nasıl yönetileceğini hata durumunda ve okuma modelinin yeniden oluşturulmasını optimize etmek için anlık görüntülerin nasıl kullanılacağı bir başarısızlıktan sonra.
Olay Odaklı Mimari Serisinde Gelecek Makaleler
İlgili Seriler
- Etkinlik Kaynak Kullanımı — CQRS'nin doğal tamamlayıcısı
- Pratik Yazılım Mimarisi — genel mimari bağlamında CQRS'nin nereye yerleştirileceği







