সব problem (16)
System Design courseSystem Design Problems

0%

0/16 done

Practice · Low Level Design · State Machines

Design a Vending Machine

BeginnerState patternCoinsChangeInventory⏱ 1-2 ঘণ্টা

Idle, টাকা ঢুকেছে, জিনিস দিচ্ছে, stock শেষ: State pattern এর আসল জায়গা, সাথে ঠিকঠাক ভাংতি ফেরত দেওয়া আর কিছু গোলমাল হলে টাকা ফেরত।

Learn mode: পুরো solution নিচে দেখানো আছে।

Problem এর পরিচয়

DifficultyBeginner
সময়Design আর code মিলিয়ে ১–২ ঘণ্টা; interview তে মোটামুটি ৪৫ মিনিট
আগে জানা দরকারEnum আর state machine, interface, lock সম্পর্কে একটু ধারণা
যা শিখবেState pattern, ভাংতি ফেরত দেওয়া, fail করা বিক্রি যেন কিছুই না বদলায়
বাস্তবে কোথায়অফিস আর হাসপাতালের snacks আর drinks এর machine, ticket machine, টাকা নেয় এমন যেকোনো kiosk

Vending machine হলো State pattern এর চেনা প্রশ্ন। একই button ("select A1") টাকা ঢোকানো আছে কিনা তার উপর নির্ভর করে আলাদা কাজ করে, আর interviewer দেখতে চান তুমি এর উত্তর if status == … এর স্তূপ দিয়ে দাও কিনা। ভাংতি দেওয়া, আর ভাংতি দিতে না পারলে পরিষ্কারভাবে টাকা ফেরত দেওয়া, এখানে বাস্তবের ঝামেলাটা যোগ করে।

Problem statement

ঢাকার কোনো হাসপাতালের করিডোরে একটা snacks এর machine এর কথা ভাবো। এতে A1 (chips, ৩০ টাকা), B2 (juice, ৬৫ টাকা), C3 (পানি, ২০ টাকা) এর মতো slot আছে। এটা ৫, ১০, ২০, ৫০ আর ১০০ টাকার coin আর নোট নেয়, আর ভেতরের coin থেকে ভাংতি দেয়।

Customer টাকা ঢোকায়, একটা slot এর code চাপে, তারপর হয় জিনিস আর ভাংতি পায়, নয়তো পরিষ্কার একটা message: টাকা কম, শেষ হয়ে গেছে, বা "ভাংতি দেওয়া যাচ্ছে না"। কেনার আগে যেকোনো সময় cancel চাপতে পারে, আর নিজের coin গুলোই ফেরত পায়।

আমরা কী বানাচ্ছি: machine এর ভেতরের controller: coin নেওয়া, জিনিস বাছা, ভাংতি আর টাকা ফেরত দেওয়া।

কেন দরকার: যে machine টাকা খেয়ে ফেলে বা ভুল ভাংতি দেয়, সে খুব তাড়াতাড়ি customer আর টাকা দুটোই হারায়, আর সেখানে ঠিক করার জন্য কেউ দাঁড়িয়েও থাকে না।

এক লাইনে: টাকা নাও, customer যথেষ্ট দিলে আর ভাংতি দেওয়া গেলে জিনিস বিক্রি করো, নইলে পরিষ্কারভাবে টাকা ফেরত দাও।

Requirement

Functional requirement

  1. প্রতিটা slot এর একটা code, একটা product এর নাম, দাম আর stock আছে।
  2. ৫, ১০, ২০, ৫০ আর ১০০ টাকা নেবে; বাকি সব ফিরিয়ে দেবে।
  3. Insert customer এর balance এ যোগ করে।
  4. Select জিনিসটা বিক্রি করে, যদি slot টা থাকে, stock থাকে, আর balance দাম ঢেকে দেয়। ভাংতি দেওয়া হয় machine এর coin থেকে, বড়টা আগে।
  5. Machine ঠিক ভাংতি দিতে না পারলে বিক্রি বাতিল, customer তার coin ফেরত পায়। আর কিছুই বদলায় না।
  6. Cancel customer যে coin গুলো ঢুকিয়েছিল ঠিক সেগুলোই ফেরত দেয়।
  7. পরিষ্কার message: আগে টাকা দাও, টাকা কম (আর কত লাগবে), শেষ হয়ে গেছে, অচেনা slot।

Non-functional requirement

Requirementএখানে এর মানে
কোনো টাকা হারাবে নাপ্রতিটা রাস্তা শেষ হয় হয় বিক্রি আর ঠিক ভাংতি দিয়ে, নয়তো পুরো টাকা ফেরত দিয়ে
Extensibilityনতুন state (maintenance mode, card payment) আনতে প্রতিটা method নতুন করে লিখতে হবে না
Concurrency তেও ঠিক থাকাTouch screen আর coin slot আলাদা thread এ চলে; balance কখনো গোলমাল হবে না
TestabilityHardware ছাড়াই প্রতিটা rule test করা যায়

যা ধরে নিচ্ছি আর যা এখানে নেই

  • আসল machine এর সামনে একবারে একজন customer।
  • Card বা bKash payment নেই, restock এর screen নেই, audit report নেই।

যা জমা দিতে হবে

  • State গুলো, আর প্রতিটা state এ প্রতিটা event কী করে।
  • একটা class diagram।
  • Insert, select আর cancel এর code, ভাংতি দেওয়া সহ।
  • "টাকা কম", "শেষ হয়ে গেছে", "ভাংতি দেওয়া যায় না" আর cancel এর test।
Hint 1: 'select' আসলে কী করে?

ছোট একটা table বানাও: সারি গুলো state (টাকা নেই, টাকা আছে), কলাম গুলো event (insert, select, cancel)। বেশিরভাগ ঘর আলাদা হলে বুঝবে State pattern এর দিকে যাওয়ার ইঙ্গিত।

Hint 2: ভাংতি আসে কোথা থেকে?

Machine এর ভেতরের coin থেকে, সাথে customer এইমাত্র যেগুলো ঢুকিয়েছে সেগুলোও। আগে সবচেয়ে বড় coin দিয়ে চেষ্টা করো।

Hint 3: মাঝপথে ভাংতি fail করলে?

Coin এর হিসাবের একটা copy নিয়ে কাজ করো। পুরো টাকা মেটানো গেলে তবেই আসল হিসাবের জায়গায় copy টা বসাও।

Hint 4: cancel

Customer কে তার নিজের coin ফেরত দিতে হলে শুধু মোট টাকা না, সে কী কী ঢুকিয়েছে তার একটা list রাখো।

মূল entity

Entityএক বাক্যে কাজকী রাখেকী করে
Productএকটা slot এ যা আছেনাম, দাম, stockকিছু না: শুধু data
Machineএকমাত্র প্রবেশপথ, আর সব টাকা আর stock এর মালিকslot, coin bank, ঢোকানো coin, এখনকার state, lockInsert, Choose, Cancel
state (interface)Machine এই মুহূর্তে event এর উত্তর কীভাবে দেয়কিছু নাinsert, choose, cancel
idle, hasMoneyদুইটা stateকিছু নাপ্রতিটা event এর নিজস্ব উত্তর
makeChangeএকটা coin bank থেকে বড়টা আগে নিয়ে একটা পরিমাণ মেটায়কিছু নাcoin, নতুন bank আর ok ফেরত দেয়
SaleCustomer যা পায়product, ভাংতিকিছু না: একটা value

কে কার সাথে কীভাবে যুক্ত: Machine তার slot, coin bank আর ঢোকানো coin এর মালিক। সে একটা এখনকার state ধরে রাখে আর প্রতিটা event তার হাতে তুলে দেয়। State গুলো machine কে বদলায় (আর পরের state দিয়ে নিজেদের বদলে দেয়)। makeChange একটা pure function: bank এর copy পায় আর নতুন একটা ফেরত দেয়।

Diagram

Class diagram

Machine- state: state- slots: map[string]*Product- bank: map[int]int- inserted: []int- mu: sync.Mutex+ Insert(coin) error+ Choose(slot) (Sale, error)+ Cancel() ([]int, error)+ Balance() intProduct+ Name: string+ Price: int+ Stock: intSale+ Product: string+ Change: []int«interface»stateinsert(m, coin) errorchoose(m, slot) (Sale, error)cancel(m) ([]int, error)idleinsert / choose / cancelhasMoneyinsert / choose / cancelcurrentslots1*Choose returnseach state answers insert, choose and cancel its own way (State)Machine hands every button press to its current stateMachine owns the slots; each slot holds a Product with price and stocka successful Choose returns a Sale: the product and the change
Play চাপো। আগে state interface implement করা দুইটা state জ্বলে ওঠে, তারপর Machine থেকে তার এখনকার state এর link, তারপর তার নিজের slot গুলো, শেষে সে যে Sale ফেরত দেয় সেটা।

কীভাবে পড়বে: Machine এর ভেতরে state type এর একটা field আছে, আর idle আর hasMoney দুটোই ওই interface implement করে। প্রতিটা public button (Insert, Choose, Cancel) শুধু এখনকার state এর কাছে কাজটা পাঠিয়ে দেয়, তাই কোথাও state নিয়ে switch নেই। Product গুলোর মালিক Machine, আর Choose একটা Sale value ফেরত দেয়।

চিহ্ন গুলো: + মানে public, - মানে private। ভরা diamond ◆ মানে মালিক (অংশটা মালিকের সাথেই আসে, সাথেই যায়), সাধারণ তীর মানে reference ধরে রাখে, dashed তীর মানে ব্যবহার করে, আর ফাঁপা ত্রিভুজওয়ালা dashed তীর মানে এই interface implement করে।

Sequence: chips কেনা

CustomerMachinehasMoneymakeChangeChoose(A1)lockchoose(m, A1)30 ≤ 40: okmakeChange(10, bank)[10], okstock-1, state = idleSale{Chips, [10]}Chips + change [10]
Play চাপো। ৪০ টাকা ঢোকানো, customer A1 বাছে। Machine কাজটা hasMoney কে দেয়, সে দাম check করে, makeChange এর কাছে ১০ টাকা চায়, machine update করে আর sale ফেরত দেয়।

কীভাবে পড়বে: প্রতিটা খাড়া dashed লাইন একটা object, আর সময় চলে উপর থেকে নিচে। ভরা তীর মানে call, dashed তীর মানে উত্তর ফেরত আসা, আর সরু bar গুলো দেখায় কোন object কখন কাজে ব্যস্ত। Lock টা Machine এ একবারই নেওয়া হয়, উত্তর ফেরা পর্যন্ত ধরে রাখা থাকে। আসল কাজ করে hasMoney, আর machine কে আবার idle এ নিয়ে যায়ও সে। makeChange যদি বলত "হবে না", তাহলে ফেরার তীরটা হতো একটা error, আর কিছুই বদলাত না।

State: প্রতিটা event কী করে

insert coinsale done / cancelIdleno moneyHasMoneybalance > 0dispenseChips + changeinsert 20select A1: needs 10 tk more, stays in HasMoney+20select A1 again: balance 40, price 30change [10]each state answers insert, select and cancel in its own way
Play চাপো। একটা coin machine কে Idle থেকে HasMoney তে নেয়। কম টাকায় A1 চাপলে machine HasMoney তেই থাকে; আরেকটা coin এর পরে বিক্রি হয়, ভাংতি বের হয়, আর machine আবার Idle এ ফেরে।

কীভাবে পড়বে: দুইটা box হলো দুইটা state; তীর গুলো event। Idle এ insert নিয়ে যায় HasMoney তে, আর select বা cancel ফিরিয়ে দেওয়া হয়। HasMoney তে insert এ সেখানেই থাকে, select হয় সেখানেই রাখে (টাকা কম, শেষ হয়ে গেছে), নয়তো বিক্রি করে Idle এ ফেরে, আর cancel টাকা ফেরত দিয়ে Idle এ ফেরে। প্রতিটা state একটা class, যে একই তিনটা event এর উত্তর নিজের মতো দেয়।

Coin bank থেকে ভাংতি দেওয়া

100x250x020x4 → x310x1 → x05x2 → x1coins in the machine, largest first20105change 35 = 20 + 10 + 5change 70: 20 + 20 + 20 + 5 = 65, still 5 owed: cancel and refund the 100
Play চাপো। ৩৫ টাকার ভাংতি বড়টা আগে দিয়ে মেটানো হয়: ২০, তারপর ১০, তারপর ৫। পরে ৭০ টাকার ভাংতি দিতে গিয়ে coin শেষ হয়ে যায়, ৫ টাকা তখনো বাকি, তাই বিক্রি বাতিল আর customer এর ১০০ ফেরত।

কীভাবে পড়বে: প্রতিটা box একটা coin এর মাপ, আর machine এ কয়টা আছে। ভাংতি দেওয়া হয় এখনো খাপ খায় এমন সবচেয়ে বড় coin নিয়ে। পরিমাণ শূন্যে না নামলে কিছুই নেওয়া হয় না: বিক্রি বাতিল, আর ঢোকানো coin গুলো ফেরত।

Design principle

  • Single responsibility: Machine data আর lock রাখে; প্রতিটা state একটা পরিস্থিতির rule রাখে; makeChange শুধু হিসাব করে।
  • Open/closed: একটা "maintenance" state (সব ফিরিয়ে দেয়, technician এর চাবি নেয়) যোগ করা মানে নতুন একটা type; Insert, Choose আর Cancel বদলায় না।
  • Encapsulation: coin, stock আর state private। শুধু তিনটা public method এগুলো বদলাতে পারে, আর তিনটাই lock নেয়।
  • হয় সব, নয়তো কিছুই না: একটা বিক্রি হয় পুরোটা হয় (stock কমে, bank update, ভাংতি দেওয়া), নয়তো কিছুই বদলায় না। makeChange copy নিয়ে কাজ করে বলে এটা সহজ।

Design pattern

State

  1. কেন দরকার: select, insert আর cancel এর মানে টাকা থাকলে আর না থাকলে আলাদা।
  2. কী সমাধান করে: প্রতিটা method এর if m.status == "idle" { … } else if … সরিয়ে প্রতিটা state এর জন্য একটা object।
  3. না থাকলে কী হতো: প্রতিটা নতুন state (maintenance, card payment, out of order) এ প্রতিটা method edit করতে হতো।
  4. দাম কী: type বেশি। দুইটা state এর জন্য একটা switch ই যথেষ্ট; state বাড়লে pattern টা নিজের খরচ তুলে নেয়, আর interviewer ঠিক এই follow-up টাই জিজ্ঞেস করেন।
  5. কোথায় আছে: state.go। Machine রাখে state, আর ডাকে m.state.choose(m, slot)।

যে pattern গুলো নেইনি

  • ভাংতি দেওয়ার জন্য Strategy: ১০০/৫০/২০/১০/৫ এর জন্য greedy ঠিক কাজ করে। Machine এ কখনো অদ্ভুত মাপের coin আসলে makeChange কে একটা dynamic-programming version দিয়ে বদলে দাও; এটা একটাই function, তাই এখনো interface লাগে না।
  • Machine এর জন্য Singleton: প্রতিটা আসল machine হলো main এ বানানো একটা object; test নিজের গুলো বানায়।

Go তে পুরো solution

Project structure

vending/
├── go.mod
├── product.go       Product, Sale, accepted coins
├── change.go        makeChange: pay an amount from a coin bank
├── state.go         the State pattern: idle and hasMoney
├── machine.go       Machine: the entry point and the lock
├── machine_test.go  tests, including a concurrency test
└── cmd/demo/main.go a customer at the machine

ধাপ ১: product আর coin

// product.go
package vending

// Product sits in one slot. Price is in taka.
type Product struct {
	Name  string
	Price int
	Stock int
}

// Coins the machine accepts, largest first (also the order change is given in).
var Denominations = []int{100, 50, 20, 10, 5}

func validCoin(c int) bool {
	for _, d := range Denominations {
		if d == c {
			return true
		}
	}
	return false
}

// Sale is what the customer gets back from a successful purchase.
type Sale struct {
	Product string
	Change  []int
}

ধাপ ২: ভাংতি দেওয়া, copy এর উপর

// change.go
package vending

// makeChange pays `amount` from the coins in `bank`, largest first, and returns the coins
// used. It works on a copy, so a failed attempt changes nothing. Greedy is correct for
// 100/50/20/10/5; for odd coin sets you'd need dynamic programming.
func makeChange(amount int, bank map[int]int) ([]int, map[int]int, bool) {
	left := map[int]int{}
	for d, n := range bank {
		left[d] = n
	}
	var coins []int
	for _, d := range Denominations {
		for amount >= d && left[d] > 0 {
			amount -= d
			left[d]--
			coins = append(coins, d)
		}
	}
	return coins, left, amount == 0
}

ধাপ ৩: state গুলো

hasMoney.choose ভাংতি হিসাব করার আগে ঢোকানো coin গুলো bank এর একটা copy তে যোগ করে, যাতে customer এর নিজের coin ও ভাংতিতে ব্যবহার করা যায়। ভাংতি সফল হলে তবেই copy টা আসল bank এর জায়গা নেয়।

// state.go
package vending

import (
	"errors"
	"fmt"
)

var (
	ErrBadCoin     = errors.New("not a valid coin")
	ErrInsertFirst = errors.New("insert money first")
	ErrNothing     = errors.New("nothing to refund")
	ErrUnknownSlot = errors.New("no such slot")
)

// state is the State pattern: each state answers the same three events in its own way.
type state interface {
	insert(m *Machine, coin int) error
	choose(m *Machine, slot string) (Sale, error)
	cancel(m *Machine) ([]int, error)
}

// idle: no money inserted.
type idle struct{}

func (idle) insert(m *Machine, coin int) error {
	m.inserted = append(m.inserted, coin)
	m.state = hasMoney{}
	return nil
}
func (idle) choose(*Machine, string) (Sale, error) { return Sale{}, ErrInsertFirst }
func (idle) cancel(*Machine) ([]int, error)        { return nil, ErrNothing }

// hasMoney: the customer has a balance and can buy or cancel.
type hasMoney struct{}

func (hasMoney) insert(m *Machine, coin int) error {
	m.inserted = append(m.inserted, coin)
	return nil
}

func (hasMoney) choose(m *Machine, slot string) (Sale, error) {
	p, ok := m.slots[slot]
	if !ok {
		return Sale{}, ErrUnknownSlot
	}
	if p.Stock == 0 {
		return Sale{}, fmt.Errorf("%s is sold out", p.Name)
	}
	if bal := m.balance(); bal < p.Price {
		return Sale{}, fmt.Errorf("%s costs %d tk, insert %d tk more", p.Name, p.Price, p.Price-bal)
	}
	// The inserted coins join the bank first, so they can be used for change.
	bank := map[int]int{}
	for d, n := range m.bank {
		bank[d] = n
	}
	for _, c := range m.inserted {
		bank[c]++
	}
	change, after, ok := makeChange(m.balance()-p.Price, bank)
	if !ok {
		refund := m.inserted
		m.inserted, m.state = nil, idle{}
		return Sale{}, fmt.Errorf("cannot make change, refunded %v", refund)
	}
	m.bank = after
	p.Stock--
	m.inserted, m.state = nil, idle{}
	return Sale{Product: p.Name, Change: change}, nil
}

func (hasMoney) cancel(m *Machine) ([]int, error) {
	refund := m.inserted
	m.inserted, m.state = nil, idle{}
	return refund, nil
}

ধাপ ৪: machine

প্রতিটা public method lock নেয়, আর event টা এখনকার state এর হাতে দেয়।

// machine.go
package vending

import "sync"

// Machine is the one entry point. The current state object decides how each event is handled.
type Machine struct {
	mu       sync.Mutex
	state    state
	slots    map[string]*Product
	bank     map[int]int // coins available for change
	inserted []int       // this customer's coins, returned exactly on cancel
}

func NewMachine(slots map[string]*Product, bank map[int]int) *Machine {
	return &Machine{state: idle{}, slots: slots, bank: bank}
}

func (m *Machine) Insert(coin int) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	if !validCoin(coin) {
		return ErrBadCoin
	}
	return m.state.insert(m, coin)
}

func (m *Machine) Choose(slot string) (Sale, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	return m.state.choose(m, slot)
}

func (m *Machine) Cancel() ([]int, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	return m.state.cancel(m)
}

// Balance is the customer's money in the machine.
func (m *Machine) Balance() int {
	m.mu.Lock()
	defer m.mu.Unlock()
	return m.balance()
}

// balance is for code that already holds the lock (the states).
func (m *Machine) balance() int {
	total := 0
	for _, c := range m.inserted {
		total += c
	}
	return total
}

// Stock reports how many are left in a slot.
func (m *Machine) Stock(slot string) int {
	m.mu.Lock()
	defer m.mu.Unlock()
	return m.slots[slot].Stock
}

ধাপ ৫: চালিয়ে দেখো

// cmd/demo/main.go
package main

import (
	"fmt"

	"vending"
)

func main() {
	m := vending.NewMachine(
		map[string]*vending.Product{
			"A1": {Name: "Chips", Price: 30, Stock: 2},
			"B2": {Name: "Juice", Price: 65, Stock: 3},
			"C3": {Name: "Water", Price: 20, Stock: 0},
		},
		map[int]int{20: 2, 10: 2, 5: 2}, // coins loaded for change
	)
	show := func(action, result string) { fmt.Printf("%-14s -> %s\n", action, result) }
	insert := func(c int) {
		if err := m.Insert(c); err != nil {
			show(fmt.Sprint("insert ", c), "rejected: "+err.Error())
			return
		}
		show(fmt.Sprint("insert ", c), fmt.Sprintf("balance %d tk", m.Balance()))
	}
	choose := func(slot string) {
		s, err := m.Choose(slot)
		if err != nil {
			show("select "+slot, err.Error())
			return
		}
		total := 0
		for _, c := range s.Change {
			total += c
		}
		show("select "+slot, fmt.Sprintf("%s, change %d tk %v", s.Product, total, s.Change))
	}

	insert(20)
	choose("A1")
	insert(20)
	choose("A1")
	choose("B2")
	insert(100)
	choose("B2")
	insert(50)
	choose("C3")
	if r, err := m.Cancel(); err == nil {
		show("cancel", fmt.Sprintf("refunded %v", r))
	}
	insert(3)
	insert(100)
	choose("A1")
	fmt.Printf("stock: A1 %d, B2 %d, C3 %d\n", m.Stock("A1"), m.Stock("B2"), m.Stock("C3"))
}
$ go run ./cmd/demo
insert 20      -> balance 20 tk
select A1      -> Chips costs 30 tk, insert 10 tk more
insert 20      -> balance 40 tk
select A1      -> Chips, change 10 tk [10]
select B2      -> insert money first
insert 100     -> balance 100 tk
select B2      -> Juice, change 35 tk [20 10 5]
insert 50      -> balance 50 tk
select C3      -> Water is sold out
cancel         -> refunded [50]
insert 3       -> rejected: not a valid coin
insert 100     -> balance 100 tk
select A1      -> cannot make change, refunded [100]
stock: A1 1, B2 2, C3 0

Output টা পড়ি:

  • ২০, তারপর select A1: chips এর দাম ৩০, তাই machine আরো ১০ চায়, আর ২০ রেখে দেয়।
  • আরেকটা ২০, select A1: balance ৪০, chips ৩০, ভাংতি ১০।
  • টাকা ছাড়া select B2: Idle ফিরিয়ে দেয় "insert money first" বলে।
  • ১০০, select B2: juice ৬৫, ৩৫ টাকার ভাংতি আসে ২০ + ১০ + ৫ হিসেবে।
  • ৫০, select C3: পানি শেষ; ৫০ ভেতরেই থাকে, আর cancel হুবহু [50] ফেরত দেয়।
  • insert 3: এমন coin machine নেয় না।
  • ১০০, select A1: ৭০ টাকা ভাংতি লাগবে, কিন্তু machine এ বাকি আছে তিনটা ২০ আর একটা ৫ (৬৫ টাকা)। সে ১০০ ফেরত দেয়, কিছুই বিক্রি করে না। শেষ stock এর লাইন দেখায় A1 এ এখনো ১টা আছে।

বাকি পাঁচটা language

একই design, একই গল্প, একই output। প্রতিটা program চালানো হয়েছে।

from __future__ import annotations

import threading
from dataclasses import dataclass

DENOMINATIONS = [100, 50, 20, 10, 5]  # largest first


@dataclass
class Product:
    name: str
    price: int
    stock: int


class VendingError(Exception):
    pass


def make_change(amount: int, bank: dict[int, int]) -> tuple[list[int], dict[int, int], bool]:
    """Pay `amount` from `bank`, largest coin first, working on a copy."""
    left, coins = dict(bank), []
    for d in DENOMINATIONS:
        while amount >= d and left.get(d, 0) > 0:
            amount -= d
            left[d] -= 1
            coins.append(d)
    return coins, left, amount == 0


class Idle:
    """State: no money inserted."""

    def insert(self, m: Machine, coin: int) -> None:
        m.inserted.append(coin)
        m.state = HasMoney()

    def choose(self, m: Machine, slot: str):
        raise VendingError("insert money first")

    def cancel(self, m: Machine) -> list[int]:
        raise VendingError("nothing to refund")


class HasMoney:
    """State: the customer has a balance and can buy or cancel."""

    def insert(self, m: Machine, coin: int) -> None:
        m.inserted.append(coin)

    def choose(self, m: Machine, slot: str) -> tuple[str, list[int]]:
        p = m.slots.get(slot)
        if p is None:
            raise VendingError("no such slot")
        if p.stock == 0:
            raise VendingError(f"{p.name} is sold out")
        bal = sum(m.inserted)
        if bal < p.price:
            raise VendingError(f"{p.name} costs {p.price} tk, insert {p.price - bal} tk more")
        bank = dict(m.bank)
        for c in m.inserted:  # inserted coins can be used for change
            bank[c] = bank.get(c, 0) + 1
        change, after, ok = make_change(bal - p.price, bank)
        refund = m.inserted
        m.inserted, m.state = [], Idle()
        if not ok:
            raise VendingError(f"cannot make change, refunded {fmt(refund)}")
        m.bank = after
        p.stock -= 1
        return p.name, change

    def cancel(self, m: Machine) -> list[int]:
        refund = m.inserted
        m.inserted, m.state = [], Idle()
        return refund


class Machine:
    def __init__(self, slots: dict[str, Product], bank: dict[int, int]):
        self._lock = threading.Lock()
        self.state: Idle | HasMoney = Idle()
        self.slots, self.bank = slots, bank
        self.inserted: list[int] = []

    def insert(self, coin: int) -> None:
        with self._lock:
            if coin not in DENOMINATIONS:
                raise VendingError("not a valid coin")
            self.state.insert(self, coin)

    def choose(self, slot: str) -> tuple[str, list[int]]:
        with self._lock:
            return self.state.choose(self, slot)

    def cancel(self) -> list[int]:
        with self._lock:
            return self.state.cancel(self)

    def balance(self) -> int:
        with self._lock:
            return sum(self.inserted)


def fmt(coins: list[int]) -> str:
    return "[" + " ".join(map(str, coins)) + "]"  # same format as Go


m = Machine({"A1": Product("Chips", 30, 2), "B2": Product("Juice", 65, 3), "C3": Product("Water", 20, 0)},
            {20: 2, 10: 2, 5: 2})


def show(action: str, result: str) -> None:
    print(f"{action:<14} -> {result}")


def insert(c: int) -> None:
    try:
        m.insert(c)
        show(f"insert {c}", f"balance {m.balance()} tk")
    except VendingError as e:
        show(f"insert {c}", f"rejected: {e}")


def choose(slot: str) -> None:
    try:
        name, change = m.choose(slot)
        show(f"select {slot}", f"{name}, change {sum(change)} tk {fmt(change)}")
    except VendingError as e:
        show(f"select {slot}", str(e))


insert(20)
choose("A1")
insert(20)
choose("A1")
choose("B2")
insert(100)
choose("B2")
insert(50)
choose("C3")
show("cancel", f"refunded {fmt(m.cancel())}")
insert(3)
insert(100)
choose("A1")
print(f"stock: A1 {m.slots['A1'].stock}, B2 {m.slots['B2'].stock}, C3 {m.slots['C3'].stock}")

এক language থেকে আরেকটায় কী বদলায়:

State গুলোLock
Goদুইটা ছোট type সহ একটা interfaceপ্রতিটা public method এ sync.Mutex
Pythonএকই method ওয়ালা দুইটা class (duck typing)with সহ threading.Lock
TypeScriptState type এর দুইটা object literalদরকার নেই: এক thread, কোনো await নেই
Rustএকটা enum State আর match: প্রতিটা state handle হলো কিনা compiler দেখেসব data একটা Mutex এর ভেতরে
Javaদুইটা class সহ একটা interfacesynchronized method
C++একটা abstract class, unique_ptr<State>std::lock_guard

C++ এর একটা খুঁটিনাটি জানা ভালো: কোনো state নিজেকে বদলে দিলে (m.state = make_unique<Idle>()), পুরনো state object ওই লাইনেই ধ্বংস হয়, তাই তার পরে code শুধু local variable ছোঁয়।

Testing

// machine_test.go
package vending

import (
	"errors"
	"slices"
	"sync"
	"testing"
)

func newTestMachine() *Machine {
	return NewMachine(
		map[string]*Product{
			"A1": {Name: "Chips", Price: 30, Stock: 2},
			"C3": {Name: "Water", Price: 20, Stock: 0},
		},
		map[int]int{20: 2, 10: 2, 5: 2},
	)
}

func TestChooseBeforeInsert(t *testing.T) {
	m := newTestMachine()
	if _, err := m.Choose("A1"); !errors.Is(err, ErrInsertFirst) {
		t.Fatalf("got %v", err)
	}
	if _, err := m.Cancel(); !errors.Is(err, ErrNothing) {
		t.Fatalf("cancel in idle: %v", err)
	}
}

func TestBadCoinChangesNothing(t *testing.T) {
	m := newTestMachine()
	if err := m.Insert(3); !errors.Is(err, ErrBadCoin) {
		t.Fatalf("got %v", err)
	}
	if m.Balance() != 0 {
		t.Fatalf("balance %d", m.Balance())
	}
}

func TestNotEnoughMoneyKeepsBalance(t *testing.T) {
	m := newTestMachine()
	m.Insert(20)
	if _, err := m.Choose("A1"); err == nil {
		t.Fatal("expected an error")
	}
	if m.Balance() != 20 || m.Stock("A1") != 2 {
		t.Fatalf("balance %d stock %d", m.Balance(), m.Stock("A1"))
	}
}

func TestExactChange(t *testing.T) {
	m := newTestMachine()
	m.Insert(50)
	s, err := m.Choose("A1")
	if err != nil || s.Product != "Chips" || !slices.Equal(s.Change, []int{20}) {
		t.Fatalf("got %+v, %v", s, err)
	}
	if m.Stock("A1") != 1 || m.Balance() != 0 {
		t.Fatal("stock or balance not updated")
	}
}

func TestSoldOut(t *testing.T) {
	m := newTestMachine()
	m.Insert(20)
	if _, err := m.Choose("C3"); err == nil || err.Error() != "Water is sold out" {
		t.Fatalf("got %v", err)
	}
	if m.Balance() != 20 {
		t.Fatal("sold out should keep the money in")
	}
}

func TestCancelReturnsSameCoins(t *testing.T) {
	m := newTestMachine()
	m.Insert(10)
	m.Insert(5)
	got, err := m.Cancel()
	if err != nil || !slices.Equal(got, []int{10, 5}) {
		t.Fatalf("got %v, %v", got, err)
	}
}

func TestNoChangeRefundsAndKeepsBank(t *testing.T) {
	m := NewMachine(map[string]*Product{"A1": {Name: "Chips", Price: 30, Stock: 1}}, map[int]int{})
	m.Insert(100)
	if _, err := m.Choose("A1"); err == nil {
		t.Fatal("expected cannot make change")
	}
	if m.Stock("A1") != 1 || m.Balance() != 0 || len(m.bank) != 0 {
		t.Fatal("a failed sale must change nothing")
	}
}

// Many goroutines inserting at once must not lose a coin. Run with -race.
func TestConcurrentInserts(t *testing.T) {
	m := newTestMachine()
	var wg sync.WaitGroup
	for range 50 {
		wg.Add(1)
		go func() { defer wg.Done(); m.Insert(10) }()
	}
	wg.Wait()
	if m.Balance() != 500 {
		t.Fatalf("balance %d, want 500", m.Balance())
	}
}
$ go test -race -v ./...
=== RUN   TestChooseBeforeInsert
--- PASS: TestChooseBeforeInsert (0.00s)
=== RUN   TestBadCoinChangesNothing
--- PASS: TestBadCoinChangesNothing (0.00s)
=== RUN   TestNotEnoughMoneyKeepsBalance
--- PASS: TestNotEnoughMoneyKeepsBalance (0.00s)
=== RUN   TestExactChange
--- PASS: TestExactChange (0.00s)
=== RUN   TestSoldOut
--- PASS: TestSoldOut (0.00s)
=== RUN   TestCancelReturnsSameCoins
--- PASS: TestCancelReturnsSameCoins (0.00s)
=== RUN   TestNoChangeRefundsAndKeepsBank
--- PASS: TestNoChangeRefundsAndKeepsBank (0.00s)
=== RUN   TestConcurrentInserts
--- PASS: TestConcurrentInserts (0.00s)
PASS
ok  	vending	1.012s
?   	vending/cmd/demo	[no test files]
Testযা check করে
TestChooseBeforeInsertIdle select আর cancel ফিরিয়ে দেয়
TestBadCoinChangesNothing৩ টাকার coin ফিরিয়ে দেওয়া হয়, balance ০ থাকে
TestNotEnoughMoneyKeepsBalanceকম টাকায় select করলে টাকা আর stock দুটোই থাকে
TestExactChange৩০ টাকার chips এ ৫০ দিলে একটা ২০ ফেরত
TestSoldOutশেষ হয়ে গেলে টাকা ভেতরেই থাকে
TestCancelReturnsSameCoinsCancel ফেরত দেয় [10 5], যেগুলো আসলেই ঢোকানো হয়েছিল
TestNoChangeRefundsAndKeepsBankFail করা বিক্রি কিছুই বদলায় না: stock, balance, bank
TestConcurrentInserts৫০টা goroutine প্রত্যেকে ১০ ঢোকায়: balance ঠিক ৫০০

Insert থেকে lock সরিয়ে concurrency test আবার চালিয়েছি: race detector ৭টা data race দেখালো, আর test fail করলো। Lock সহ pass করে।

Design review

যা ভালো: প্রতিটা রাস্তা শেষ হয় বিক্রিতে নয়তো পুরো টাকা ফেরতে; State pattern প্রতিটা পরিস্থিতির rule একসাথে রাখে; ভাংতি দেওয়া একটা pure function, test করা সহজ।

"যদি…"কী বদলাতে হবে
Card বা bKash paymentCoin এর পাশে নতুন একটা payment এর উৎস; select করলে balance না, card থেকে কাটা হবে। সম্ভবত নতুন একটা state, PayingByCard।
Maintenance modeএকটা maintenance state, যেটা customer ফিরিয়ে দেয় আর restock করতে দেয়। আগের কোনো method বদলায় না।
অদ্ভুত মাপের coin (ধরো ২৫)Solution থাকলেও greedy fail করতে পারে; makeChange কে dynamic programming দিয়ে বদলাও।
ছোট coin রেখে দিতে চাইভাংতির নীতি একটা strategy হয়ে যায়: বড় coin আগে দাও, বা machine এ কমপক্ষে কয়েকটা ৫ রেখে দাও।
বিক্রির মাঝে কারেন্ট চলে গেলঢোকানো coin আর stock save করো; চালু হলে ঢোকানো coin ফেরত দাও।

Production এ যাওয়ার আগে: প্রতিটা insert, বিক্রি আর refund এর একটা event log (ঝামেলা হলে দেখার জন্য), আর ভাংতি কমে গেলে একটা সতর্কবার্তা, যাতে machine বিক্রি ফিরিয়ে দেওয়া শুরু করার আগেই staff coin ভরে দেয়।

Interview প্রশ্ন

Practice প্রশ্ন

Beginner

  1. Status এর উপর switch না দিয়ে State pattern কেন?
  2. শুধু মোট টাকা না রেখে ঢোকানো coin এর list কেন রাখো?

Intermediate

  1. Fail করা বিক্রি যেন কিছুই না বদলায়, সেটা কীভাবে নিশ্চিত করো?
  2. "Maintenance mode" কোথায় যাবে?

Advanced

  1. Greedy ভাংতি কখন ভুল, আর তার বদলে কী ব্যবহার করবে?
  2. Touch screen আর coin slot আলাদা thread এ চলে। Lock না থাকলে কী গোলমাল হতে পারে?

Design এর সাধারণ ভুল

  • একটা status string, আর প্রতিটা method এ if এর chain।
  • ভাংতি দেওয়া যাবে কিনা জানার আগেই bank থেকে coin নিয়ে নেওয়া, তাতে fail করা বিক্রির পরে bank এ কম থাকে।
  • "৫০ টাকা" যেকোনো coin এ ফেরত দেওয়া, customer এর নিজের coin না দিয়ে।
  • Payment সফল হওয়ার আগেই stock কমানো।

উত্তর

১. State pattern কেন?

প্রতিটা event প্রতিটা state এ আলাদা আচরণ করে। Switch দিলে প্রতিটা method এ প্রতিটা state এর জন্য একটা branch বাড়ে, আর প্রতিটা নতুন state এ প্রতিটা method edit করতে হয়। Pattern দিলে নতুন state মানে নতুন type, আর machine শুধু কাজটা তার হাতে তুলে দেয়।

২. ঢোকানো coin কেন রাখবো?

Cancel এ customer যা ঢুকিয়েছিল সেটাই ফেরত দেওয়া উচিত, আর ঢোকানো coin দিয়ে ভাংতিও দেওয়া যায়। শুধু মোট রাখলে দুটোই হারায়।

৩. Fail করা বিক্রি কীভাবে কিছুই বদলায় না?

makeChange bank এর copy নিয়ে কাজ করে। ভাংতি সফল হলে তবেই stock আর bank update হয়; fail হলে ঢোকানো coin ফেরত, আর state আবার idle।

৪. Maintenance mode কোথায় যাবে?

নতুন একটা state type এ: সে insert আর select ফিরিয়ে দেয়, আর machine এ এই mode এ ঢোকা আর বের হওয়ার জন্য technician এর একটা method থাকে। আগের state গুলো বদলায় না।

৫. Greedy কখন ভুল?

২৫/১০/১ এর মতো coin এর set এ greedy ৩০ মেটায় ২৫ + ১×৫ দিয়ে (ছয়টা coin), ১০×৩ এর বদলে। আর coin সীমিত থাকলে অন্য একটা combination কাজ করলেও greedy fail করতে পারে। হাতে থাকা coin এর উপর dynamic programming solution থাকলে সেটা খুঁজে পায়।

৬. Lock না থাকলে কী গোলমাল হয়?

Insert এর "ঢোকানো list পড়ো, যোগ করো" আর select এর "balance পড়ো, list খালি করো" মাঝখানে মিশে যেতে পারে, তাতে একটা coin হারায় বা পুরনো balance দিয়ে বিক্রি হয়। Lock সরিয়ে আমাদের test আসল data race দেখিয়েছে।

Interviewer রা যা দেখেন: পরিষ্কার একটা state table, কারণ সহ State pattern, হয় সব নয়তো কিছুই না এমন বিক্রি, আর ভাংতি দেওয়া নিয়ে বুঝেশুনে একটা উত্তর।

নিজে design করে solution টা বুঝেছো? Mark করো, তাহলে progress ট্র্যাক হবে।