Part 01 · Low Level Design

Observer and Event-Driven Design

ObserverMediatorEvent busIn-process pub/sub

একটা পরিবর্তন যখন অনেক object এর কাছে পৌঁছাতে হয়: Observer, সবাই সবার সাথে কথা বলা বন্ধ করা Mediator, আর আসল broker আসার আগে process এর ভেতরের event bus।

⏱ 1-2 ঘণ্টা · Station 10 / 80

PlaceOrder অনেক বেশি মানুষকে চেনে

Customer order দিলে দোকানকে একটা SMS পাঠাতে হয়, loyalty point যোগ করতে হয়, analytics এ লিখতে হয়, warehouse কে জানাতে হয়, আর invoice email করতে হয়। PlaceOrder এর প্রথম version পাঁচটা service কেই সরাসরি ডাকে, একটার পর একটা।

তারপর সমস্যা জমতে থাকে। SMS gateway down হলো আর order ই fail করতে লাগলো, কারণ প্রথম error পেলেই PlaceOrder থেমে যায়। Marketing নতুন একটা "first order" coupon চায়, আর কাউকে আবার PlaceOrder edit করতে হয়। Analytics team এক সপ্তাহের জন্য তাদের tracking বন্ধ রাখতে চায়, সেটাও আরেকটা edit। PlaceOrder হয়ে গেছে এমন একটা function যেটা সবাইকে বদলাতে হয়, আর যে সবাইকে চেনে।

সমাধান হলো কে কাকে চেনে সেটা উল্টে দেওয়া: order শুধু ঘোষণা করে "একটা order হয়েছে", আর যার দরকার সে শোনে। এটাই Observer pattern, আর প্রতিটা event-driven system এর পেছনের idea।

সমস্যা: caller প্রতিটা reaction কে চেনে

যা দেখা যায়কারণ
নতুন reaction (coupon, warehouse) যোগ করতে মূল flow edit করতে হয়মূল flow প্রতিটা reaction কে সরাসরি ডাকে
একটা reaction fail করলে (SMS) পুরো order ভেঙে যায়reaction গুলো মূল flow এর ভেতরেই চলে, কোনো আলাদা করা নেই
Deploy ছাড়া কোনো reaction বন্ধ করা যায় নাreaction গুলো শক্ত করে বাঁধা
মূল service পাঁচটা অন্য module import করেDependency গুলো মূল থেকে বাইরের দিকে

মসজিদের মাইক আর একটা WhatsApp group এর কথা ভাবো

মুয়াজ্জিন প্রতিটা বাসায় phone করে বলেন না যে নামাজের সময় হয়েছে। একটা ঘোষণা যায়, আর যে শুনতে চায় সে শোনে। নতুন পরিবার এলাকায় আসলে তারাও শোনে; মুয়াজ্জিনের কাজ বদলায় না। এটা Observer: একটা subject, অনেক শ্রোতা, আর subject কে কারা কী করছে তার list রাখতে হয় না।

একটা WhatsApp group হলো mediator: member দের একে অপরের নম্বর লাগে না; তারা group এ কথা বলে, group পৌঁছে দেয়। Event bus তোমার program এর ভেতরে এই একই জিনিস: publisher আর subscriber রা চেনে শুধু bus আর topic এর নাম।

order servicePublish(event)event busorder.placedsmsloyaltyanalyticssms: gateway down#501#501#501analytics unsubscribed#503#503sms failed, loyalty still got the event
Play চাপো। Order service #501 bus এ publish করে, আর সেটা SMS, loyalty আর analytics এর কাছে ছড়িয়ে যায়। পরে analytics unsubscribe করে; #503 publish হওয়ার সময় SMS gateway down, কিন্তু loyalty তবুও event পায়।

তবে একটা জায়গায় মেলে না। ঘোষণা মিস করলে মিস হয়ে যায়, ব্যাস। Software এ একটা event মিস করা মানে হতে পারে invoice যায়নি বা point হারিয়েছে, তাই ঠিক করতে হয়: "process এর ভেতরে, পারলে পৌঁছাবে" যথেষ্ট কিনা, নাকি একটা durable queue লাগবে (HLD এর chapter গুলোতে) যেটা event ততক্ষণ রেখে দেয় যতক্ষণ না কেউ সেটা সামলায়।

আগে অনুমান করো

Order service 'order.placed' একটা bus এ publish করে। SMS, loyalty আর analytics subscribe করে আছে। Marketing একটা 'first order coupon' reaction যোগ করলো। কোন code বদলাবে?

Observer, mediator আর event bus

  1. Observer: একটা subject observer দের একটা list রাখে, আর কিছু ঘটলে প্রত্যেককে জানায়। Observer রা নিজেরাই register আর unregister করে। Subject নির্ভর করে শুধু ছোট একটা interface এর উপর ("এমন কিছু যাকে জানানো যায়"), নির্দিষ্ট reaction গুলোর উপর না।
  2. Event: অতীতের একটা ঘটনা, অতীত কালে নাম দেওয়া: order.placed, payment.failed। reaction এর যা data লাগে সেটা বহন করে (order ID, customer, amount), আর কে reaction দেবে সে ব্যাপারে কিছুই না।
  3. Event bus (in-process pub/sub): একটা shared object, যেখানে publisher একটা topic এ publish করে আর subscriber রা topic এ subscribe করে। কোনো দিকই অন্য দিককে চেনে না। এটা মাঝখানে একজন লোক সহ Observer, তাই অনেক subject আর অনেক observer প্রতিটা জোড়া আলাদা করে না জুড়েই মিলতে পারে।
  4. Mediator: এমন একটা object যেটা একদল object এর মধ্যে সমন্বয় করে, যাতে তারা সরাসরি একে অপরের সাথে কথা না বলে। Chat room, air-traffic controller, ride-matching service: প্রত্যেকে mediator এর সাথে কথা বলে। Bus হলো event এর জন্য সাধারণ একটা mediator; mediator সমন্বয়ের logic ও রাখতে পারে।
  5. আলাদা রাখা: একটা subscriber fail করলে বাকিরা বা publisher যেন থেমে না যায়। প্রতিটা subscriber এর error আলাদা করে ধরো, log করো, আর retry করবে কিনা ঠিক করো।
  6. দাম: flow টা বোঝা কঠিন হয়ে যায়। "Order হলে কী কী হয়?" এর উত্তর আর একটা function না; উত্তর হলো "order.placed এ যা যা subscribe করা"। Subscription গুলো এমন এক জায়গায় register করো যেটা চোখে পড়ে।

একটা event, অনেক reaction

ধাপে ধাপে দেখো

bus.Publish(order.placed,

Order serviceEvent busSMSLoyalty

ধাপ 1/4 Order service একটা ঘটনা ঘোষণা করে

রহিমের ৩০০ টাকার order #503 হয়েছে। Service publish করে, তারপর তার কাজ শেষ। কে শুনছে সে জানে না।

topic
order.placed
order
#503

কে কাকে চেনে?

চেনাজানার তীর গুলো আঁকো:

  • সরাসরি call: order → SMS, order → loyalty, order → analytics। মূল flow সবাইকে চেনে।
  • Event দিয়ে: order → bus ← SMS, loyalty, analytics। মূল flow চেনে শুধু bus কে; reaction গুলো চেনে শুধু bus আর topic।

নতুন feature যোগ করতে যদি মূল flow edit করতে হয় শুধু নতুন কিছু ডাকার জন্য, জিজ্ঞেস করো এটা বরং একটা subscriber হওয়া উচিত কিনা।

নিজে বলো: event না সরাসরি call?

প্রতিটা case চালিয়ে দেখো

এটা কি event হওয়া উচিত?

publish(user.registered)
কাজ করে: Event। Sign-up এর SMS এর জন্য অপেক্ষা করা বা SMS এর জন্য fail করা উচিত না। অন্য reaction (coupon, analytics) পরে যোগ হতে পারে।

Code এ order এর event bus

Topic, subscribe/unsubscribe, আর প্রতিটা subscriber এর error আলাদা রাখা সহ ছোট একটা in-process bus। তিনটা subscriber order এ সাড়া দেয়, একটা পরে unsubscribe করে, আর একটা fail করে।

package main

import (
	"errors"
	"fmt"
)

type Event struct {
	OrderID  int
	Customer string
	Amount   int // taka
}

type Handler func(e Event) error

type subscription struct {
	id   int
	name string
	fn   Handler
}

// Bus is an in-process event bus: publishers and subscribers only know the bus and the topic.
type Bus struct {
	subs   map[string][]subscription
	nextID int
}

func NewBus() *Bus { return &Bus{subs: map[string][]subscription{}} }

func (b *Bus) Subscribe(topic, name string, fn Handler) int {
	b.nextID++
	b.subs[topic] = append(b.subs[topic], subscription{b.nextID, name, fn})
	return b.nextID
}

func (b *Bus) Unsubscribe(id int) {
	for topic, list := range b.subs {
		kept := list[:0:0]
		for _, s := range list {
			if s.id != id {
				kept = append(kept, s)
			}
		}
		b.subs[topic] = kept
	}
}

// Publish calls every subscriber in order. One failing subscriber doesn't stop the others.
func (b *Bus) Publish(topic string, e Event) {
	fmt.Printf("publish %s #%d\n", topic, e.OrderID)
	for _, s := range b.subs[topic] {
		if err := s.fn(e); err != nil {
			fmt.Printf("  %-9s : failed: %v\n", s.name, err)
		}
	}
}

func main() {
	bus := NewBus()

	// The order service only publishes. It doesn't know who listens.
	bus.Subscribe("order.placed", "sms", func(e Event) error {
		if e.OrderID == 503 {
			return errors.New("gateway down")
		}
		fmt.Printf("  %-9s : sent \"order #%d confirmed\" to %s\n", "sms", e.OrderID, e.Customer)
		return nil
	})

	points := map[string]int{}
	loyalty := func(sign int) Handler {
		return func(e Event) error {
			p := sign * (e.Amount / 100) // 1 point per 100 taka
			points[e.Customer] += p
			fmt.Printf("  %-9s : %s %+d points (total %d)\n", "loyalty", e.Customer, p, points[e.Customer])
			return nil
		}
	}
	bus.Subscribe("order.placed", "loyalty", loyalty(+1))
	bus.Subscribe("order.cancelled", "loyalty", loyalty(-1))

	analytics := bus.Subscribe("order.placed", "analytics", func(e Event) error {
		fmt.Printf("  %-9s : recorded #%d\n", "analytics", e.OrderID)
		return nil
	})

	bus.Publish("order.placed", Event{501, "Rahim", 720})
	bus.Publish("order.placed", Event{502, "Nadia", 1500})

	bus.Unsubscribe(analytics)
	fmt.Println("analytics unsubscribed")

	bus.Publish("order.placed", Event{503, "Rahim", 300})
	bus.Publish("order.cancelled", Event{502, "Nadia", 1500})
}

ছয়টাই হুবহু এটা print করে:

publish order.placed #501
  sms       : sent "order #501 confirmed" to Rahim
  loyalty   : Rahim +7 points (total 7)
  analytics : recorded #501
publish order.placed #502
  sms       : sent "order #502 confirmed" to Nadia
  loyalty   : Nadia +15 points (total 15)
  analytics : recorded #502
analytics unsubscribed
publish order.placed #503
  sms       : failed: gateway down
  loyalty   : Rahim +3 points (total 10)
publish order.cancelled #502
  loyalty   : Nadia -15 points (total 0)

Output টা পড়ি:

  • #501 আর #502: প্রতিটা publish তিনজন subscriber এর কাছেই পৌঁছায়, যে ক্রমে তারা subscribe করেছিল সেই ক্রমে। Publisher কারো নাম নেয়নি।
  • Point: প্রতি ১০০ টাকায় ১ point, তাই ৭২০ টাকায় ৭ point আর ১৫০০ তে ১৫।
  • analytics unsubscribed: এখন থেকে তাকে আর ডাকা হয় না; আর কোনো code বদলায়নি।
  • #503: SMS fail করে ("gateway down"), bus সেটা log করে, আর loyalty তবুও চলে: রহিম হয় ১০ point।
  • order.cancelled #502: cancel এ subscribe করেছিল শুধু loyalty; সে নাদিয়ার ১৫ point ফেরত নেয়।
  • Language ভেদে কী আলাদা: Go, TypeScript, Java আর C++ handler রাখে function বা lambda হিসেবে। Rust এ handler এর জন্য Box<dyn Fn> লাগে, আর point এর table এর জন্য Rc<RefCell<…>>, কারণ দুইটা handler এটা share করে আর বদলায়; borrow checker এই share করাটা খোলাখুলি লিখিয়ে নেয়। Python প্রতিটা subscriber এর যেকোনো exception ধরে; Go তে handler throw না করে একটা error ফেরত দেয়।

Real system এ event

  • In-process event হলো "ছুড়ে দাও, ভুলে যাও"। Publish এর পরে process crash করলে reaction গুলো হারিয়ে যায়। যা অবশ্যই হতে হবে (invoice, refund), তার জন্য order এর একই transaction এ event টা database এ লেখো (outbox pattern), আর সেখান থেকে পৌঁছাও।
  • Sync না async, ঠিক করো। এই chapter এর bus subscriber দের একটা একটা করে, সাথে সাথে ডাকে। ধীর একটা subscriber publisher কেও ধীর করে দেয়। আসল system প্রায়ই event একটা worker বা queue এর হাতে দেয়।
  • Subscriber কে idempotent বানাও। Event retry হলে একই event দুইবার আসতে পারে; দুইবার point দেওয়া একটা bug। Event বা order ID দিয়ে duplicate বাদ দাও।
  • Subscription গুলো এক জায়গায় register করো। একটা wiring.go বা main, যেখানে প্রতিটা subscription এর list আছে, flow টা খুঁজে পাওয়ার মতো রাখে।
  • Event এর নাম দাও ঘটনা হিসেবে, অতীত কালে, কাজ করার মতো যথেষ্ট data সহ। doStuff{id} এর চেয়ে order.placed{id, customer, amount} ভালো।

যে design গুলো পরে problem create করে

প্রতিটা case চালিয়ে দেখো

যে event design টা পরে ভোগাবে

for s := range subs { if err := s(e); err != nil { return err } }
ভাঙে: SMS down মানে loyalty point আর analytics ও নেই। প্রতিটা subscriber এর error আলাদা রাখো।

একটু থেমে ভাবো

Finance team বললো: "প্রতিটা order এর জন্য invoice email যেতেই হবে, কোনো ব্যতিক্রম নেই।" In-process একটা subscriber কি যথেষ্ট?

উত্তর দেখো

না। Order save হওয়া আর subscriber চলার মাঝখানে process crash করলে in-process bus event হারিয়ে ফেলে, আর fail করা একটা email শুধু log হয়। "কোনো ব্যতিক্রম নেই" এর জন্য order এর একই database transaction এ একটা invoice_pending record (বা outbox event) save করো, আর একটা worker সেই table থেকে invoice পাঠাবে, done মার্ক করবে, আর fail হলে retry করবে। Event এর idea একই থাকে; শুধু পৌঁছানোটা হয় টেকসই। HLD part এ queue আর outbox pattern বিস্তারিত আছে।

ছোট্ট quiz

১. Observer কে কার উপর নির্ভর করে, সেটা কীভাবে বদলায়?

উত্তর

Subject আর নির্দিষ্ট reaction গুলোর উপর নির্ভর করে না। Observer রা নির্ভর করে subject (বা bus) এর উপর, আর subject চেনে শুধু জানানোর ছোট একটা interface।

২. প্রতিটা subscriber এর error আলাদা রাখা কেন জরুরি?

উত্তর

যাতে একটা ভাঙা reaction (down হওয়া SMS gateway) বাকিদের বা publisher কে থামিয়ে না দেয়। প্রতিটা failure আলাদা করে log আর retry করা যায়।

৩. কখন event ভুল tool?

উত্তর

যখন caller কে সামনে যাওয়ার জন্য উত্তরের অপেক্ষা করতেই হয় (address check, card authorise)। সেটা সরাসরি call।

৪. Event bus আর mediator এর পার্থক্য কী?

উত্তর

Bus শুধু topic ধরে publisher থেকে subscriber এর কাছে event পৌঁছায়। Mediator একদল object এর সমন্বয়ের logic ও রাখতে পারে (chat room ঠিক করে কে message পাবে, dispatcher rider আর driver মেলায়)।

তোমার challenge: প্রথম order এ coupon

order.placed এ একটা coupon subscriber যোগ করো, যেটা শুধু customer এর প্রথম order এ coupon দেয়: কোনো customer কে প্রথমবার দেখলে print করে <customer> gets FIRST50 (বাকিদের মতো একই name : format এ), আর তারপর আর কিছু না।

  • Analytics এর পরে, প্রথম publish এর আগে register করো।
  • Bus বা আগের কোনো subscriber বদলাবে না।

চালানোর আগে আন্দাজ করো নতুন লাইন গুলো কোথায় আসবে।

হয়েছে বুঝবে যখন output এ ঠিক দুইটা লাইন যোগ হবে, #501 এর analytics লাইনের পরে coupon : Rahim gets FIRST50 আর #502 এর analytics লাইনের পরে coupon : Nadia gets FIRST50, #503 এ কোনো coupon লাইন আসবে না, আর বাকি সব লাইন একই থাকবে।

এক কথায়

প্রতিটা reaction কে ডেকো না, ঘটনা publish করো: publisher একটা topic এ ঘোষণা করে "এটা ঘটেছে", subscriber রা ঠিক করে কী করবে, আর কেউ কাউকে চেনে না। প্রতিটা subscriber এর failure আলাদা রাখো, subscription গুলো এক জায়গায় list করে রাখো, আর যে reaction কখনো হারানো চলবে না তার জন্য durable queue নাও।

Station stamp

Chapter শেষ করো

পড়া শেষ হলে Complete চাপো, progress save হবে। XP শুধু Pro তে — Pro থাকলে এই chapter এ +10 XP পেতে।

Pro নিয়ে XP পাও →