সরাসরি পর্বে যান
পর্ব ৩৫ · ৬৬-এর মধ্যে · মডিউল ৬

Event sourcing আর CQRS — event থেকে state আর আলাদা read model

Event Sourcing & CQRS — State from Events, Separate Read Models

Taught in Bangla, this lesson contrasts storing state with storing events using a wallet and bus-seat example, walks through commands, events, aggregates and folds, optimistic concurrency with expected versions, replay cost and the optimal snapshot interval with a short proof, event versioning and upcasting, CQRS projections, read-model lag and read-your-writes, and when event sourcing pays off versus becoming over-engineering.

এই পর্বে যা শিখবেন

  • State রাখা আর event রাখার পার্থক্য — একটি wallet আর একটি বাসের সিটের উদাহরণে
  • Command → event → state-এর পথ, fold, আর expected version দিয়ে একই stream-এ সংঘাত ঠেকানো
  • Snapshot কত পরপর রাখবেন — replay-র খরচ নিজে হিসাব করা, সর্বোত্তম interval-এর ছোট প্রমাণসহ
  • Event-এর গঠন বদলালে upcasting; CQRS-এর projection, rebuild আর নতুন read model পেছন থেকে বানানো
  • কখন এই নকশা সত্যিই কাজে লাগে, আর কখন এটি অকারণ জটিলতা

১ · খাতার শেষ লাইন, না পুরো খাতা — state রাখা বনাম ঘটনা রাখা

পাড়ার মুদি দোকানের বাকির খাতাটি ভাবুন। দোকানি কখনো লেখেন না “রহিম সাহেবের বাকি এখন ৭৫০ টাকা” আর আগের লাইন কেটে দেন না। তিনি লেখেন: “৩ তারিখ — চাল, ৫০০ টাকা”, “৮ তারিখ — তেল, ৪০০ টাকা”, “১২ তারিখ — নগদ জমা ১৫০ টাকা”। মোট বাকি জানতে চাইলে তিনি লাইনগুলো যোগ-বিয়োগ করেন। কোনো লাইন ভুল হলে সেটি ঘষে মোছেন না — নিচে একটি সংশোধনী লাইন লেখেন। মাস শেষে ঝগড়া হলে খাতা খুলে দেখানো যায় কোন দিন কী হয়েছিল।

সফটওয়্যারের বেশিরভাগ ডেটাবেস কিন্তু উল্টোটা করে। একটি mobile wallet সেবার (কাল্পনিক) accounts টেবিলে একটি সারি থাকে: A, balance = 1450. টাকা তুললে সারিটি UPDATE হয়ে যায় — আগের মান হারিয়ে যায়। একে বলে state-oriented নকশা: ডেটাবেস জানে এখন কী আছে, কিন্তু কীভাবে এখানে এলো, তা জানে না।

State রাখা (সাধারণ CRUD)Event রাখা (event sourcing)
A · balance 1450 · version 6v1 Deposited 5000 · v2 Withdrew 1200 · v3 Transferred 800 → B · v4 Deposited 300 · v5 Deposited 150 · v6 Withdrew 2000
প্রতিটি বদলে পুরোনো মান মুছে নতুন মানপুরোনো লাইন কখনো বদলায় না, শুধু শেষে নতুন লাইন যোগ হয় (append-only)
“১৫ আগস্টে balance কত ছিল?” — জানা নেই, আলাদা log না রাখলে১৫ আগস্ট পর্যন্ত event যোগ করলেই উত্তর
পড়া সস্তা: একটি সারিপড়তে হলে event যোগ করতে হয় — অথবা আগে থেকে হিসাব করা একটি read model লাগে

এবার একটি বাসের সিট। ঈদের আগে একটি দূরপাল্লার বাসের (কাল্পনিক অপারেটর) সিট A3-এর state-oriented সারিতে আছে A3 · BOOKED · যাত্রী: নাসরিন. গ্রাহকসেবায় আরেকজন যাত্রী ফোন করে বললেন, “আমি তো সকালে এই সিটই কিনেছিলাম!” সারিটি কিছুই বলতে পারে না। Event রাখা নকশায় সিটের stream-এ থাকে: SeatHeld(রবিন, ০৯:০২), HoldExpired(০৯:১৭), SeatHeld(নাসরিন, ০৯:১৮), SeatBooked(নাসরিন, ০৯:২০). রবিনের hold-এর মেয়াদ ১৫ মিনিটে ফুরিয়েছিল, টাকা কাটেনি — এক নজরে উত্তর মেলে।

মূল ধারণা

Event sourcing মানে ব্যবসায় যা ঘটেছে, তার প্রতিটি ঘটনাকে (domain event) পরিবর্তনহীন রেকর্ড হিসেবে ক্রমানুসারে জমা রাখা, আর এই ঘটনাগুলোকেই ডেটার আসল উৎস (source of truth) ধরা। বর্তমান অবস্থা কোনো টেবিলে “রাখা” থাকে না — ঘটনাগুলো থেকে হিসাব করে বের করা হয়। অবস্থার টেবিল থাকলেও সেটি একটি কপি, যা যেকোনো সময় মুছে আবার বানানো যায়।

এই ধারণা নতুন নয়। ব্যাংকের ledger ঠিক এভাবে কাজ করে — হিসাবরক্ষকেরা কয়েকশো বছর ধরে লেনদেনের লাইন জমিয়ে balance বের করেন (অংশ ৯-এ ফিরব)। ডেটাবেসের ভেতরেও একই কৌশল: পর্ব ১৯-এর write-ahead log প্রতিটি বদল আগে log-এ লেখে, টেবিলটি পরে সেই log থেকেই মেলানো যায়। Event sourcing এই log-কে ডেটাবেসের ভেতর থেকে বের করে এনে ব্যবসার ভাষায় লেখে — “page 812-এর byte বদলেছে” নয়, “রহিম ৮০০ টাকা পাঠিয়েছেন”।

“Event Sourcing” নামটি জনপ্রিয় হয় মোটামুটি ২০০৫ সালে, Martin Fowler-এর একটি প্রবন্ধের মাধ্যমে; Domain-Driven Design-এর চর্চাকারীদের মধ্যে কৌশলটি তার আগেও প্রচলিত ছিল। CQRS (Command Query Responsibility Segregation) নামটি মূলত Greg Young-এর, ২০০৯–২০১০ সালের আশপাশে তাঁর লেখা আর বক্তৃতায়; Udi Dahan-এর “Clarified CQRS” (প্রায় ২০০৯) লেখাটিও ধারণাটি ছড়াতে বড় ভূমিকা রাখে। এর শিকড় Bertrand Meyer-এর command-query separation নীতিতে (১৯৮৮ সালের বই “Object-Oriented Software Construction”). তারিখগুলো সূত্রভেদে সামান্য আলাদা পাওয়া যায়।

২ · Event sourcing-এর যন্ত্র — command, event, aggregate আর fold

চারটি শব্দ বুঝলেই পুরো যন্ত্রটি পরিষ্কার।

Command — অনুরোধ

“A থেকে ১,২০০ টাকা তুলুন” — একটি command. এটি ভবিষ্যতের ইচ্ছা, তাই প্রত্যাখ্যানও হতে পারে। নাম সাধারণত আদেশের রূপে: Withdraw.

হতে পারে, না-ও হতে পারে

Event — ঘটে যাওয়া সত্য

নিয়ম মেনে command গৃহীত হলে লেখা হয় Withdrew 1200. নাম অতীত কালে, কারণ এটি ঘটে গেছে — একে আর অস্বীকার করা যায় না, শুধু নতুন event দিয়ে সংশোধন করা যায়।

অপরিবর্তনীয়

Aggregate — নিয়মের সীমানা

যে জিনিসের নিয়ম একসাথে রক্ষা করতে হয় — একটি wallet, একটি বাস-ট্রিপের সিট-তালিকা। প্রতিটি aggregate-এর নিজের একটি event stream, যেমন wallet-A.

এক stream = এক সংগতির একক

Fold — হিসাব মেলানো

শূন্য অবস্থা থেকে শুরু করে প্রতিটি event একে একে প্রয়োগ: state = events.reduce(apply, initial). এই fold-ই মুদি দোকানির যোগ-বিয়োগ।

একই event, একই ফল

গাণিতিকভাবে লিখলে: s0 = প্রাথমিক অবস্থা, আর sn = apply(sn−1, en). তাই sn = apply(…apply(apply(s0, e1), e2)…, en). এর থেকে দুটি কঠোর শর্ত আসে:

  • apply হবে বিশুদ্ধ (pure): শুধু পুরোনো অবস্থা আর event দেখে নতুন অবস্থা ফেরত দেবে — ঘড়ি দেখবে না, random সংখ্যা নেবে না, অন্য সেবাকে ডাকবে না। তাহলেই আজ আর দশ বছর পরে একই event থেকে একই অবস্থা আসবে।
  • সিদ্ধান্ত আর side effect থাকবে command-এর ধাপে: “balance যথেষ্ট কি না” যাচাই হয় command নেওয়ার সময়; SMS পাঠানো হয় event লেখার পরে, আলাদা কোনো subscriber থেকে। apply-এর ভেতরে SMS থাকলে প্রতিটি rebuild-এ গ্রাহক পুরোনো সব SMS আবার পেতেন।

নিচের stepper-এ একটি wallet-এর চারটি command আর শেষে একটি পুরো rebuild দেখুন। খেয়াল করুন, প্রত্যাখ্যাত command কোনো event রেখে যায় না।

Wallet A-এর stream আর অবস্থা
Command
নিয়ম যাচাই
নতুন event
Stream-এ মোট
Balance (fold)
  1. প্রথম জমা। শূন্য অবস্থা থেকে শুরু। জমায় কোনো শর্ত নেই, তাই Deposited 5000 লেখা হলো version ১-এ। Balance ৫,০০০ — কিন্তু এটি কোথাও আলাদা করে লেখা হয়নি, event থেকে গোনা।
  2. টাকা তোলা। Command নেওয়ার আগে বর্তমান অবস্থা বের করা হলো (৫,০০০), নিয়ম “balance ≥ পরিমাণ” মিলল, তাই Withdrew 1200 version ২-এ। Balance ৩,৮০০.
  3. প্রত্যাখ্যান। ৯,০০০ টাকা তোলার অনুরোধ, কিন্তু balance ৩,৮০০. নিয়ম ভাঙে, তাই কোনো event লেখা হলো না — stream-এ এখনো ২টি event. প্রত্যাখ্যানের খবর চাইলে আলাদা log-এ রাখা যায়, কিন্তু wallet-এর ইতিহাসে এটি “ঘটনা” নয়।
  4. B-কে পাঠানো। ৩,৮০০ ≥ ৮০০, তাই Transferred 800 (out) version ৩-এ; balance ৩,০০০. B-এর stream-এ আলাদা একটি Transferred 800 (in) লাগবে — দুটি আলাদা stream-এ লেখা, তাই একে saga হিসেবে চালাতে হয় (পর্ব ২৫)।
  5. পুরো rebuild. সার্ভার restart হলো, memory-তে কিছু নেই। Stream-এর ৩টি event আবার fold করা হলো: ০ → ৫,০০০ → ৩,৮০০ → ৩,০০০. একই event, একই ক্রম, একই ফল — এটিই replay.

Event-এর নাম ব্যবসার ভাষায়: WalletUpdated {balance: 3000} লিখলে আপনি আসলে state-ই লিখছেন, শুধু অন্য নামে — কেন বদলাল, তা হারিয়ে যায়। Withdrew, Transferred, FeeCharged — প্রতিটি নাম একটি ব্যবসায়িক উদ্দেশ্য বলে। পরে নতুন প্রশ্ন (“মাসে কত fee কাটা হলো?”) এলে এই নামগুলোই উত্তর দেয়।

৩ · Event store আর expected version — একই stream-এ দুজন লেখক

Event store হলো event রাখার ডেটাবেস। তার কাছে মূলত দুটি কাজ চাওয়া হয়: (১) একটি stream-এর সব event ক্রমানুসারে পড়া, আর (২) একটি stream-এর শেষে নতুন event যোগ করা — একটি শর্তসহ। কোনো UPDATE নেই, সাধারণত DELETE-ও নেই। আলাদা পণ্য আছে (যেমন open-source EventStoreDB), আবার PostgreSQL-এর মতো সাধারণ relational ডেটাবেসেও একটি টেবিল দিয়ে কাজটি ভালোভাবে চলে।

শর্তটি কেন? ধরুন wallet A-তে এখন version ৪, balance ৩,৩০০. দুটি app সার্ভার প্রায় একসাথে দুটি command পেল — একটিতে ২,০০০ তোলা, অন্যটিতে ২,৫০০. দুজনেই stream পড়ে দেখল ৩,৩০০, দুজনেরই নিয়ম মিলল। দুজনেই event যোগ করলে balance হবে −১,২০০ — নিয়ম ভাঙল, যদিও প্রত্যেকে আলাদাভাবে ঠিক কাজ করেছে। এটি পর্ব ২৪-এর lost update-এরই আরেক রূপ।

সমাধান optimistic concurrency, stream-এর ওপর। প্রতিটি append-এর সাথে client বলে: “আমি version ৪ দেখে এই সিদ্ধান্ত নিয়েছি — stream এখনো ৪-এ থাকলে তবেই লিখুন।” একে বলে expected version. Store মিলিয়ে দেখে; না মিললে লেখা প্রত্যাখ্যান করে, আর client stream আবার পড়ে, নিয়ম আবার যাচাই করে। ব্যাংকের কাউন্টারে টোকেনের মতো: আপনার টোকেন নম্বর ডাকার আগেই অন্য কেউ সেই কাউন্টারে বসে গেলে আপনাকে আবার নতুন করে ডাকা হবে।

Wallet A, দুটি app সার্ভার
Store-এ A
সার্ভার ১
সার্ভার ২
ফল
  1. একই ছবি, দুই হাতে। দুটি সার্ভার প্রায় একই মুহূর্তে stream পড়ল — দুজনেই দেখল version ৪, balance ৩,৩০০. কোনো lock নেওয়া হয়নি; পড়া সস্তা ও মুক্ত।
  2. সার্ভার ১ আগে লিখল। ৩,৩০০ ≥ ২,০০০, তাই সে Withdrew 2000 পাঠাল, expected version ৪. Store দেখল stream সত্যিই ৪-এ, তাই event version ৫ হিসেবে লেখা হলো। Balance এখন ১,৩০০. সার্ভার ২ তার পুরোনো ছবিতে এখনো ৩,৩০০ দেখছে।
  3. সার্ভার ২-এর লেখা আটকাল। সে-ও expected version ৪ পাঠাল, কিন্তু store-এ এখন ৫. Store লেখাটি প্রত্যাখ্যান করল — কোনো event যোগ হলো না।
  4. আবার পড়া, আবার যাচাই। সার্ভার ২ stream নতুন করে পড়ল: version ৫, balance ১,৩০০. নিয়ম আবার চালাল — ১,৩০০ ≥ ২,৫০০ নয়, তাই command প্রত্যাখ্যাত, গ্রাহক জানলেন “অপর্যাপ্ত balance”. শুধু আবার লেখার চেষ্টা নয়, নিয়ম আবার যাচাই — এটিই গুরুত্বপূর্ণ।
  5. যাচাই না থাকলে। Store যদি expected version না দেখত, দ্বিতীয় লেখাটিও version ৬ হয়ে যেত: ৩,৩০০ − ২,০০০ − ২,৫০০ = −১,২০০. প্রতিটি সার্ভার নিজের চোখে ঠিক ছিল, কিন্তু মিলিত ফল ভুল।

Relational ডেটাবেসে এই শর্তের সবচেয়ে সহজ রূপ একটি UNIQUE constraint: UNIQUE (stream_id, version). একই stream-এ একই version দুবার থাকতে পারবে না, তাই দুজন একই expected version থেকে version = 5 লিখতে গেলে দ্বিতীয়জন ব্যর্থ হবেই — app-এর কোনো ভুল এটি এড়াতে পারে না। নিচের live SQL-এ চালিয়ে দেখুন; শেষ লাইনটি ইচ্ছা করে ব্যর্থ হয়:

-- Two servers read wallet A at version 4 (balance 3300). Each tries to append version 5.
-- Server 1 appends first and wins.
INSERT INTO events (stream_id, version, type, amount, at) VALUES ('A', 5, 'Withdrew', 2000, '2026-09-15');
-- Server 2 uses the same expected version; ON CONFLICT turns the clash into "0 rows".
INSERT INTO events (stream_id, version, type, amount, at) VALUES ('A', 5, 'Withdrew', 2500, '2026-09-15')
ON CONFLICT (stream_id, version) DO NOTHING;
-- Server 2 reloads: version and balance have moved, so it must re-check its rule.
SELECT MAX(version) AS version,
       SUM(CASE WHEN type = 'Deposited' OR dir = 'in' THEN amount ELSE -amount END) AS balance
FROM events WHERE stream_id = 'A';
-- Without the reload, a plain INSERT at the stale version is refused by the database itself:
INSERT INTO events (stream_id, version, type, amount, at) VALUES ('A', 5, 'Withdrew', 2500, '2026-09-15');

প্রথম INSERT-এ ১টি সারি বদলেছে — সার্ভার ১ version ৫ পেল। দ্বিতীয়টিতে ON CONFLICT ... DO NOTHING থাকায় error নয়, ০টি সারি বদলেছে — app এই “শূন্য” দেখেই বোঝে conflict হয়েছে। তারপর নতুন করে পড়া: version 5, balance 1300, তাই ২,৫০০ তোলার নিয়ম আর মেলে না। শেষ লাইনে পুরোনো version-এ সরাসরি INSERT, আর ডেটাবেস নিজেই থামাল: UNIQUE constraint failed: events.stream_id, events.version. এটি single-node SQLite — একটি টেবিলের ভেতরের শর্ত দেখায়; কয়েকটি shard জুড়ে stream থাকলে প্রতিটি stream যেন একটিই shard-এ থাকে, সেটি নিশ্চিত করতে হয় (পর্ব ২৩)।

Concurrency-র সীমানা stream-এর সীমানা। Expected version শুধু একটি stream রক্ষা করে। “একজন গ্রাহকের সব wallet মিলিয়ে দিনে ৫০,০০০ টাকার বেশি নয়” — এমন নিয়ম কয়েকটি stream জুড়ে, তাই আলাদা নকশা লাগে: হয় নিয়মটিকে একটি stream-এ আনা (গ্রাহকের একটি “দৈনিক সীমা” aggregate), নয়তো saga বা পরে মিলিয়ে দেখা। Aggregate-এর সীমানা ঠিক করাই event sourcing-এর সবচেয়ে কঠিন নকশা-সিদ্ধান্ত।

৪ · Rebuild আর snapshot — replay-র দাম হিসাব

প্রতিটি command-এর আগে বর্তমান অবস্থা লাগে, আর অবস্থা আসে fold থেকে। একটি wallet-এ দিনে গড়ে ১০টি event হলে তিন বছরে প্রায় ১০,৯৫০টি। প্রতিবার সবগুলো পড়া কি সম্ভব? হিসাব করে দেখি। ধরা যাক একটি event store থেকে পড়ে প্রয়োগ করতে r = ২০ µs লাগে (অনুমান)।

Snapshot ছাড়া: ১০,৯৫০ × ২০ µs = ২১৯ ms — প্রতিটি command-এ

এক command-এর আগে ২১৯ ms শুধু অতীত পড়তে — অগ্রহণযোগ্য। সমাধান snapshot: প্রতি k-টি event পরপর ওই মুহূর্তের অবস্থা (version আর balance) আলাদা করে লিখে রাখা। লোড করার সময় সর্বশেষ snapshot পড়ে শুধু তার পরের event-গুলো fold করা হয়। Snapshot একটি cache মাত্র — হারালে বা ভুল হলে মুছে event থেকে আবার বানানো যায়; আসল সত্য event-ই।

হিসাবটি সংখ্যায়

Stream-এ L-টি event, snapshot প্রতি k-টিতে, snapshot পড়তে c, লিখতে S সময়। তাহলে:

  • Replay-র event সংখ্যা = L mod k. সবচেয়ে খারাপ অবস্থায় k − ১, গড়ে মোটামুটি (k − ১)/২ (শেষ snapshot-এর পরে কোথায় আছেন, তা ০ থেকে k − ১-এর মধ্যে সমান সম্ভাবনায় ধরে)।
  • উদাহরণ: L = ১০,৯৫০, k = ১০০, c = ১ ms (অনুমান)। L mod k = ৫০, তাই এখন ১ + ৫০ × ০.০২ = ২ ms. সবচেয়ে খারাপে ১ + ৯৯ × ০.০২ = ২.৯৮ ms, গড়ে ১ + ৪৯.৫ × ০.০২ ≈ ১.৯৯ ms. ২১৯ ms থেকে প্রায় ১০০ গুণ কম।
  • দাম: প্রতি k-টি append-এ একটি বাড়তি snapshot লেখা — তিন বছরে ⌊১০,৯৫০ / ১০০⌋ = ১০৯টি, যদিও সাধারণত শুধু সর্বশেষটিই রাখা হয়।

সর্বোত্তম k — একটি ছোট প্রমাণ

ধরুন প্রতিটি command আগে stream লোড করে (memory-তে কিছু রাখা হয় না)। তাহলে প্রতি command-এ গড় খরচ = replay (r × (k − ১)/২) + snapshot লেখার ভাগ (S/k), সঙ্গে snapshot পড়ার স্থির খরচ c:

C(k) = c + r(k − ১)/২ + S/k  →  dC/dk = r/২ − S/k² = ০  →  k* = √(২S/r)

দ্বিতীয় অন্তরজ ২S/k³ > ০, তাই এটি সত্যিই সর্বনিম্ন। S = ২ ms, r = ০.০২ ms হলে k* = √২০০ ≈ ১৪; তখন বাড়তি খরচ প্রায় ০.২৭ ms, যেখানে k = ১০০-তে প্রায় ১.০১ ms. ছোট k-তে বারবার snapshot লেখার খরচ, বড় k-তে বেশি replay-র খরচ — মাঝখানে বর্গমূলের নিয়ম। বাস্তবে aggregate প্রায়ই memory-তে cache থাকে, তখন replay শুধু ঠান্ডা শুরুতে (restart, নতুন সার্ভার) হয় — আর snapshot লেখা প্রায়ই command-এর পথ থেকে সরিয়ে পেছনের একটি কাজে দেওয়া হয়। তাই সূত্রটি শুরু-বিন্দু, চূড়ান্ত উত্তর নয়; মাপুন।

এবার পুরো wallet-টি একটি simulator-এ: তিনটি event type, fold, প্রতিটি append-এ expected version-এর যাচাই, আর প্রতি ৪টি event-এ snapshot:

// Event-sourced wallet: balance = fold(events). Expected-version check + a snapshot every N events.
const N = 4, store = [], snaps = {};     // store: append-only list of events; snaps: latest per stream
function apply(bal, e) {                 // pure: old state + one event -> new state
  if (e.type === "Deposited") return bal + e.amt;
  if (e.type === "Withdrew") return bal - e.amt;
  return e.dir === "out" ? bal - e.amt : bal + e.amt;            // Transferred
}
function load(id, useSnap = true) {      // rebuild current state; count how many events were replayed
  const s = (useSnap && snaps[id]) || { version: 0, bal: 0 };
  const evs = store.filter((e) => e.stream === id && e.version > s.version);
  return { version: s.version + evs.length, bal: evs.reduce(apply, s.bal), replayed: evs.length };
}
function append(id, expected, e) {      // the store refuses if someone appended after we read
  const cur = load(id).version;
  if (cur !== expected) throw new Error(`conflict on ${id}: expected v${expected}, found v${cur}`);
  store.push({ ...e, stream: id, version: cur + 1 });
  if ((cur + 1) % N === 0) snaps[id] = { version: cur + 1, bal: load(id).bal };
}
function command(id, type, amt, extra = {}) {   // read -> check the rule -> append with expected version
  const s = load(id);
  if ((type === "Withdrew" || extra.dir === "out") && s.bal < amt) return console.log(`REJECTED ${type} ${amt} on ${id}: balance ${s.bal}`);
  append(id, s.version, { type, amt, ...extra });
}
command("A", "Deposited", 5000); command("A", "Withdrew", 1200);
command("A", "Transferred", 800, { dir: "out" }); command("B", "Transferred", 800, { dir: "in" });
command("A", "Deposited", 300); command("A", "Withdrew", 9000); command("A", "Deposited", 150);
const c1 = load("A"), c2 = load("A");          // two app servers read A at the same version
append("A", c1.version, { type: "Withdrew", amt: 2000 });
try { append("A", c2.version, { type: "Withdrew", amt: 2500 }); } catch (err) { console.log(err.message); }
command("A", "Withdrew", 2500);                // c2 reloads, re-checks the rule, then retries
for (const id of ["A", "B"]) {
  const a = load(id), b = load(id, false);
  console.log(`${id}: v${a.version} balance=${a.bal} | snapshot v${snaps[id] ? snaps[id].version : 0} -> replayed ${a.replayed} | no snapshot -> replayed ${b.replayed}, balance=${b.bal}`);
}
console.log("events in store: " + store.length + ", without the version check A would be " + (c2.bal - 2000 - 2500));

আউটপুট:

REJECTED Withdrew 9000 on A: balance 3300
conflict on A: expected v5, found v6
REJECTED Withdrew 2500 on A: balance 1450
A: v6 balance=1450 | snapshot v4 -> replayed 2 | no snapshot -> replayed 6, balance=1450
B: v1 balance=800 | snapshot v0 -> replayed 1 | no snapshot -> replayed 1, balance=800
events in store: 7, without the version check A would be -1050

৯,০০০ তোলার command ৩,৩০০ balance দেখে প্রত্যাখ্যাত, কোনো event নেই। তারপর দুটি সার্ভার একই version ৫ দেখে লিখতে গেল; প্রথমটি v6 পেল, দ্বিতীয়টি conflict on A: expected v5, found v6. দ্বিতীয়জন আবার পড়ে দেখল balance ১,৪৫০ — ২,৫০০ তোলা যায় না, তাই প্রত্যাখ্যান। শেষ লাইন দেখাচ্ছে যাচাই না থাকলে balance হতো −১,০৫০. লোডের সময় snapshot (v4) থেকে মাত্র ২টি event replay, snapshot ছাড়া ৬টি — ফল একই ১,৪৫০. B-এর মাত্র ১টি event, তাই snapshot এখনো তৈরি হয়নি। খেয়াল করুন, A-এর transfer আর B-এর জমা দুটি আলাদা append — মাঝখানে crash হলে অর্ধেক কাজ থাকে; বাস্তবে এটি saga বা একটি transfer aggregate দিয়ে সামলাতে হয় (পর্ব ৬০-এ ledger-সহ পুরো নকশা)।

৫ · Event-এর গঠন বদলায় — versioning আর upcasting

State-oriented ডেটাবেসে কলাম বদলালে একটি migration চালিয়ে সব সারি নতুন রূপে আনা যায়। Event store-এ নিয়ম উল্টো: event পরিবর্তনহীন, তিন বছর আগের Deposited {amt: 5000} চিরকাল সেই রূপেই থাকবে। অথচ এর মধ্যে ব্যবসা বদলেছে — টাকা এখন পয়সায় রাখা হয় (দশমিকের গোলমাল এড়াতে), আর কোন চ্যানেলে জমা (এজেন্ট, ব্যাংক, কার্ড) সেটিও লাগে। এটি পর্ব ১২-এর schema evolution-এর সবচেয়ে কঠিন রূপ, কারণ পুরোনো ডেটা কখনো মরে না।

প্রচলিত কৌশলগুলো:

  • প্রতিটি event-এ version নম্বর: Deposited v1, v2, v3 — পড়ার কোড জানে কোনটি কোন রূপ।
  • Upcasting: পড়ার সময় একটি ছোট ফাংশনের শিকল পুরোনো event-কে ধাপে ধাপে সর্বশেষ রূপে তোলে (v1 → v2 → v3). Store-এর event বদলায় না; fold আর projection শুধু সর্বশেষ রূপ চেনে।
  • যোগ করুন, মানে বদলাবেন না: নতুন ঐচ্ছিক field যোগ নিরাপদ; পুরোনো field-এর অর্থ বদলানো (টাকা থেকে পয়সা, একই নামে) সবচেয়ে বিপজ্জনক — নতুন নাম বা নতুন version নিন।
  • শেষ উপায় — stream কপি করে রূপান্তর: পুরো stream পড়ে নতুন রূপে নতুন stream-এ লেখা। Audit-এর কারণে পুরোনোটিও রেখে দিতে হয়, আর কাজটি ঝুঁকিপূর্ণ।
// Upcasting: old events stay as written; on read, a chain of small functions lifts them to the latest shape.
const stored = [                                   // three eras of the same wallet, never rewritten
  { type: "Deposited", v: 1, data: { amt: 5000 } },                                 // 2024: whole taka
  { type: "Deposited", v: 2, data: { poisha: 30000 } },                             // 2025: poisha, no channel
  { type: "Deposited", v: 3, data: { poisha: 15050, channel: "agent" } },           // 2026: current shape
  { type: "Withdrew",  v: 1, data: { amt: 1200 } },
];
const upcasters = {                                // "type@version" -> function returning the next version
  "Deposited@1": (d) => ({ poisha: d.amt * 100 }),
  "Deposited@2": (d) => ({ ...d, channel: "unknown" }),
  "Withdrew@1":  (d) => ({ poisha: d.amt * 100 }),
};
const LATEST = { Deposited: 3, Withdrew: 2 };
function upcast(e) {
  let { v, data } = e, steps = 0;
  while (v < LATEST[e.type]) { data = upcasters[e.type + "@" + v](data); v++; steps++; }
  return { type: e.type, v, data, steps };
}
let poisha = 0;
for (const e of stored.map(upcast)) {             // the fold only ever sees the latest shape
  poisha += (e.type === "Deposited" ? 1 : -1) * e.data.poisha;
  console.log(`${e.type} v${e.v} (${e.steps} upcast steps) ${JSON.stringify(e.data)}`);
}
console.log("balance: " + (poisha / 100).toFixed(2) + " taka; stored events unchanged: " + JSON.stringify(stored[0].data));

আউটপুট:

Deposited v3 (2 upcast steps) {"poisha":500000,"channel":"unknown"}
Deposited v3 (1 upcast steps) {"poisha":30000,"channel":"unknown"}
Deposited v3 (0 upcast steps) {"poisha":15050,"channel":"agent"}
Withdrew v2 (1 upcast steps) {"poisha":120000}
balance: 4250.50 taka; stored events unchanged: {"amt":5000}

২০২৪-এর v1 event দুই ধাপে উঠল: টাকা থেকে পয়সা (৫০০০ → ৫০০০০০), তারপর channel: "unknown". ২০২৫-এর v2 এক ধাপে, ২০২৬-এর v3 কোনো ধাপ ছাড়াই। Fold শুধু সর্বশেষ রূপ দেখে, তাই balance ঠিক ৪,২৫০.৫০ টাকা (৫,০০০ + ৩০০ + ১৫০.৫০ − ১,২০০)। শেষ অংশ প্রমাণ করছে store-এর আসল event এখনো {"amt":5000}. মনে রাখুন, “unknown” একটি সৎ ডিফল্ট — পুরোনো event-এর চ্যানেল আসলে জানা নেই, আর upcaster মিথ্যা তথ্য বানাতে পারে না।

৬ · CQRS — লেখার মডেল এক, পড়ার মডেল অনেক

রেস্তোরাঁর রান্নাঘর আর ওয়েটারের কথা ভাবুন। রান্নাঘর (লেখার দিক) কঠোর নিয়মে চলে — কোন অর্ডার আগে, কোন উপকরণ শেষ, কোন পদ বাতিল। ওয়েটার (পড়ার দিক) রান্নাঘরের খাতা খুলে গ্রাহককে উত্তর দেন না; তাঁর হাতে থাকে একটি সাজানো মেনু-বোর্ড আর টেবিলভিত্তিক অর্ডারের তালিকা — রান্নাঘরের খবর থেকে বানানো, গ্রাহকের প্রশ্নের মাপে সাজানো।

CQRS ঠিক এই ভাগ। Write model command নেয়, নিয়ম যাচাই করে, event (বা state) লেখে — এখানে সংগতি আর সঠিকতা প্রধান। Read model শুধু প্রশ্নের উত্তর দেয়, আর প্রতিটি পাতা বা প্রশ্নের জন্য আলাদা আকারে সাজানো থাকে — balance পাতার জন্য একটি ছোট key-value, মাসিক statement-এর জন্য একটি টেবিল, খোঁজার জন্য একটি search index (পর্ব ২৭)। Read model আসলে একটি materialized view বা denormalized কপি (পর্ব ২১), যা event থেকে বানানো হয়।

Event থেকে read model বানানোর কাজটিকে বলে projection, আর যে প্রক্রিয়া এটি চালায় তাকে projector. প্রতিটি projector event log পড়ে, প্রতিটি event-এ নিজের টেবিল আপডেট করে, আর মনে রাখে কোন event পর্যন্ত পড়া হয়েছে — তার checkpoint. ব্যাপারটি পর্ব ৩৩-এর consumer-এর offset রাখার মতোই।

CQRS আর event sourcing — লেখার পথ, পড়ার পথ

Command side → event store → projectors → read models ← queries

CQRS আর event sourcing — লেখার পথ, পড়ার পথ App · client Command handler Query API Event store Projector Balance view মাসিক statement Search · analytics write model · aggregate নিয়ম যাচাই শুধু পড়ে কোনো নিয়ম নয় append-only UNIQUE(stream, version) v1 · v2 · v3 · … checkpoint ধরে পড়ে A → 1450 A · 2026-08 · in/out খোঁজা, রিপোর্ট কঠিন তীর: লেখা ও event · ভাঙা তীর: পড়া projector পিছিয়ে থাকলে read model পুরোনো = eventual consistency ১ command ২ append ৩ নতুন event ৫ query ৪ পড়া
ছবি ৩৫.১ — লেখা যায় বাঁ দিকের পথে, পড়া আসে ডান দিকের read model থেকে। Read model যত খুশি বানানো যায়, মুছে আবার বানানো যায় — সত্য একটাই, event store.
মূল ধারণা

Event sourcing ≠ CQRS. Event sourcing বলে কী রাখবেন (ঘটনা, state নয়)। CQRS বলে লেখা আর পড়ার মডেল আলাদা করবেন। একটি ছাড়া অন্যটি সম্ভব: সাধারণ টেবিলে লিখে CDC দিয়ে আলাদা read model বানানো CQRS, event sourcing নয়; আবার event store রেখে শুধু aggregate-এর fold দিয়েই সব পড়া — event sourcing, CQRS নয় (ছোট সিস্টেমে চলে, কিন্তু “সব wallet-এর মধ্যে কার balance সবচেয়ে বেশি” প্রশ্নে অচল)। বাস্তবে দুটি প্রায়ই একসাথে আসে, কারণ event store থেকে সরাসরি প্রশ্নের উত্তর দেওয়া কঠিন।

একই event log থেকে দুটি projection, একটি পিছিয়ে থাকা projector, তারপর অক্টোবরে আসা নতুন একটি চাহিদা — “২,০০০ বা তার বেশি টাকার সব লেনদেনের তালিকা” — যার read model শুরু থেকে replay করে পেছনের তারিখসহ বানানো হলো:

// One event log, several read models (projections). Each projector keeps its own checkpoint.
const log = [   // [position, date, stream, type, amount, direction]
  [1, "2026-08-03", "A", "Deposited", 5000], [2, "2026-08-10", "A", "Withdrew", 1200],
  [3, "2026-08-21", "A", "Transferred", 800, "out"], [4, "2026-08-21", "B", "Transferred", 800, "in"],
  [5, "2026-09-02", "A", "Deposited", 300], [6, "2026-09-05", "B", "Withdrew", 500],
  [7, "2026-09-15", "A", "Withdrew", 2000], [8, "2026-10-01", "B", "Deposited", 6000],
  [9, "2026-10-04", "A", "Deposited", 150],
].map(([pos, date, stream, type, amt, dir]) => ({ pos, date, stream, type, amt, dir }));
const sign = (e) => (e.type === "Deposited" || e.dir === "in" ? +1 : -1);
function projector(name, handler, view) {                  // a projector = handler + view + checkpoint
  const p = { name, view, checkpoint: 0, applied: 0 };
  p.catchUp = (upto = log.length) => {
    p.applied = 0;
    for (const e of log) if (e.pos > p.checkpoint && e.pos <= upto) { handler(p.view, e); p.checkpoint = e.pos; p.applied++; }
    return p;
  };
  return p;
}
const balances = projector("balances", (v, e) => { v[e.stream] = (v[e.stream] || 0) + sign(e) * e.amt; }, {});
const monthly = projector("monthly", (v, e) => {
  const k = e.stream + " " + e.date.slice(0, 7), row = (v[k] = v[k] || { in: 0, out: 0 });
  row[sign(e) > 0 ? "in" : "out"] += e.amt;
}, {});
balances.catchUp(6); monthly.catchUp();                     // the balance projector is 3 events behind
console.log("balances @" + balances.checkpoint + ": " + JSON.stringify(balances.view) + "  (stale)");
balances.catchUp();
console.log("balances @" + balances.checkpoint + ": " + JSON.stringify(balances.view) + "  (caught up: " + balances.applied + " new events)");
for (const [k, r] of Object.entries(monthly.view).sort()) console.log("statement " + k + ": in " + r.in + ", out " + r.out);
// New requirement in October: list every movement of 2000 or more -- built from position 1.
const large = projector("large", (v, e) => { if (e.amt >= 2000) v.push(e.date + " " + e.stream + " " + e.type + " " + e.amt); }, []);
console.log("large (new, rebuilt from start): " + large.catchUp().view.join(" | "));
balances.view = {}; balances.checkpoint = 0;                // drop and rebuild a read model
console.log("balances rebuilt: " + JSON.stringify(balances.catchUp().view) + ", replayed " + balances.applied + " events");

আউটপুট:

balances @6: {"A":3300,"B":300}  (stale)
balances @9: {"A":1450,"B":6300}  (caught up: 3 new events)
statement A 2026-08: in 5000, out 2000
statement A 2026-09: in 300, out 2000
statement A 2026-10: in 150, out 0
statement B 2026-08: in 800, out 0
statement B 2026-09: in 0, out 500
statement B 2026-10: in 6000, out 0
large (new, rebuilt from start): 2026-08-03 A Deposited 5000 | 2026-09-15 A Withdrew 2000 | 2026-10-01 B Deposited 6000
balances rebuilt: {"A":1450,"B":6300}, replayed 9 events

প্রথম লাইনে balance projector checkpoint ৬-এ, তাই A ৩,৩০০ আর B ৩০০ — সত্যের চেয়ে ৩টি event পিছিয়ে। Catch-up-এর পরে A ১,৪৫০, B ৬,৩০০, ঠিক ৩টি নতুন event প্রয়োগ হয়েছে। মাসিক statement একই event থেকে সম্পূর্ণ অন্য আকার: A-এর আগস্টে জমা ৫,০০০, খরচ ২,০০০ (১,২০০ তোলা + ৮০০ পাঠানো)। নতুন “বড় লেনদেন” তালিকা অক্টোবরে বানানো হলেও আগস্টের ৫,০০০ টাকার জমাটিও পেয়েছে — state-oriented নকশায় এই ইতিহাস থাকত না। শেষে balance view মুছে ৯টি event replay করে আবার একই ফল। খেয়াল করুন: handler-গুলো idempotent নয় (একই event দুবার এলে যোগফল দুবার), তাই checkpoint আর event এক transaction-এ রাখা বা event position দেখে duplicate বাদ দেওয়া জরুরি (পর্ব ৩৪)।

Event store যদি SQL টেবিল হয়, projection একটি query মাত্র — আর সেটি টেবিল হিসেবে জমা রাখলেই read model. নিচে balance, মাসিক statement, “৩১ আগস্টে balance কত ছিল” (time travel) আর একটি জমা-রাখা read model:

-- Read model 1: current balance per wallet (a projection = a fold over events).
SELECT stream_id, MAX(version) AS version,
       SUM(CASE WHEN type = 'Deposited' OR dir = 'in' THEN amount ELSE -amount END) AS balance
FROM events GROUP BY stream_id ORDER BY stream_id;
-- Read model 2: monthly statement of wallet A.
SELECT substr(at, 1, 7) AS month,
       SUM(CASE WHEN type = 'Deposited' OR dir = 'in' THEN amount ELSE 0 END) AS money_in,
       SUM(CASE WHEN type = 'Deposited' OR dir = 'in' THEN 0 ELSE amount END) AS money_out
FROM events WHERE stream_id = 'A' GROUP BY month ORDER BY month;
-- Time travel: what was A's balance at the end of August?
SELECT SUM(CASE WHEN type = 'Deposited' OR dir = 'in' THEN amount ELSE -amount END) AS a_on_aug_31
FROM events WHERE stream_id = 'A' AND at <= '2026-08-31';
-- Materialise read model 1 as a table that queries can hit directly.
CREATE TABLE balance_view AS
SELECT stream_id, MAX(pos) AS checkpoint,
       SUM(CASE WHEN type = 'Deposited' OR dir = 'in' THEN amount ELSE -amount END) AS balance
FROM events GROUP BY stream_id;
SELECT * FROM balance_view ORDER BY stream_id;

প্রথম query-তে A version 4, balance 3300 আর B version 2, balance 300. মাসিক statement-এ A-এর 2026-08-এ জমা ৫,০০০, খরচ ২,০০০; 2026-09-এ জমা ৩০০, খরচ ০. Time travel query দেখাচ্ছে ৩১ আগস্ট শেষে A-এর balance ছিল 3000 — কোনো আলাদা ইতিহাস-টেবিল ছাড়াই। শেষে balance_view টেবিলে প্রতিটি wallet-এর checkpoint (শেষ event-এর pos: A ৫, B ৬) আর balance. বাস্তবে এই টেবিলটি প্রতিবার পুরো হিসাব না করে প্রতিটি নতুন event-এ একটি সারি আপডেট করে বাড়ানো হয়।

৭ · Projection পিছিয়ে থাকে — eventual consistency আর read-your-writes

Write model-এ event লেখা আর read model-এ তা দেখা যাওয়ার মাঝে একটি ফাঁক থাকে — projector-কে event পড়তে, প্রয়োগ করতে আর checkpoint সরাতে হয়। সাধারণত এটি মিলিসেকেন্ড থেকে কয়েক সেকেন্ড, কিন্তু projector বিকল হলে বা ভারী rebuild চললে মিনিট-ঘণ্টাও হতে পারে। তাই read model eventually consistent: নতুন লেখা থামলে শেষ পর্যন্ত মিলবে, মাঝের সময়ে পুরোনো মান দেখাতে পারে। সবচেয়ে বিরক্তিকর রূপটি হলো নিজের লেখা নিজে না দেখা — পর্ব ২২-এর read-your-writes ভাঙা।

Event store, projector আর ব্যবহারকারীর পর্দা
Store-এর শেষ event
Projector checkpoint
Read model: A
পিছিয়ে (lag)
ব্যবহারকারী দেখেন
  1. সব মিলে আছে। Store-এর শেষ event pos ৬, projector-ও ৬ পর্যন্ত পড়েছে। Read model-এ A-এর balance ৩,৩০০ — সত্যের সাথে মিল।
  2. লেখা হলো। গ্রাহক ২,০০০ টাকা তুললেন। Command handler expected version মিলিয়ে event pos ৭-এ লিখল আর সঙ্গে সঙ্গে জানাল “সফল”। Projector এখনো ৬-এ — ১টি event পিছিয়ে।
  3. নিজের লেখা উধাও। গ্রাহক সাথে সাথে balance পাতা খুললেন। পাতা read model থেকে পড়ে, যা এখনো ৩,৩০০ দেখাচ্ছে। “টাকা কি কাটেনি?” — তিনি হয়তো আবার তোলার চেষ্টা করবেন, বা গ্রাহকসেবায় ফোন করবেন। ধরা যাক জানালাটি প্রায় ২০০ ms (অনুমান)।
  4. Projector ধরে ফেলল। Projector pos ৭ পড়ে A-কে ১,৩০০ করল, checkpoint ৭. এখন যে কেউ পড়লে ঠিক মান পাবেন — eventual consistency-র “eventually”.
  5. একটি প্রতিকার। Command-এর reply-তে লেখা event-এর position (৭) ফেরত দেওয়া হলো। Balance পাতা আগে দেখে read model-এর checkpoint ৭ বা তার বেশি কি না — না হলে অল্প অপেক্ষা করে, অথবা এই একজন গ্রাহকের জন্য write model থেকে fold করে উত্তর দেয়। অন্য গ্রাহকদের পাতা আগের মতোই দ্রুত।

Read-your-writes রক্ষার প্রচলিত কৌশল:

  • Position বা version দিয়ে অপেক্ষা: উপরের ধাপ ৫ — reply-তে position, পড়ার সময় checkpoint ≥ position পর্যন্ত অপেক্ষা (একটি সময়সীমাসহ)।
  • নিজের ডেটা write model থেকে: লেখার পরের কয়েক সেকেন্ড শুধু সেই ব্যবহারকারীর নিজের aggregate fold করে দেখানো।
  • UI-তে আশাবাদী আপডেট: সফল reply পেলে পর্দায় নিজেই নতুন মান বসানো — কিন্তু অন্য ডিভাইসে খুললে কাজ করে না।
  • একই transaction-এ read model: এক ডেটাবেসে থাকলে event আর ছোট একটি read model একসাথে commit — তখন সেই read model আর eventual নয়, তবে লেখা ধীর হয় আর projector-এর স্বাধীনতা কমে।

Read model দিয়ে নিয়ম যাচাই নয়। বাস-বুকিংয়ে “সিট A3 খালি কি না” যদি search read model থেকে দেখে বুকিং নেওয়া হয়, তাহলে projector-এর পিছিয়ে থাকার জানালায় দুজনেই খালি দেখবেন — double booking. নিয়ম সবসময় write model-এ, aggregate-এর সর্বশেষ অবস্থায় আর expected version দিয়ে যাচাই করুন; read model শুধু দেখানো আর খোঁজার জন্য।

৮ · লাভ, দাম আর কখন নয় — audit, time travel, মুছে ফেলা আর over-engineering

লাভ

পূর্ণ audit trail: কে, কখন, কী করল — ইতিহাস নিজেই ডেটা, তাই আলাদা audit টেবিল ভুলে বাদ পড়ার প্রশ্ন নেই। Time travel: যেকোনো তারিখের অবস্থা জানার temporal query সহজ। নতুন read model পেছন থেকে। Debug: production-এর event replay করে bug হুবহু ঘটানো। Integration: অন্য সেবা event শুনতে পারে, dual write ছাড়া — event store নিজেই outbox-এর কাজ করে, তাই পর্ব ২৫-এর dual write সমস্যা আর থাকে না।

দাম

জটিলতা: event-এর নকশা, aggregate-এর সীমানা, projector, checkpoint, rebuild — দলের সবাইকে শিখতে হয়। Schema চিরকাল: প্রতিটি পুরোনো event-এর upcaster বহন। Eventual read model আর তার UX. সরল প্রশ্নও কঠিন: “সব wallet-এর balance”-এর জন্যও projection লাগে। মুছে ফেলা কঠিন — নিচে দেখুন।

মুছে ফেলার অধিকার বনাম পরিবর্তনহীন log

অনেক দেশের ব্যক্তিগত তথ্য সুরক্ষা আইন ব্যবহারকারীকে নিজের তথ্য মুছে দিতে বলার অধিকার দেয় — সবচেয়ে পরিচিত উদাহরণ ইউরোপীয় ইউনিয়নের GDPR (২০১৮ সাল থেকে প্রযোজ্য), যার “right to erasure”. অথচ event store-এর মূলনীতি “কখনো মুছবেন না”। প্রচলিত কৌশল:

  • ব্যক্তিগত তথ্য event-এর বাইরে: event-এ শুধু একটি customer ID; নাম, ফোন, ঠিকানা আলাদা একটি সাধারণ টেবিলে, যেখান থেকে মোছা যায়।
  • Crypto-shredding: প্রতিটি ব্যক্তির তথ্য তাঁর নিজস্ব একটি key দিয়ে encrypt করে event-এ রাখা; মুছতে বললে শুধু key-টি ধ্বংস — event থাকে, কিন্তু পড়া যায় না। Key-এর ব্যবস্থাপনা নিরাপদ হতে হয় (পর্ব ৫২)। এটি আইনের চোখে “মুছে ফেলা” হিসেবে যথেষ্ট কি না, তা এখতিয়ার আর আইনি ব্যাখ্যাভেদে আলাদা — আইনজ্ঞের পরামর্শ ছাড়া ধরে নেবেন না।
  • Backup, snapshot আর read model-ও: সব কপি থেকে মোছার পরিকল্পনা লাগে — শুধু event store নয়।

CQRS কখন সাহায্য করে, কখন অকারণ

সাহায্য করেসাধারণত অকারণ
পড়া আর লেখার চাপ ও আকার খুব আলাদা (লাখো balance পড়া, অল্প লেখা)সাধারণ ফর্ম-ভিত্তিক CRUD: profile, সেটিংস, ক্যাটালগের admin পাতা
একই ডেটার অনেক রূপ: statement, search, dashboard, fraud-এর দৃশ্যএক টেবিল আর দুটি index-এ সব প্রশ্নের উত্তর মেলে
লেখার দিকে জটিল নিয়ম (টাকা, সিট, stock)ছোট দল, অল্প ব্যবহারকারী, দ্রুত বদলানো prototype
Audit আর ইতিহাস ব্যবসার আসল চাহিদা (wallet, ledger, বীমার দাবি)ইতিহাসের প্রয়োজন শুধু “শেষ কে বদলাল” — একটি audit কলামই যথেষ্ট

পুরো সিস্টেমে নয়, অংশে। CQRS আর event sourcing-এর প্রবক্তারাও (Greg Young, Udi Dahan-সহ) বারবার বলেছেন — এগুলো পুরো সিস্টেমের architecture নয়, নির্দিষ্ট অংশের (একটি bounded context) কৌশল। একটি wallet সেবার ledger অংশ event-sourced হতে পারে, অথচ একই সেবার “প্রোফাইলের ছবি বদলানো” সাধারণ CRUD থাকবে।

৯ · Log, ledger আর double-entry — বড় ছবিতে event sourcing

Log-এর সাথে সম্পর্ক। পর্ব ৩৩-এর log-ভিত্তিক stream (partition, offset, consumer group) দেখতে event store-এর মতো, আর projector-কে চালাতে এটি চমৎকার। কিন্তু দুটি এক নয়। Event store-এর কাছে চাওয়া হয় একটি aggregate-এর stream আলাদা করে দ্রুত পড়া আর stream-প্রতি expected version — অথচ একটি partition-এ হাজারো aggregate মিশে থাকে, আর সাধারণ log-ভিত্তিক broker-এ stream-প্রতি expected version-এর যাচাই সাধারণত সরাসরি থাকে না (পণ্য ও সংস্করণভেদে আলাদা)। Retention-এর মেয়াদে পুরোনো event মুছে যাওয়াও আরেক ঝুঁকি — event store-এ event-ই সত্য, তাই retention “চিরকাল” হতে হয়। প্রচলিত নকশা: event store (প্রায়ই একটি SQL টেবিল) সত্য রাখে, আর সেখান থেকে outbox বা CDC দিয়ে event log-ভিত্তিক broker-এ যায়, যাতে অন্য সেবা আর projector শুনতে পারে। Event-এর রকমভেদ — শুধু খবর দেওয়া event বনাম পুরো state বহনকারী event বনাম event sourcing-এর event — পর্ব ৩২-এ, আর queue-এর ভিত্তি পর্ব ৩১-এ।

Double-entry-র সাথে সম্পর্ক। হিসাববিজ্ঞানের double-entry bookkeeping-এ প্রতিটি লেনদেন দুটি হিসাবে সমান ও বিপরীত entry হিসেবে লেখা হয় — A থেকে ৮০০ বের হলে B-তে ৮০০ ঢোকে, আর সব entry-র যোগফল সবসময় শূন্য। এটি event sourcing-এর একটি বিশেষ, কঠোর রূপ: entry কখনো মোছা হয় না, ভুল শোধরাতে বিপরীত entry (compensating event) লেখা হয়, আর balance হলো entry-গুলোর fold. পার্থক্য হলো double-entry একটি অতিরিক্ত অপরিবর্তনীয় শর্ত যোগ করে — যোগফল শূন্য — যা যেকোনো মুহূর্তে যাচাই করা যায়। টাকার সিস্টেমে event sourcing আর double-entry ledger প্রায়ই একসাথে চলে; পর্ব ৬০-এ ticket booking আর payment-এর case study-তে পুরো নকশা দেখবেন।

পরের পর্বগুলোর সাথে। Projector আসলে একটি stream processor — window, watermark আর বড় আকারের replay নিয়ে পর্ব ৩৬। আর দীর্ঘ কাজ (যেমন একটি transfer saga) নির্ভরযোগ্যভাবে চালানোর workflow engine-ও প্রায়ই ভেতরে event-এর ইতিহাস রেখে replay করে নিজের অবস্থা ফিরে পায় — পর্ব ৩৭।

আরও গভীরে: Pat Helland-এর “Immutability Changes Everything” (CIDR ২০১৫) দেখায় কীভাবে ডেটাবেস, storage আর distributed system — সবখানে “পরিবর্তনহীন ডেটা যোগ করা, পুরোনোটা বদলানো নয়” নীতি ছড়িয়ে পড়ছে; তাঁর আগের একটি লেখার শিরোনামই ছিল “Accountants Don’t Use Erasers”. Jay Kreps-এর “The Log” (LinkedIn engineering blog, ২০১৩) আর Martin Kleppmann-এর “Turning the database inside out” আলোচনা (প্রায় ২০১৪–১৫) যুক্তি দেয়, log-ই মূল ডেটা আর টেবিল, index ও cache তার থেকে বানানো দৃশ্য — event sourcing আর CQRS-এর সাধারণ রূপ।

পরীক্ষা ও ইন্টারভিউতে — বিশ্ববিদ্যালয়, চাকরি বা system design ইন্টারভিউর পরীক্ষায় এমন প্রশ্ন আসতে পারে:

  • Event sourcing আর CQRS কি একই? না — প্রথমটি কী রাখবেন (event), দ্বিতীয়টি লেখা-পড়ার মডেল আলাদা করা; একটি ছাড়া অন্যটি চলে।
  • ১,২৫০টি event, প্রতি ১০০-তে snapshot — লোডে কয়টি replay? ১,২৫০ mod ১০০ = ৫০টি, সঙ্গে v1200-এর snapshot.
  • দুটি command একই stream-এ একসাথে এলে? দুটিই expected version পাঠায়; প্রথমটি জেতে, দ্বিতীয়টি conflict পেয়ে আবার পড়ে ও নিয়ম আবার যাচাই করে।
  • Event-এর schema বদলালে পুরোনো event? বদলানো হয় না; পড়ার সময় upcasting দিয়ে নতুন রূপে তোলা হয়।
  • লেখার পরেই পুরোনো balance দেখা যায় কেন? Projection eventual; প্রতিকার — reply-তে position দিয়ে অপেক্ষা, বা নিজের ডেটা write model থেকে।

১০ · AI-এর যুগে event sourcing আর CQRS-এর নকশা যাচাই

“একটি mobile wallet-এর architecture বানিয়ে দিন” লিখলে AI প্রায়ই চোখ ধাঁধানো একটি তালিকা দেয়: “Event sourcing, CQRS, Kafka, microservices, Redis”. শব্দগুলো ঠিক, কিন্তু নকশা নয়। কোন aggregate, কোন event, কোথায় নিয়ম, read model কতটা পিছিয়ে থাকতে পারে — এগুলো প্রায়ই অনুপস্থিত থাকে।

AI-এর সাধারণ ভুলকেন ভুলকীভাবে যাচাই করবেন
সব সেবায় event sourcing আর CQRSবেশিরভাগ CRUD অংশে শুধু জটিলতা বাড়েপ্রতিটি অংশে জিজ্ঞেস করুন: audit বা ইতিহাস কি আসল চাহিদা?
Kafka topic-কেই event store ধরাStream-প্রতি expected version আর aggregate-ভিত্তিক পড়া সাধারণত সরাসরি থাকে না; retention-এর ঝুঁকিঅফিসিয়াল ডকুমেন্টেশনে concurrency আর retention দেখুন; দুই সার্ভারের conflict নিজে simulate করুন
Read model থেকে নিয়ম যাচাইProjection-এর lag-এর জানালায় double booking বা overdraftFailure-mode review: projector ৫ মিনিট বন্ধ থাকলে কী হয়?
apply-এর ভেতরে SMS বা payment APIপ্রতিটি rebuild-এ side effect আবার ঘটেএকটি rebuild চালিয়ে দেখুন বাইরে কিছু যায় কি না
Snapshot-এর ব্যবধান “১০০০ দিন” বা ভুল হিসাবReplay আর snapshot লেখার খরচ না মেলানোনিজে r, S দিয়ে k* = √(২S/r) আর গড় replay হিসাব করুন
Upcasting, মুছে ফেলার আইন, পণ্যের সীমা নিয়ে আত্মবিশ্বাসী মিথ্যাপণ্যের সীমা আর আইনি ব্যাখ্যা বদলায়, AI প্রায়ই বানিয়ে বলেঅফিসিয়াল ডকুমেন্টেশন আর আইনজ্ঞের মত

AI-কে নকশা চাইলে প্রেক্ষাপট, সংখ্যা আর ব্যর্থতার প্রশ্ন একসাথে দিন:

আমি একটি কাল্পনিক mobile wallet-এর ledger অংশ নকশা করছি। প্রতিদিন প্রায় ২০ লাখ লেনদেন, একটি wallet-এ দিনে গড়ে ১০টি event. Event sourcing আর CQRS বিবেচনা করছি। ১. কোন অংশে event sourcing লাগবে আর কোন অংশে সাধারণ CRUD যথেষ্ট — কারণসহ বলো। ২. Aggregate-এর সীমানা আর event-এর তালিকা দাও (অতীত কালের নাম, প্রতিটির field). ৩. একই wallet-এ দুটি command একসাথে এলে expected version দিয়ে কী হবে, ধাপে ধাপে দেখাও। ৪. r = ২০ µs, snapshot লিখতে ২ ms ধরে snapshot interval হিসাব করো, প্রতিটি ধাপ দেখিয়ে। ৫. Projector ৫ মিনিট বন্ধ থাকলে ব্যবহারকারী কী দেখবেন, আর read-your-writes কীভাবে রাখবে? ৬. যেসব পণ্যের সীমা বা আইনি বিষয়ে তুমি নিশ্চিত নও, সেগুলো স্পষ্ট করে চিহ্নিত করো।

AI-এর নকশা হাতে পেলে — যাচাইয়ের চেকলিস্ট

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।

প্র ০১হিসাবরক্ষকেরা কয়েকশো বছর ধরে “event sourcing” করছেন — সফটওয়্যারে এটি এত দেরিতে জনপ্রিয় হলো কেন?

Double-entry bookkeeping-এর লিখিত বিবরণ পাওয়া যায় ১৪৯৪ সালে Luca Pacioli-র গণিতের বইয়ে, যদিও ইতালির বণিকেরা পদ্ধতিটি তারও আগে থেকে ব্যবহার করতেন। নিয়মটি সরল: প্রতিটি লেনদেন খাতায় যোগ হয়, কিছুই ঘষে মোছা হয় না, ভুল হলে বিপরীত entry. Balance একটি ফলাফল, মূল তথ্য নয়। এটিই event sourcing-এর মূল কথা।

তাহলে কম্পিউটারের প্রথম যুগে ডেটাবেস কেন উল্টো পথে গেল? বড় কারণ খরচ। ১৯৭০–৮০-এর দশকে ডিস্ক ছিল খুব দামি আর ছোট; প্রতিটি পরিবর্তন চিরকাল রেখে দেওয়া বিলাসিতা মনে হতো। “শুধু বর্তমান মান রাখুন, জায়গায় বদলান” — এই নকশা জায়গা বাঁচাত, আর relational model-এর সরল টেবিল-ভাবনায় চমৎকার মানাত। ইতিহাস লাগলে আলাদা audit টেবিল বা নিয়মিত backup.

মজার ব্যাপার, ডেটাবেস ভেতরে ভেতরে log রেখেই গেছে — write-ahead log ছাড়া crash recovery অসম্ভব। কিন্তু সেই log ছিল লুকানো, নিম্নস্তরের, ব্যবসার ভাষায় নয়, আর কিছু সময় পরে মুছে ফেলা হতো।

২০০০-এর দশকে তিনটি জিনিস বদলাল। এক, storage অনেক সস্তা হলো, তাই “সব রাখা” আর বিলাসিতা নয়। দুই, Domain-Driven Design-এর চর্চায় জটিল ব্যবসায়িক নিয়ম (বীমা, ব্যাংক, logistics) আলাদা করে মডেল করার চাপ এলো, আর ব্যবসার মানুষ “কী ঘটেছে” ভাষায় কথা বলেন — “দাবি জমা পড়ল”, “অনুমোদন হলো”। তিন, distributed system আর অনেক সেবার যুগে একই ডেটার অনেক রূপ লাগল, আর event ছিল সেবাগুলোকে জোড়ার স্বাভাবিক উপায়। Martin Fowler-এর প্রায় ২০০৫ সালের লেখা আর Greg Young-এর ২০১০-এর আশপাশের কাজ এই চর্চাগুলোকে নাম দেয়।

তবে হিসাবরক্ষকদের একটি সুবিধা সফটওয়্যারে নেই: তাঁদের খাতার “schema” কয়েকশো বছরে প্রায় বদলায়নি — তারিখ, বিবরণ, ডেবিট, ক্রেডিট। সফটওয়্যারের event প্রতি কয়েক মাসে বদলায়, তাই upcasting আর versioning-এর ঝামেলা আসে।

মূল উপলব্ধি: নকশার সিদ্ধান্ত প্রায়ই তখনকার খরচের প্রতিফলন। খরচ বদলালে পুরোনো, ভুলে যাওয়া নকশা ফিরে আসে — কিন্তু নতুন সমস্যা সঙ্গে নিয়ে।

প্র ০২একটি দল বলছে, “আমাদের ই-কমার্স সাইটের সব সেবায় event sourcing আর CQRS দেব — ভবিষ্যতে কাজে লাগবে।” আপনি কী পরামর্শ দেবেন?

প্রথম প্রশ্ন: কোন অংশে ইতিহাস আর audit ব্যবসার আসল চাহিদা? একটি ই-কমার্স সাইটে (কাল্পনিক) সম্ভাব্য অংশগুলো দেখি:

  • পেমেন্ট আর refund-এর ledger: হ্যাঁ, উপযুক্ত। প্রতিটি টাকার চলাচলের ইতিহাস আইন আর হিসাব মেলানোর জন্য লাগে, আর নিয়ম কঠোর।
  • অর্ডারের জীবনচক্র: সম্ভবত। “Placed → Paid → Packed → Shipped → Returned” — প্রতিটি ধাপ একটি ঘটনা, আর গ্রাহকসেবা প্রায়ই জানতে চায় কখন কী হয়েছে।
  • পণ্যের ক্যাটালগ, ব্যবহারকারীর প্রোফাইল, CMS-এর পাতা: প্রায় নিশ্চিতভাবে না। এখানে প্রশ্ন “এখন কী আছে”, ইতিহাস লাগলে একটি audit কলাম বা পরিবর্তনের log যথেষ্ট।

“ভবিষ্যতে কাজে লাগবে” যুক্তির দাম আজই দিতে হয়। প্রতিটি সেবায় event-এর নকশা, upcaster, projector, checkpoint আর rebuild-এর প্রক্রিয়া লাগবে। প্রতিটি পাতায় eventual consistency-র UX ভাবতে হবে — “পণ্যের দাম বদলালাম, তালিকায় কেন পুরোনো?” নতুন সদস্যদের শেখার সময় বাড়বে। আর একটি সূক্ষ্ম ফাঁদ: ভুলভাবে নকশা করা event (যেমন ProductUpdated যাতে পুরো সারি থাকে) চিরকাল বহন করতে হবে, অথচ সেটি state-এর চেয়ে বেশি কিছু দেয় না।

অনেক অভিজ্ঞ চর্চাকারী — CQRS-এর প্রবক্তারাসহ — বলেন, এই কৌশল একটি bounded context-এর ভেতরে প্রয়োগের জন্য, পুরো সিস্টেমের architecture হিসেবে নয়। একটি মাঝামাঝি পথও আছে: ledger অংশে event sourcing, বাকি অংশ সাধারণ টেবিল, আর অন্য সেবাকে খবর দিতে outbox দিয়ে event প্রকাশ। এতে event-driven integration-এর বেশিরভাগ সুবিধা পাওয়া যায়, event sourcing-এর পুরো দাম ছাড়াই।

পরামর্শের রূপ: প্রতিটি অংশের জন্য লিখুন “কোন প্রশ্নের উত্তর আজ দিতে পারছি না, যা event-এর ইতিহাস দিলে পারব?” উত্তর ফাঁকা হলে সেই অংশে লাগবে না। আর ভবিষ্যতে লাগলে? CDC দিয়ে পরিবর্তনের stream পরেও বের করা যায় — ইতিহাস সম্পূর্ণ হবে না, কিন্তু সেদিন থেকে শুরু করা যায়।

মূল উপলব্ধি: নকশার কৌশল বাছাই হয় সমস্যা দেখে, ফ্যাশন দেখে নয়। “কোথায় লাগবে না” বলতে পারা অভিজ্ঞতার বড় চিহ্ন।

প্র ০৩“কখনো মুছবেন না” নীতির event store-এ একজন গ্রাহক নিজের সব তথ্য মুছে দিতে বললেন। প্রকৌশলের দিক থেকে কী করা যায়, আর কোথায় সতর্ক থাকতে হবে?

ইউরোপীয় ইউনিয়নের GDPR (২০১৬ সালে গৃহীত, ২০১৮ সাল থেকে প্রযোজ্য) “right to erasure” নামে এই অধিকারের কথা বলে, আর আরও অনেক দেশে ব্যক্তিগত তথ্য সুরক্ষার আইন বা খসড়া আছে, যার খুঁটিনাটি আলাদা। প্রকৌশলীর কাজ আইনের ব্যাখ্যা করা নয়, কিন্তু এমন নকশা করা যাতে প্রয়োজনে মোছা সম্ভব হয়। Event store-এ এটি আগে থেকে না ভাবলে প্রায় অসম্ভব।

কৌশল ১ — ব্যক্তিগত তথ্য event-এর বাইরে। Event-এ থাকবে customer_id: c-7781, নাম-ফোন-ঠিকানা থাকবে একটি সাধারণ টেবিলে। মোছার অনুরোধে শুধু সেই সারি মুছুন; event-এর ইতিহাস থাকে, কিন্তু এখন তা একটি অর্থহীন ID-র সাথে যুক্ত। সমস্যা: event-এর ভেতরে ঢুকে পড়া মুক্ত লেখা (“ডেলিভারি নোট: বাসা ১২, রোড ৫…”) এই নিয়ম ভাঙে, তাই কোন field-এ কী যায়, তা কঠোরভাবে নিয়ন্ত্রণ করতে হয়।

কৌশল ২ — crypto-shredding. প্রতিটি গ্রাহকের জন্য একটি আলাদা encryption key; তাঁর ব্যক্তিগত field সেই key দিয়ে encrypt হয়ে event-এ যায়। মুছতে বললে key ধ্বংস — event থাকে, কিন্তু সেই অংশ আর কেউ পড়তে পারে না। এতে পরিবর্তনহীন নীতি রক্ষা হয়। সতর্কতা: key-এর ভাণ্ডার নিজেই এখন অত্যন্ত গুরুত্বপূর্ণ — হারালে বৈধ ডেটাও পড়া যাবে না, ফাঁস হলে সব খোলা। আর encrypt করা ডেটা “মুছে ফেলা” হিসেবে আইনের চোখে যথেষ্ট কি না, তা নিয়ে আইনি মহলে আলোচনা আছে; সিদ্ধান্ত আইনজ্ঞের সাথে নিন।

কৌশল ৩ — শেষ উপায়ে stream পুনর্লিখন। সংশ্লিষ্ট stream নতুন করে লিখে পুরোনোটি মোছা। এটি পরিবর্তনহীনতার প্রতিশ্রুতি ভাঙে আর ভুলের ঝুঁকি বাড়ায়, তাই খুব সাবধানে, নিয়ন্ত্রিত প্রক্রিয়ায়।

যেটি প্রায়ই ভুলে যাওয়া হয়: snapshot, প্রতিটি read model, search index, cache, analytics-এর কপি আর backup — সবখানে একই তথ্যের কপি আছে। Event store থেকে মুছে read model rebuild না করলে নাম থেকেই যায়। Log-ভিত্তিক broker-এ retention আর compaction-এর নিয়মও দেখতে হয়।

মূল উপলব্ধি: “কী মুছতে হতে পারে” প্রথম দিনেই নকশার প্রশ্ন। পরিবর্তনহীন সিস্টেমে মোছার পথ আগে থেকে না বানালে পরে আর বানানো যায় না।

প্র ০৪একটি projection-এ bug ধরা পড়ল, আরেকবার ভুল event-ই লেখা হয়ে গেছে — দুই ক্ষেত্রে সংশোধনের পথ কি একই?

না, আর এই পার্থক্যই event sourcing-এর সবচেয়ে কাজের শিক্ষাগুলোর একটি।

ক্ষেত্র ১ — projection-এর bug. ধরুন মাসিক statement-এ fee-কে খরচ না ধরে জমা ধরা হচ্ছিল। Event সঠিক, শুধু তার ব্যাখ্যা ভুল। সমাধান প্রায় আনন্দদায়ক: projector-এর কোড ঠিক করুন, read model-এর টেবিল মুছুন, checkpoint শূন্যে নিন, আর শুরু থেকে replay. কয়েক মিনিট বা ঘণ্টা পরে নতুন, সঠিক statement — অতীতের মাসগুলোসহ। State-oriented নকশায় এমন ভুল হলে প্রায়ই ভুল মান টেবিলে মিশে যায়, আর আসল মান ফিরে পাওয়ার উপায় থাকে না। বড় সিস্টেমে rebuild-এর সময় পুরোনো read model চালু রেখে পাশে নতুনটি বানানো হয় (blue-green-এর মতো), তারপর query-কে নতুনটিতে সরানো হয়।

ক্ষেত্র ২ — ভুল event. ধরুন একটি bug-এর কারণে কিছু wallet-এ fee দুবার কাটা হয়েছে — FeeCharged event দুবার লেখা। Event এখন ইতিহাসের অংশ, আর অন্য সেবা হয়তো এরই মধ্যে সেটি পড়ে কাজ করেছে (SMS গেছে, হিসাব রিপোর্টে উঠেছে)। মুছে ফেলা মানে ইতিহাস মিথ্যা করা, আর downstream-এর সাথে অমিল। সঠিক পথ হিসাবরক্ষকের পথ: একটি নতুন সংশোধনী event, যেমন FeeReversed {ref: ভুল event-এর id, reason: "duplicate charge bug"}. Balance ঠিক হয়, ইতিহাসে ভুল আর সংশোধন দুটোই থাকে, আর audit-এ স্পষ্ট দেখা যায় কী হয়েছিল।

একটি সূক্ষ্ম বিষয়: যদি ভুল event কোনো নিয়মই ভাঙে (যেমন balance ঋণাত্মক করে), তাহলে fold-এর কোড যেন সেই অবস্থাও সামলাতে পারে — replay-র সময় নিয়ম যাচাই হয় না, কারণ event ঘটে গেছে। নিয়ম যাচাই command-এর, fold-এর নয়।

মূল উপলব্ধি: ব্যাখ্যার ভুল replay করে সারান; ঘটনার ভুল নতুন ঘটনা দিয়ে সারান। Event-কে ইতিহাস ভাবুন, ইতিহাস ঘষে মোছা যায় না।

প্র ০৫Aggregate কত বড় হবে — একটি বাস-ট্রিপের পুরো সিট-তালিকা একটি stream, নাকি প্রতিটি সিট আলাদা stream?

এটি event sourcing-এর সবচেয়ে বাস্তব নকশা-প্রশ্ন, কারণ expected version শুধু একটি stream-কে রক্ষা করে। তাই নিয়মের সীমানাই stream-এর সীমানা।

বিকল্প ক — পুরো ট্রিপ একটি stream (trip-7, ৪০টি সিট)। সুবিধা: ট্রিপ-জুড়ে নিয়ম সহজে রক্ষা হয় — “একজন যাত্রী একটি ট্রিপে সর্বোচ্চ ৪টি সিট”, “পাশাপাশি দুটি সিট একসাথে”। অসুবিধা: ঈদের আগে সকাল ৯টায় বিক্রি খুললে শত শত মানুষ একই ট্রিপে ঝাঁপিয়ে পড়েন, সবাই একই stream-এ লিখতে চান। প্রতিটি লেখার জন্য একটিই version, তাই প্রায় সবাই conflict পান, retry করেন, আবার conflict — ঈদের আগে বাসের টিকিটের ভিড়ের সফটওয়্যার-রূপ। হিসাবটা সরল: একটি stream-এ প্রতি সেকেন্ডে কতগুলো লেখা সফল হতে পারে, তার একটি সীমা আছে (প্রতিটি লেখার round trip-এর বিপরীত), আর তার বেশি চাপ এলে conflict-এর হার দ্রুত বাড়ে।

বিকল্প খ — প্রতিটি সিট একটি stream (trip-7-seat-A3) — এখন A3 আর A4-এর ক্রেতারা একে অপরকে আটকান না, conflict শুধু একই সিটে। কিন্তু “একজন যাত্রী সর্বোচ্চ ৪টি সিট” নিয়ম এখন কয়েকটি stream জুড়ে — আর তা expected version দিয়ে রক্ষা হয় না। তখন হয় যাত্রীর জন্য আলাদা একটি aggregate (“এই ট্রিপে যাত্রী X-এর বুকিং-সংখ্যা”), নয়তো saga, নয়তো নিয়মটিকে নরম করা (পরে যাচাই করে অতিরিক্ত বুকিং বাতিল)।

বাস্তব পথ প্রায়ই মাঝামাঝি: ভিড়ের জন্য ছোট stream, আর যে নিয়ম সত্যিই অখণ্ড হতে হবে শুধু তার জন্য বড় aggregate রাখা হয়। ব্যবসার মানুষকে জিজ্ঞেস করুন — “এই নিয়ম এক মুহূর্তের জন্য ভাঙলে কী ক্ষতি?” প্রায়ই উত্তর আসে, “পরে মিলিয়ে নিলেই চলবে” — তাহলে সেটি aggregate-এর ভেতরে রাখার দরকার নেই। পর্ব ৬০-এর case study-তে টিকিট বুকিংয়ের এই প্রশ্নে আরও গভীরে যাওয়া হবে।

মূল উপলব্ধি: বড় aggregate মানে শক্ত নিয়ম কিন্তু কম concurrency; ছোট aggregate মানে বেশি concurrency কিন্তু নিয়ম ভাগ হয়ে যায়। সীমানা টানুন সেই নিয়ম ধরে, যা এক মুহূর্তের জন্যও ভাঙা চলবে না।

অনুশীলন

আগে কাগজে নিজে করুন, তারপর উত্তর মিলিয়ে নিন।

  1. Snapshot interval-এর হিসাব: একটি merchant wallet-এ (কাল্পনিক) দিনে ২,০০০টি event আসে, এক বছর ধরে (৩৬৫ দিন)। ধরা যাক একটি event পড়ে প্রয়োগ করতে r = ১০ µs, একটি snapshot পড়তে c = ২ ms, লিখতে S = ৩ ms; প্রতিটি command আগে stream লোড করে। (ক) Snapshot ছাড়া পুরো লোডে কত সময়? (খ) k = ১,০০০ হলে সবচেয়ে খারাপ আর গড় লোড-সময়? (গ) সর্বোত্তম k আর তখন প্রতি command-এ বাড়তি খরচ (replay + snapshot লেখার ভাগ), k = ১,০০০-এর সাথে তুলনা। (ঘ) সর্বোত্তম k-তে দিনে কয়টি snapshot, আর প্রতিটি ২ KB হলে সব রেখে দিলে বছরে কত জায়গা?

    (ক) L = ২,০০০ × ৩৬৫ = ৭,৩০,০০০টি event. ৭,৩০,০০০ × ০.০১ ms = ৭,৩০০ ms ≈ ৭.৩ সেকেন্ড — প্রতিটি command-এ, অসম্ভব।

    (খ) সবচেয়ে খারাপ: c + (k − ১)r = ২ + ৯৯৯ × ০.০১ = ১১.৯৯ ms. গড়: ২ + ৪৯৯.৫ × ০.০১ = ≈ ৭.০০ ms.

    (গ) k* = √(২S/r) = √(২ × ৩ / ০.০১) = √৬০০ ≈ ২৪.৫, অর্থাৎ ২৪ বা ২৫. k = ২৫-এ বাড়তি খরচ r(k − ১)/২ + S/k = ০.০১ × ১২ + ৩/২৫ = ০.১২ + ০.১২ = ০.২৪ ms (সর্বোত্তম বিন্দুতে দুই ভাগ সমান হয় — এটি k* সূত্রেরই ফল)। k = ১,০০০-এ: ০.০১ × ৪৯৯.৫ + ৩/১০০০ ≈ ৪.৯৯৫ + ০.০০৩ ≈ ৫.০ ms. সঙ্গে দুই ক্ষেত্রেই snapshot পড়ার ২ ms.

    (ঘ) দিনে ২,০০০ / ২৫ = ৮০টি snapshot; বছরে ৮০ × ৩৬৫ = ২৯,২০০টি × ২ KB = ৫৮,৪০০ KB ≈ ৫৮ MB. সাধারণত শুধু সর্বশেষটি রাখা হয়, তখন মাত্র ২ KB. আর aggregate memory-তে cache থাকলে replay শুধু ঠান্ডা শুরুতে — তখন বড় k-ও চলে। হিসাবটি শুরু-বিন্দু, মাপজোখ শেষ কথা।

  2. নকশা সমালোচনা — বাস-বুকিং: একটি দল (কাল্পনিক) বাসের টিকিটের জন্য event sourcing আর CQRS নিয়েছে। নকশা: “বুকিং সার্ভিস search read model থেকে দেখে সিট খালি কি না; খালি হলে SeatBooked event লেখে। প্রতিটি event-এ projector যাত্রীকে SMS পাঠায়। Event-এ যাত্রীর নাম আর ফোন নম্বর থাকে।” তিনটি সমস্যা খুঁজুন আর প্রতিটির প্রতিকার দিন।
    • Read model থেকে নিয়ম যাচাই: projection পিছিয়ে থাকার জানালায় দুজনেই A3 খালি দেখবেন — double booking. প্রতিকার: সিটের (বা ট্রিপের) aggregate stream লোড করে write model-এ যাচাই, আর expected version দিয়ে append; দুটি command-এর একটিই জিতবে, অন্যটি conflict পেয়ে আবার পড়ে দেখবে সিট আর খালি নেই।
    • Projector থেকে SMS: projection rebuild করলে প্রতিটি পুরোনো event-এ আবার SMS যাবে — হাজারো যাত্রী পুরোনো টিকিটের বার্তা পাবেন। প্রতিকার: SMS একটি আলাদা subscriber-এর কাজ, যার নিজের checkpoint আর event ID দিয়ে idempotency (একবার পাঠানো হলে আবার নয়); read model rebuild তাকে ছোঁয় না।
    • Event-এ ব্যক্তিগত তথ্য: মোছার অনুরোধ এলে event থেকে নাম-ফোন সরানো যাবে না। প্রতিকার: event-এ শুধু passenger ID; নাম-ফোন আলাদা মোছা যায় এমন টেবিলে, অথবা যাত্রী-প্রতি key দিয়ে encrypt করে crypto-shredding.

    বাড়তি পর্যবেক্ষণ: aggregate-এর আকার ঠিক করতে হবে — পুরো ট্রিপ একটি stream হলে ঈদের ভিড়ে conflict বাড়বে (ভাবনার প্রশ্ন ৫)।

  3. আউটপুট অনুমান করুন: নিচের কোড চালালে চারটি লাইনে কী ছাপা হবে? (Snapshot প্রতি ২টি event-এ।)
    const events = [], SNAP = 2, snaps = [];
    const fold = (bal, e) => (e.type === "Deposited" ? bal + e.amt : bal - e.amt);
    function load() {
      const s = snaps[snaps.length - 1] || { v: 0, bal: 0 };
      const rest = events.slice(s.v);
      return { v: s.v + rest.length, bal: rest.reduce(fold, s.bal), replayed: rest.length };
    }
    function append(expected, e) {
      if (events.length !== expected) return "conflict at v" + events.length;
      events.push(e);
      if (events.length % SNAP === 0) snaps.push({ v: events.length, bal: load().bal });
      return "ok v" + events.length;
    }
    append(0, { type: "Deposited", amt: 1000 });
    append(1, { type: "Withdrew", amt: 300 });
    append(2, { type: "Deposited", amt: 200 });
    const x = load(), y = load();
    console.log(x.v, x.bal, x.replayed);
    console.log(append(x.v, { type: "Withdrew", amt: 500 }));
    console.log(append(y.v, { type: "Withdrew", amt: 600 }));
    const z = load();
    console.log(z.v, z.bal, z.replayed, z.bal >= 600 ? "retry" : "reject");
    3 900 1
    ok v4
    conflict at v4
    4 400 0 reject

    প্রথম তিনটি append-এর পর stream-এ ৩টি event; দ্বিতীয় append-এ snapshot হয়েছে (v2, balance ৭০০)। তাই load() snapshot থেকে শুরু করে মাত্র ১টি event (Deposited 200) replay করে: 3 900 1. x আর y দুজনেই v3 দেখেছে। x আগে লিখল — ok v4, আর v4-এ নতুন snapshot (balance ৪০০)। y-এর expected version ৩, কিন্তু store-এ এখন ৪ — conflict at v4. শেষে নতুন করে লোড: v4, balance ৪০০, snapshot ঠিক v4-এ বলে replay ০টি; ৪০০ ≥ ৬০০ নয়, তাই reject — conflict-এর পরে আবার লিখলেও নিয়ম ভাঙত।

  4. Query লিখুন: একটি events টেবিলে কলাম stream_id, version, type, amount, dir, at (type: Deposited, Withdrew, Transferred; dir: Transferred হলে 'in' বা 'out', নইলে NULL থাকে)। এবার লিখুন: (ক) Wallet B-এর মাসভিত্তিক statement — মাস, মোট জমা, মোট খরচ — লিখুন। (খ) একটি conditional append লিখুন: wallet B-এর সর্বশেষ version এখনো ২ হলে তবেই version ৩-এ Deposited 6000 যোগ হবে। (গ) (খ)-এর query থাকা সত্ত্বেও UNIQUE (stream_id, version) কেন দরকার?
    -- (ক)
    SELECT substr(at, 1, 7) AS month,
           SUM(CASE WHEN type = 'Deposited' OR dir = 'in' THEN amount ELSE 0 END) AS money_in,
           SUM(CASE WHEN type = 'Deposited' OR dir = 'in' THEN 0 ELSE amount END) AS money_out
    FROM events WHERE stream_id = 'B' GROUP BY month ORDER BY month;
    -- (খ)
    INSERT INTO events (stream_id, version, type, amount, at)
    SELECT 'B', 3, 'Deposited', 6000, '2026-10-01'
    WHERE (SELECT MAX(version) FROM events WHERE stream_id = 'B') = 2;

    উপরের live SQL-এর নমুনা ডেটায় (ক)-এর ফল: 2026-08 · 800 · 0 আর 2026-09 · 0 · 500. (খ) চালালে প্রথমবার ১টি সারি বদলেছে, আবার চালালে ০টি সারি বদলেছে — version তখন ৩.

    (গ) শর্তসাপেক্ষ INSERT-এর “পড়ে দেখা” আর “লেখা” একসাথে অখণ্ড — এই নিশ্চয়তা ডেটাবেস আর isolation level-এর ওপর নির্ভর করে। কিছু ডেটাবেসে দুটি transaction একসাথে MAX(version) = 2 দেখে দুজনেই লিখে ফেলতে পারে (পর্ব ২৪-এর phantom আর write skew দেখুন)। UNIQUE constraint যেকোনো isolation level-এ index দিয়ে রক্ষা হয়, তাই এটিই শেষ পাহারাদার — conditional query হলো সুন্দর ত্রুটি-বার্তার জন্য, UNIQUE হলো সঠিকতার জন্য।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমEvent sourcing আর CQRS-এ একটি command-এর পথ সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. Command এলো: “wallet A থেকে ২,০০০ টাকা তুলুন”
  2. সর্বশেষ snapshot আর তার পরের event fold করে A-এর অবস্থা আর version বের করা
  3. নিয়ম যাচাই: balance ≥ ২,০০০?
  4. Expected version দিয়ে event store-এ Withdrew 2000 append
  5. Projector নতুন event পড়ে read model আপডেট করে, checkpoint সরায়
  6. Balance পাতার query read model থেকে নতুন মান পড়ে

একটি event stream-এ ১,২৫০টি event আছে, আর প্রতি ১০০টি event পরপর snapshot নেওয়া হয় (version ১০০, ২০০, … তে)। বর্তমান অবস্থা লোড করতে সর্বশেষ snapshot-এর পরে কয়টি event replay করতে হবে?

  • ১,২৫০টি
  • ১০০টি
  • ৫০টি
  • ১২টি

Event sourcing আর CQRS-এর সম্পর্ক নিয়ে কোন কথাটি সঠিক?

  • CQRS করতে হলে event sourcing বাধ্যতামূলক
  • দুটি আলাদা ধারণা; একটি ছাড়া অন্যটি ব্যবহার করা যায়, যদিও প্রায়ই একসাথে আসে
  • দুটি একই কৌশলের দুই নাম
  • Event sourcing করলে CQRS ছাড়া কোনো ডেটা পড়াই যায় না

একটি wallet stream এখন version ৭-এ। দুটি সার্ভার দুটি withdraw command-এর জন্য expected version ৭ দিয়ে event append করতে চায়; প্রথমটি সফল হলো। দ্বিতীয়টির কী হওয়া উচিত?

  • দ্বিতীয়টিও version ৮ হিসেবে লেখা হবে
  • দ্বিতীয়টি নিজে থেকে version ৯ হিসেবে লেখা হবে
  • দুটিই বাতিল হবে, কারণ conflict হয়েছে
  • Conflict-এ প্রত্যাখ্যাত হবে; সার্ভার নতুন করে পড়ে নিয়ম আবার যাচাই করবে

Event-এর গঠন বদলেছে: পুরোনো Deposited {amt} টাকায়, নতুন Deposited {poisha, channel}. Event-sourced সিস্টেমে পুরোনো event-এর জন্য সাধারণ কৌশল কোনটি?

  • পড়ার সময় upcasting করে নতুন রূপে তোলা, store-এর event অপরিবর্তিত রেখে
  • একটি UPDATE চালিয়ে সব পুরোনো event জায়গায় বদলে দেওয়া
  • পুরোনো event মুছে শুধু নতুন event রাখা
  • পুরোনো field নামেই নতুন অর্থ ধরে নেওয়া

CQRS-ভিত্তিক একটি wallet অ্যাপে গ্রাহক টাকা তুলে “সফল” দেখলেন, কিন্তু সাথে সাথে balance পাতায় পুরোনো balance দেখলেন। কোন নিশ্চয়তা ভেঙেছে, আর কেন?

  • Atomicity — event অর্ধেক লেখা হয়েছে
  • Read-your-writes — read model projection-এর কারণে একটু পিছিয়ে আছে
  • Durability — event হারিয়ে গেছে
  • Optimistic concurrency — দুটি command conflict করেছে

পরিবর্তনহীন event store-এ ব্যক্তিগত তথ্য মোছার অনুরোধ সামলাতে “crypto-shredding” বলতে কী বোঝায়?

  • পুরো event store মুছে নতুন করে শুরু করা
  • ব্যক্তিগত তথ্যের জায়গায় তার hash বসানো
  • প্রতিটি ব্যক্তির তথ্য নিজস্ব key দিয়ে encrypt করে রাখা, আর মোছার অনুরোধে শুধু সেই key ধ্বংস করা
  • শুধু read model থেকে তথ্য মুছে event রেখে দেওয়া

একটি event-sourced সিস্টেমে projector-এর apply ফাংশনের ভেতরে গ্রাহককে SMS পাঠানো হয়। Read model rebuild করলে কী হবে?

  • কিছুই হবে না, কারণ rebuild শুধু টেবিল বানায়
  • শুধু সর্বশেষ event-এর SMS আবার যাবে
  • Event-এর ক্রম এলোমেলো হয়ে balance ভুল হবে
  • প্রতিটি পুরোনো event-এর জন্য আবার SMS যাবে

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. Event sourcing: ঘটনাই সত্য, অবস্থা তাদের fold; apply বিশুদ্ধ, নিয়ম যাচাই আর side effect command-এর দিকে।
  2. Event store append-only, আর প্রতিটি append-এ expected version — SQL-এ UNIQUE (stream_id, version); conflict হলে আবার পড়ে নিয়ম আবার যাচাই। সংগতির একক একটি stream.
  3. Snapshot: replay = L mod k (≤ k − ১); প্রতি command-এর খরচ c + r(k − ১)/২ + S/k, সর্বোত্তম k* = √(২S/r). Snapshot একটি cache, সত্য নয়।
  4. Schema বদলায়, event নয়: version নম্বর আর পড়ার সময় upcasting; ভুল event-এর সংশোধন নতুন event দিয়ে, ভুল projection-এর সংশোধন replay দিয়ে।
  5. CQRS: আলাদা write আর read model; projector checkpoint ধরে read model বানায়, যা eventual — read-your-writes আলাদা করে রক্ষা, নিয়ম কখনো read model থেকে নয়। ES ≠ CQRS, আর দুটিই অংশবিশেষের কৌশল, সব জায়গার নয়।

পরের পর্বে: পর্ব ৩৬ — Batch আর stream processing: MapReduce, window আর watermark. Projector যে কাজটি ছোট আকারে করে — অবিরাম আসা event থেকে হিসাব — সেটি কোটি event-এ, দেরিতে আসা event-সহ, কীভাবে নির্ভুল করা যায়, দেখব।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান