Week 14 Tutorial: Design Patterns
Problem 1 (Easy): Shared Visit Counter
A small website wants to count how many times users interact with it. Two different parts of the app need to update and read the same counter:
auth.py— adds 1 to the counter every time a user logs in.shop.py— adds 1 to the counter every time a user buys something.
Both files must work with the same shared counter — not their own copies. Build the counter as a module singleton (a plain .py file with shared state).
- Create a file
counter.pywith:- a variable
countstarting at0 - a function
add_visit()that increasescountby 1 - a function
get_count()that returns the currentcount
- a variable
- Create a file
auth.pywith a functionlogin(user)that prints"<user> logged in"and callscounter.add_visit(). - Create a file
shop.pywith a functionbuy(user, item)that prints"<user> bought <item>"and callscounter.add_visit(). - Create a file
main.pythat imports all three modules and runs the input scenario below. - At the end,
main.pymust print the total visit count by callingcounter.get_count()— and the number must reflect every call from bothauth.pyandshop.py.
Input
Alisher logs in
Sevara logs in
Alisher buys "book"
Sevara buys "phone"
Alisher buys "pen"
Expected Output
Alisher logged in
Sevara logged in
Alisher bought book
Sevara bought phone
Alisher bought pen
Total visits: 5
Problem 2 (Easy+): Unique ID Generator
Many parts of an app need to create unique IDs — for users, for orders, for messages. The rule: no two IDs should ever be the same, even if different parts of the app create their own IDGenerator().
Build an IDGenerator class as a Singleton (using __new__). Every time someone calls next_id(), it returns the next number: 1, 2, 3, … — even if the call comes from a different IDGenerator() object.
- Create a class
IDGeneratorwith:- a class variable
_instance = None - a
__new__method that creates the object only once and saves it in_instance - inside
__new__, setcurrent = 0on the instance the first time it is created (do not put this in__init__) - a method
next_id()that increasescurrentby 1 and returns the new value
- a class variable
- In the main code, create three different generator objects:
users_gen,orders_gen,messages_gen. - Run the input code below.
- At the end, print whether all three variables point to the same object using
is.
Input
print(f"User ID: {users_gen.next_id()}")
print(f"User ID: {users_gen.next_id()}")
print(f"Order ID: {orders_gen.next_id()}")
print(f"User ID: {users_gen.next_id()}")
print(f"Message ID: {messages_gen.next_id()}")
print(f"Order ID: {orders_gen.next_id()}")
Expected Output
User ID: 1
User ID: 2
Order ID: 3
User ID: 4
Message ID: 5
Order ID: 6
Same object? True
Problem 3 (Medium): Drink Factory
A coffee shop app sells three kinds of drinks: coffee, tea, and juice. Each drink has a size and knows how to prepare itself. The cashier should not write if/elif chains — they should just say “give me a coffee, large” and get the right object back.
Build a Factory that creates drinks based on a type chosen from an Enum. Use an abstract base class so every drink follows the same interface.
- Create an
EnumcalledDrinkTypewith three members:COFFEE,TEA,JUICE. - Create an abstract base class
Drink:__init__takessize(a string like"small","medium","large") and saves it.- has an abstract method
prepare().
- Create three subclasses of
Drink:Coffee.prepare()→ prints"Brewing <size> coffee ☕"Tea.prepare()→ prints"Steeping <size> tea 🍵"Juice.prepare()→ prints"Squeezing <size> juice 🧃"
- Create a class
DrinkFactorywith:- a class variable
_types— a dict mapping eachDrinkTypeto its class. - a
@staticmethod create(kind, size)that looks up the class in_types, creates the drink, and returns it. - if
kindis not in the dict, raiseValueError(f"Unknown drink: {kind}").
- a class variable
- Run the input code below.
Input
orders = [
(DrinkType.COFFEE, "large"),
(DrinkType.TEA, "small"),
(DrinkType.JUICE, "medium"),
(DrinkType.COFFEE, "small"),
]
for kind, size in orders:
drink = DrinkFactory.create(kind, size)
drink.prepare()
Expected Output
Brewing large coffee ☕
Steeping small tea 🍵
Squeezing medium juice 🧃
Brewing small coffee ☕
Problem 4 (Medium+): Self-Registering Exporter Factory
In the previous problem, the factory had a hard-coded _types dict — every time someone added a new drink, they had to open the factory and edit it (OCP violation).
Now we will fix that. Build a Factory where each exporter class registers itself using a decorator. Adding a new exporter should require zero changes to the factory class.
The app exports a list of names into different file formats: CSV, JSON, and XML.
Step 1 — Write the exporters first (the natural way).
- Create an abstract base class
Exporterwith an abstract methodexport(data)(wheredatais a list of strings). - Create three concrete exporter classes — no factory, no decorators yet:
CSVExporter— prints all names joined by,JSONExporter— prints the list as JSON (usejson.dumps)XMLExporter— prints<list>, then `- NAME
for each name, then</list>`
Step 2 — Add the factory.
You could now write a _types = {"csv": CSVExporter, ...} dict by hand like in Problem 3, but every new exporter would force you to edit the factory. Instead, let each exporter class register itself.
- Create a class
ExporterFactorywith:- a class variable
_types = {}(empty dict — it will fill up automatically). - a
@classmethod register(cls, kind)that returns a decorator. The decorator takes a class (the exporter class being decorated, e.g.CSVExporter) as its only argument, saves it in_types[kind], and returns the same class unchanged. - a
@classmethod create(cls, kind)that creates and returns an exporter instance for the givenkind. Ifkindis unknown, raiseValueError(f"Unknown exporter: {kind}").
- a class variable
Step 3 — Move the exporters below the factory and decorate them.
- Move your three exporter classes so they appear after
ExporterFactory, and add a decorator to each one:@ExporterFactory.register("csv")aboveCSVExporter@ExporterFactory.register("json")aboveJSONExporter@ExporterFactory.register("xml")aboveXMLExporter
Notice:
ExporterFactoryitself stayed completely untouched. To add a new format tomorrow, you just write a new class with@ExporterFactory.register("yaml")— the factory never changes. That is the Open/Closed Principle in action. - Run the input code below.
Input
data = ["Alisher", "Sevara", "Aziz"]
for fmt in ["csv", "json", "xml"]:
print(f"--- {fmt.upper()} ---")
exporter = ExporterFactory.create(fmt)
exporter.export(data)
Expected Output
--- CSV ---
Alisher,Sevara,Aziz
--- JSON ---
["Alisher", "Sevara", "Aziz"]
--- XML ---
<list>
<item>Alisher</item>
<item>Sevara</item>
<item>Aziz</item>
</list>
Problem 5 (Advanced): Stock Market Traders
Build a small stock market simulator that combines two patterns:
- Observer — a stock notifies all subscribed traders whenever its price changes.
- Strategy — each trader uses a different trading strategy to decide what to do (BUY, SELL, or HOLD) based on the new price.
A Stock is like a Telegram channel — when its price changes, every subscriber instantly hears about it. Some subscribers are traders that must decide BUY / SELL / HOLD; others just watch and record. A trader never hardcodes its own rule — the rule is a swappable object the trader consults. Figure out yourself where Observer ends and Strategy begins.
- Create an abstract base class
TradingStrategywith abstract methoddecide(price)returning a string:"BUY","SELL", or"HOLD". - Create three concrete strategies —
BuyLowStrategy(threshold),SellHighStrategy(threshold),AlwaysHoldStrategy()— with the constructor signatures shown in the driver code. Their decision rules are not given to you; infer each one from the expected output. - Create an abstract base class
Observerwith abstract methodupdate(price). - Create a class
Trader(Observer):__init__takesnameand aTradingStrategyobject.update(price)— figure out what it must do and what it must print from the expected output.
- Create a class
PriceLogger(Observer)— a different kind of observer that does not trade, it just records prices.__init__takes no arguments.update(price)— figure out what it must do and what it must print from the expected output. Hint: the(total: N)part means it has to remember something between calls.
- Create a class
Stock(the subject):__init__takes asymbol(like"AAPL"), keeps an empty list of observers.subscribe(observer)adds an observer.unsubscribe(observer)removes an observer.set_price(price)prints"📊 <symbol>: $<price>", then notifies every observer by calling itsupdate(price).
- Run the input code below.
Input
apple = Stock("AAPL")
apple.subscribe(Trader("Alisher", BuyLowStrategy(100)))
apple.subscribe(Trader("Sevara", SellHighStrategy(150)))
apple.subscribe(Trader("Aziz", AlwaysHoldStrategy()))
apple.subscribe(PriceLogger())
for price in [120, 90, 160, 140]:
apple.set_price(price)
Expected Output
📊 AAPL: $120
Alisher: HOLD at $120
Sevara: HOLD at $120
Aziz: HOLD at $120
📒 Logger: recorded $120 (total: 1)
📊 AAPL: $90
Alisher: BUY at $90
Sevara: HOLD at $90
Aziz: HOLD at $90
📒 Logger: recorded $90 (total: 2)
📊 AAPL: $160
Alisher: HOLD at $160
Sevara: SELL at $160
Aziz: HOLD at $160
📒 Logger: recorded $160 (total: 3)
📊 AAPL: $140
Alisher: HOLD at $140
Sevara: HOLD at $140
Aziz: HOLD at $140
📒 Logger: recorded $140 (total: 4)
Problem 6 (Advanced+): Smart Home Automation (Mini-Project)
Build a small smart home system that combines all four patterns, split across multiple files (one responsibility per file, like SRP from SOLID):
- Singleton —
EventLog: one shared log of every event in the house. - Factory —
DeviceFactory: creates devices by name; new device types register themselves with a decorator (no edits to the factory). - Observer —
TempSensoris the subject; devices attach to it and react to temperature changes. - Strategy — each device has a strategy object that evaluates what action (
"ON","OFF","ALERT", or nothing) to take given the current temperature.
A TempSensor reports the room temperature every step. Every device subscribed to the sensor receives the new temperature and asks its strategy what to do. To avoid repeated log entries, each device remembers the action it took last time. It only writes a new event to the EventLog when the new action is different from the previous one. For example, if a heater is already "ON" and the strategy says "ON" again, nothing is logged; but if the strategy now says "OFF", that change is logged.
File structure
smart_home/
├── event_log.py # Singleton EventLog
├── strategies.py # DeviceStrategy ABC + Heater/AC/Alarm strategies
├── device.py # Listener ABC + Device class
├── sensor.py # TempSensor (subject)
├── factory.py # DeviceFactory + @register builders
└── main.py # demo loop
event_log.py— implement a singletonEventLogshared by the whole system:- Any part of the program should be able to create or access an
EventLog, but all of them must refer to the same shared log. - The log should store all recorded
events. - Provide a way to add a new event message and print it in the format
"📝 <msg>". - Use this shared log from any module that needs to record sensor or device activity.
- Any part of the program should be able to create or access an
strategies.py— abstract base classDeviceStrategywith abstract methodevaluate(temp)returning"ON","OFF","ALERT", orNone. Then three strategies:HeaterStrategy(threshold)→"ON"iftemp < threshold(room is too cold), else"OFF".ACStrategy(threshold)→"ON"iftemp > threshold(room is too hot), else"OFF".AlarmStrategy()→"ALERT"iftemp > 40ortemp < 0, else"OFF".
device.py— abstract base classListenerwith abstract methodon_reading(temp). Then classDevice(Listener):__init__(name, strategy)— saves both, pluslast_action = "OFF".on_reading(temp): ask the strategy to evaluate; if the result isNoneor equal tolast_action, do nothing. Otherwise updatelast_actionand calllog.add(f"{name} → {action}")(using thelog = EventLog()defined at the top of this file).
sensor.py— classTempSensor:- holds a list of listeners; method
attach(listener). - method
report(temp)callslog.add(f"🌡️ Sensor reports {temp}°C")(using thelog = EventLog()defined at the top of this file), then notifies every listener.
- holds a list of listeners; method
factory.py— classDeviceFactory:- class variable
_builders = {}. @classmethod register(cls, kind)returns a decorator. The decorator takes a single argument — a builder function — saves it in_builders[kind], and returns it unchanged.@classmethod create(cls, kind, name)looks up the function registered underkindand calls it withname, returning the resultingDevice. RaiseValueError(f"Unknown device: {kind}")ifkindwas never registered.- Right after the class, register three builder functions:
"heater"→Device(name, HeaterStrategy(threshold=18))"ac"→Device(name, ACStrategy(threshold=26))"alarm"→Device(name, AlarmStrategy())
- class variable
main.py— insideif __name__ == "__main__"::- Create the sensor and three devices via
DeviceFactory.create(...):"Living Room Heater","Bedroom AC","Fire Alarm". - Attach all three to the sensor.
- Loop over
readings = [22, 16, 14, 28, 30, 45, 25]and callsensor.report(temp)for each value. - At the end print
f"Total events logged: {len(log.events)}"(using thelog = EventLog()defined at the top ofmain.py).
- Create the sensor and three devices via
Expected Output
📝 🌡️ Sensor reports 22°C
📝 🌡️ Sensor reports 16°C
📝 Living Room Heater → ON
📝 🌡️ Sensor reports 14°C
📝 🌡️ Sensor reports 28°C
📝 Living Room Heater → OFF
📝 Bedroom AC → ON
📝 🌡️ Sensor reports 30°C
📝 🌡️ Sensor reports 45°C
📝 Fire Alarm → ALERT
📝 🌡️ Sensor reports 25°C
📝 Bedroom AC → OFF
📝 Fire Alarm → OFF
Total events logged: 13