Part 01 · Low Level Design

Enums and State Machines

EnumsState machineTransitionsInvalid statesState diagram

Order, ticket, elevator সবাই এক state থেকে আরেক state এ যায়। Enum আর পরিষ্কার transition দিয়ে model করো, যাতে অসম্ভব একটা state code এ লেখাই না যায়।

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

Refund হলো, তারপরেও delivered

একটা online shop প্রতিটা order এর status রাখে string হিসেবে: "placed", "paid", "shipped"। আলাদা আলাদা screen সরাসরি এটা বসায়: payment callback বসায় "paid", courier এর app বসায় "shipped", support tool বসায় "cancelled"।

একদিন support একটা order cancel করে customer কে টাকা ফেরত দিলো। দশ মিনিট পর courier এর app, যেখানে order টা তখনো খোলা ছিল, সেটাকে "delivered" করে দিলো। এখন order টা delivered আবার refunded ও। আরেকবার একটা typo তে status হয়ে গেল "shiped", আর "shipped" দিয়ে filter করা সব list থেকে order টা হাওয়া।

কোনো ভুলই আটকানো যায়নি, কারণ code কোথাও লিখে রাখেনি কোন কোন status আছে, আর কোনটা থেকে কোনটায় যাওয়া যাবে। এটা লিখে রাখার নামই state machine।

সমস্যা: সব জায়গা থেকে status বদলানো

প্রায় প্রতিটা business object এর একটা জীবনচক্র আছে: order, ticket, ride, loan এর আবেদন, job post। Status যখন একটা খোলা string, আর অনেক জায়গা থেকে বদলানো হয়:

যা ভুল হয়কেন হয়
Typo থেকে এমন state তৈরি হয় যেটা আসলে নেইযেকোনো string ই চলে যায়
Order cancelled থেকে আবার shipped হয়ে যায়কোন move legal, কোনো rule সেটা বলে না
একই check (if status == "paid" or status == "placed") অনেক screen এ copyRule গুলো এক জায়গায় নেই
মোট কয়টা state আছে, কেউ বলতে পারে নাএরা পুরো code এ ছড়ানো

Stadium এর একটা ticket এর কথা ভাবো

একটা ticket শুরুতে unused। Gate এ scan হলে সেটা used। Match বাতিল হলে সেটা refunded। Used ticket এর refund হয় না, আর refunded ticket দিয়ে ঢোকা যায় না। Gate এর guard কে পুরো ইতিহাস জানতে হয় না; সে শুধু দেখে: ticket টা এখন যেখানে আছে, সেখান থেকে এই move টা কি চলে?

payshipdelivercancelcancelplacedpaidshippeddeliveredfinalcancelledfinalorder 1001cancelno cancel arrow from shipped: refuseddelivered is final: nothing leaves it
Play চাপো। Order 1001 তীর ধরে placed থেকে paid থেকে shipped এ যায়। Shipped থাকা অবস্থায় একটা cancel আসে, কিন্তু shipped থেকে cancel এর কোনো তীর নেই, তাই সেটা ফিরিয়ে দেওয়া হয়। তারপর order টা delivered এ যায়, যেটা একটা final state।

তবে একটা জায়গায় মেলে না। Stadium এর guard চাইলে ব্যতিক্রম করতে পারে। Code পারে না, আর পারা উচিতও না: business এর যদি ব্যতিক্রম লাগে ("shipping এর পরেও cancel, return হিসেবে"), সেটা table এ নতুন একটা নাম দেওয়া transition হবে, কোনো একটা screen এ লুকানো special case না।

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

Transition table বলছে: placed → (pay → paid, cancel → cancelled), paid → (ship → shipped, cancel → cancelled), shipped → (deliver → delivered)। একটা order shipped অবস্থায় আছে। cancel আসলে কী হবে?

Enum, state আর transition

  1. Enum হলো নাম দেওয়া কয়েকটা value এর একটা বন্ধ set: Placed, Paid, Shipped, Delivered, Cancelled। Compiler (বা type checker) এর বাইরে কিছু মানে না, তাই "shiped" ঢুকতে পারে না। Go তে typed constant, Rust আর Java তে আসল enum, TypeScript এ string literal এর union, C++ এ enum class, Python এ Enum।
  2. State মানে object টা তার জীবনের এই মুহূর্তে কোথায় আছে। এটাই ঠিক করে এরপর সে কী করতে পারবে।
  3. Transition মানে এক state থেকে আরেক state এ যাওয়া, যেটা ঘটে একটা event এর কারণে (pay, ship, cancel)। Legal transition গুলোর পুরো list ই হলো state machine।
  4. Transition গুলো এক জায়গায় লেখো, পারলে data হিসেবে: একটা table বা একটা match। তাহলে পুরো জীবনচক্র এক screen এ পড়া, review করা আর test করা যায়।
  5. State private রাখো, আর বদলাও শুধু একটা method দিয়ে (Apply(event)), যেটা table দেখে নেয়। এটা আগের chapter এর encapsulation, এবার জীবনচক্রের উপর।
  6. Final state থেকে বের হওয়ার কোনো transition নেই (delivered, cancelled)। এটা পরিষ্কার করে বলে দিলে "zombie" object হয় না, মানে শেষ হয়ে যাওয়ার পরেও বদলাতে থাকা object।
  7. State diagram state গুলোকে box আর transition গুলোকে নাম লেখা তীর হিসেবে আঁকে। Product manager কে দেখানোর জন্য এর চেয়ে পরিষ্কার ছবি নেই, আর এটা table এর সাথে লাইন ধরে মিলতে হবে।

একটা cancel, table এর সাথে মিলিয়ে দেখা

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

shipped অবস্থায় order.Apply(cancel)

ScreenOrder.ApplyTransition tableOrder এর state

ধাপ 1/5 Support screen cancel চায়

Screen নিজে status বসায় না। সে একটা event পাঠায়: cancel।

event
cancel

পুরো জীবনচক্র এক পাতায়

জীবনচক্রটাকে এমন একটা table হিসেবে ভাবো যেটা print করে দেয়ালে লাগিয়ে রাখা যায়:

কোথা থেকেEventকোথায়
placedpaypaid
placedcancelcancelled
paidshipshipped
paidcancelcancelled
shippeddeliverdelivered

প্রতিটা legal move একটা সারি। Table এ যা নেই, তা illegal। Business rule বদলালে একটা সারি যোগ বা বাদ দাও, আর diagram, code আর test একসাথে বদলায়।

নিজে বলো: এটা কি চলবে?

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

Legal transition কিনা?

উপরের order table টা ধরে।

placed -> paid
কাজ করে: Table এ আছে। স্বাভাবিক প্রথম ধাপ।

Code এ order এর জীবনচক্র

State আর event হলো enum, transition গুলো একটা table এ (বা একটা match এ), আর state বদলানোর একমাত্র রাস্তা Apply। দুইটা order কে legal আর illegal দুই রকম event দিয়ে চালানো হয়।

package main

import (
	"fmt"
	"strings"
)

// State and Event are enums: a closed set of names instead of free-form strings.
type State int

const (
	Placed State = iota
	Paid
	Shipped
	Delivered
	Cancelled
)

func (s State) String() string {
	return [...]string{"placed", "paid", "shipped", "delivered", "cancelled"}[s]
}

type Event string

const (
	Pay     Event = "pay"
	Ship    Event = "ship"
	Deliver Event = "deliver"
	Cancel  Event = "cancel"
)

// The whole lifecycle in one table: from a state, which events are allowed, and where they lead.
// Anything not listed is an invalid transition.
var transitions = map[State]map[Event]State{
	Placed:  {Pay: Paid, Cancel: Cancelled},
	Paid:    {Ship: Shipped, Cancel: Cancelled},
	Shipped: {Deliver: Delivered},
	// Delivered and Cancelled are final: no way out.
}

type Order struct {
	ID      int
	state   State
	history []State
}

func NewOrder(id int) *Order {
	return &Order{ID: id, state: Placed, history: []State{Placed}}
}

// Apply is the only way to change the state, so an impossible state can't be reached.
func (o *Order) Apply(e Event) error {
	next, ok := transitions[o.state][e]
	if !ok {
		return fmt.Errorf("cannot %s when %s", e, o.state)
	}
	o.state = next
	o.history = append(o.history, next)
	return nil
}

func (o *Order) History() string {
	parts := []string{}
	for _, s := range o.history {
		parts = append(parts, s.String())
	}
	return strings.Join(parts, " > ")
}

func run(o *Order, events ...Event) {
	for _, e := range events {
		from := o.state
		if err := o.Apply(e); err != nil {
			fmt.Printf("%-8s %-9s : %v\n", e, from, err)
		} else {
			fmt.Printf("%-8s %-9s -> %s\n", e, from, o.state)
		}
	}
}

func main() {
	a := NewOrder(1001)
	fmt.Printf("order %d %s\n", a.ID, a.state)
	run(a, Pay, Ship, Cancel, Deliver, Deliver)

	b := NewOrder(1002)
	fmt.Printf("order %d %s\n", b.ID, b.state)
	run(b, Cancel, Pay)

	fmt.Printf("history %d: %s\n", a.ID, a.History())
	fmt.Printf("history %d: %s\n", b.ID, b.History())
}

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

order 1001 placed
pay      placed    -> paid
ship     paid      -> shipped
cancel   shipped   : cannot cancel when shipped
deliver  shipped   -> delivered
deliver  delivered : cannot deliver when delivered
order 1002 placed
cancel   placed    -> cancelled
pay      cancelled : cannot pay when cancelled
history 1001: placed > paid > shipped > delivered
history 1002: placed > cancelled

Output টা পড়ি:

  • Order 1001 যায় placed → paid → shipped। Shipped অবস্থায় cancel ফিরিয়ে দেওয়া হয়, state shipped ই থাকে।
  • দ্বিতীয় deliver ফিরিয়ে দেওয়া হয়, কারণ delivered final। Courier এর app callback বারবার পাঠালেও কিছু ভাঙে না।
  • Order 1002 সাথে সাথেই cancel হয়; দেরিতে আসা pay ফিরিয়ে দেওয়া হয়।
  • History তে থাকে শুধু যে transition গুলো সত্যি ঘটেছে, তাই এটা ভরসা করার মতো একটা audit trail।
  • Language ভেদে কী আলাদা: Rust table টা লেখে (state, event) জোড়ার উপর একটা match দিয়ে, আর নতুন state যোগ করলে compiler প্রতিটা match দেখিয়ে দেয় যেখানে সেটা handle করতে হবে। TypeScript enum এর বদলে string literal এর union ব্যবহার করে, তাই "shiped" একটা type error; type এর নাম OrderEvent, কারণ browser এ আগে থেকেই Event নামে একটা global আছে। Java তে EnumMap, যেটা দ্রুত আর ক্রম ঠিক রাখে। Go তে built-in enum নেই, তাই typed constant আর একটা String() method দিয়ে কাজ চলে; কোনো case বাদ পড়লে compiler ধরে না, table টা এক জায়গায় রাখার এটা আরেকটা কারণ।

Real system এ state machine

  • State রাখো enum column হিসেবে (বা CHECK constraint সহ string), আর history রাখো row হিসেবে (order_id, from, to, event, at)। History ই তোমার audit log, আবার debug করার tool ও।
  • Transition আর যে data সে রক্ষা করে, দুটো একসাথে atomic করো। "Paid মার্ক করো আর payment টা লিখে রাখো" একটা database transaction হওয়া উচিত, প্রায়ই UPDATE ... WHERE state = 'placed' দিয়ে, যাতে দুইটা callback দুজনেই জিতে না যায়।
  • Side effect হবে transition সফল হওয়ার পরে। "আপনার order shipped" SMS পাঠাও Apply কোনো error না দেওয়ার পরেই, আগে কখনো না।
  • বাইরে থেকে অচেনা event আসা স্বাভাবিক। Payment gateway আর courier callback বারবার পাঠায়। যে state machine repeat হওয়া event নিরাপদে ফিরিয়ে দেয়, সেটা তোমার system কে প্রায় বিনা খরচে idempotent বানিয়ে দেয়।
  • যখন প্রতিটা state এর নিজের আচরণ লাগে (প্রতিটা state অনেক event কে আলাদা ভাবে handle করে), তখন State pattern প্রতিটা state কে একটা class বানায়। এটা আসছে behavioural pattern এর chapter এ; তার আগ পর্যন্ত একটা table ই যথেষ্ট।

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

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

ভাঙা জীবনচক্রটা বাছো

order.status = "shiped"
ভাঙে: Typo থেকে এমন একটা state তৈরি হলো যেটা কোনো query খোঁজে না। Enum ব্যবহার করো, compiler ই ফিরিয়ে দেবে।

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

Business চায় customer delivered order ৭ দিনের মধ্যে return করতে পারবে। State machine এ কী বদলাবে?

উত্তর দেখো

পুরনো transition বাঁকিয়ে না, নতুন state আর নতুন transition যোগ করো: delivered --return--> return_requested --refund--> returned। ৭ দিনের rule টা হলো return transition এর উপর একটা guard (input হিসেবে লাগবে delivery র তারিখ আর আজকের তারিখ)। তখন delivered আর final থাকে না, যেটা জীবনচক্রে সত্যিকারের একটা পরিবর্তন, তাই table, diagram আর test একসাথে update করো। এর জন্য cancel আবার ব্যবহার কোরো না: delivery র পরের return আর shipping এর আগের cancel দুইটা আলাদা business event, টাকার হিসাবও আলাদা।

ছোট্ট quiz

১. Status এর জন্য string এর চেয়ে enum ভালো কেন?

উত্তর

Value গুলোর set বন্ধ, আর compiler বা type checker সেটা check করে, তাই typo আর বানানো state ঢুকতে পারে না, আর state handle করা প্রতিটা জায়গা খুঁজে বের করা যায়।

২. Legal transition এর list কোথায় থাকা উচিত?

উত্তর

এক জায়গায়, সাধারণত একটা table বা match হিসেবে, state বদলানোর একমাত্র method টার পাশে। Screen event পাঠায়; rule ঠিক করে না।

৩. Final state কী, আর এটা আলাদা করে বলে দেওয়া কেন দরকার?

উত্তর

যে state থেকে বের হওয়ার কোনো transition নেই, যেমন delivered বা cancelled। এটা বলে দিলে দেরিতে আসা বা বারবার আসা event শেষ হয়ে যাওয়া object কে আর বদলাতে পারে না।

৪. isPaid, isShipped, isCancelled boolean গুলোর বদলে একটা enum কেন?

উত্তর

তিনটা boolean এ আটটা combination হয়, যার বেশিরভাগই অসম্ভব। একটা enum field এ ঠিক ততগুলো state থাকে যতগুলো business এর আছে।

তোমার challenge: return

Return এর flow যোগ করো: delivered order return করা যাবে।

  • return event আর returned state যোগ করো, সাথে একটা নতুন transition: delivered → return → returned।
  • গল্পের শেষে order 1001 এ return পাঠাও, তারপর cancel।

চালানোর আগে আন্দাজ করো নতুন দুইটা লাইন আর নতুন history কী হবে।

হয়েছে বুঝবে যখন নতুন লাইন দুইটা হবে return delivered -> returned আর cancel returned : cannot cancel when returned, history এর লাইন হবে history 1001: placed > paid > shipped > delivered > returned, আর অন্য কোনো লাইন বদলাবে না।

এক কথায়

State গুলো enum হিসেবে লেখো, প্রতিটা legal move একটা table এ রাখো, state private রাখো আর বদলাও শুধু এমন একটা method দিয়ে যেটা table দেখে নেয়। Table এ যা নেই তা ফিরিয়ে দেওয়া হয়, তাই অসম্ভব state আর তৈরিই হয় না, আর table, diagram আর test সবসময় একই কথা বলে।

Station stamp

Chapter শেষ করো

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

Pro নিয়ে XP পাও →