Guided Practice: From a Verbal Requirement to a Class Structure
- Go through a full design process — from a requirement text to working code
- Document design decisions — why X is a Class, why Y belongs to Z
- Draw relationships between Classes — has-a, is-a and quantity (one-to-many)
- Implement according to the design, and discover and fix holes in the design while testing
What will we learn?
This is a workshop lesson. We will take one verbal requirement, and go together through the whole process:
- Reading and marking.
- Deciding on Classes.
- Responsibility and data for every Class.
- Relationships between the Classes.
- A code skeleton.
- Implementation.
- Testing, and fixing the design.
Why do we need this?
In the OOP project you will build later, before writing the code you need a Design stage. This lesson is the general rehearsal: exactly the same process, on a different problem, with the instructor leading.
Explanation from the basics
The requirement
A system for the gym “KodFit”
The gym has members. Every member has a name, a member number and a membership type: Basic (up to 8 visits a month) or Premium (unlimited). Premium costs 250 NIS a month and Basic 150.
The gym has classes (yoga, spinning…). Every class has a name, a coach, a day and time, and a limited number of spots.
A member can register for a class if there is room, and if they have not used up their monthly visit quota. A registration counts as a visit. A member can cancel a registration, and the visit comes back to them.
The system needs to display: all the classes with the free spots, the classes of a certain member, and the monthly revenue from all the members.
Syntax
There is no new syntax. We will use everything we learned: Classes, __init__, properties, Encapsulation, Inheritance + Polymorphism, Composition and Exceptions of our own.
First code example
Step 1 — reading and marking
| Nouns | Verbs | Rules and numbers |
|---|---|---|
| Gym, member, name, member number, membership type, Basic, Premium, class, coach, day and time, spots, registration, visit, revenue | Register, cancel, display | Basic: up to 8 visits, 150 NIS. Premium: unlimited, 250 NIS. Limited spots. Registration = a visit. Cancelling returns a visit |
Step 2 — what are the Classes?
| Candidate | Decision | Reasoning |
|---|---|---|
| Member | ✓ Class Member | Identity, data, rules (a quota). |
| Membership type | ✓ A hierarchy: Plan → BasicPlan, PremiumPlan | Every type has different behavior (price, limit), and that is exactly Polymorphism. If “Student” is added in the future, you add a Class. |
| Class | ✓ Class GymClass | Identity, data, a rule (spots). (Class is a problematic name, so GymClass.) |
| Registration | ✗ Not a separate Class (for now) | It has no data of its own beyond “who and where”. It is enough for the class to hold its members. |
| Gym | ✓ Class Gym | Holds all the members and the classes, and coordinates between them. |
| Coach, day and time, name | Attributes | Values, without behavior. |
| Revenue | Method | Calculated from the sum of the prices. |
Step 3 — responsibility and data
| Class | Responsibility (one sentence) | Data | Interface |
|---|---|---|---|
Plan | Defines the price and the visit limit of a membership type | — | monthly_price(), visit_limit() |
Member | Manages the monthly quota of one member | Name, number, plan, visits used | can_visit(), use_visit(), return_visit() |
GymClass | Manages the list of people registered for one class | Name, coach, time, capacity, registered members | has_room(), add(), remove(), is_registered() |
Gym | Coordinates registrations and provides reports | Members, classes | add_member(), add_class(), register(), cancel(), schedule(), classes_of(), monthly_revenue() |
An important decision: where is “is it allowed to register” checked? There are two rules: room in the class (belongs to GymClass) and the quota (belongs to Member). Gym.register coordinates: it asks both of them, and only if both agree, it makes the changes in both.
Step 4 — relationships
Gym
/ \
has many has many
/ \
Member ◄──────── GymClass
│ (class holds its registered members)
has-a
│
Plan ◄── is-a ── BasicPlan
◄── is-a ── PremiumPlanGymhas manyMemberandGymClass→ Composition (internal dicts).Memberhas aPlan→ Composition. (A member is not a kind of plan!)BasicPlanis aPlan→ Inheritance + Polymorphism.GymClassholds references toMember, not copies.
Step 5 — a skeleton
class Plan:
def monthly_price(self): ...
def visit_limit(self): ...
class BasicPlan(Plan): ...
class PremiumPlan(Plan): ...
class Member:
def __init__(self, member_id, name, plan): ...
def can_visit(self): ...
def use_visit(self): ...
def return_visit(self): ...
class GymClass:
def __init__(self, class_id, name, coach, time, capacity): ...
def has_room(self): ...
def add(self, member): ...
def remove(self, member): ...
class Gym:
def register(self, member_id, class_id): ...
def cancel(self, member_id, class_id): ...
def schedule(self): ...
def monthly_revenue(self): ...Breaking down the code line by line
A moment before implementing — checking the design against the requirement, line by line:
| Requirement | Who handles it |
|---|---|
| “Basic up to 8 visits, Premium unlimited” | BasicPlan.visit_limit() → 8. PremiumPlan.visit_limit() → None (unlimited). |
| “A price by type” | A polymorphic monthly_price(). |
| “Register if there is room and if the quota was not used up” | Gym.register → GymClass.has_room() + Member.can_visit(). |
| “Registration = a visit” | register calls member.use_visit(). |
| “Cancelling returns a visit” | cancel → class.remove() + member.return_visit(). |
| “Display the classes with free spots” | Gym.schedule(). |
| “A member’s classes” | Gym.classes_of(member_id). |
| “Monthly revenue” | Gym.monthly_revenue() → the sum of member.plan.monthly_price(). |
Every line in the requirement “lands” in a Class and a method. No line without a home, and no Class without a reason.
Another example
Step 6 — implementation. week-3/gym.py:
class GymError(Exception):
pass
class Plan:
def monthly_price(self):
raise NotImplementedError
def visit_limit(self):
raise NotImplementedError
def name(self):
return type(self).__name__.replace("Plan", "")
class BasicPlan(Plan):
def monthly_price(self):
return 150
def visit_limit(self):
return 8
class PremiumPlan(Plan):
def monthly_price(self):
return 250
def visit_limit(self):
return None
class Member:
def __init__(self, member_id, name, plan):
self.member_id = member_id
self.name = name
self.plan = plan
self._visits_used = 0
@property
def visits_used(self):
return self._visits_used
def can_visit(self):
limit = self.plan.visit_limit()
return limit is None or self._visits_used < limit
def use_visit(self):
if not self.can_visit():
raise GymError(self.name + " reached the monthly limit of " + str(self.plan.visit_limit()))
self._visits_used = self._visits_used + 1
def return_visit(self):
if self._visits_used > 0:
self._visits_used = self._visits_used - 1
class GymClass:
def __init__(self, class_id, name, coach, time, capacity):
if capacity <= 0:
raise ValueError("capacity must be positive")
self.class_id = class_id
self.name = name
self.coach = coach
self.time = time
self.capacity = capacity
self._members = []
@property
def free_spots(self):
return self.capacity - len(self._members)
def has_room(self):
return self.free_spots > 0
def is_registered(self, member):
return member in self._members
def add(self, member):
if self.is_registered(member):
raise GymError(member.name + " is already in " + self.name)
if not self.has_room():
raise GymError(self.name + " is full")
self._members.append(member)
def remove(self, member):
if not self.is_registered(member):
raise GymError(member.name + " is not registered to " + self.name)
self._members.remove(member)
class Gym:
def __init__(self, name):
self.name = name
self._members = {}
self._classes = {}
def add_member(self, member):
self._members[member.member_id] = member
def add_class(self, gym_class):
self._classes[gym_class.class_id] = gym_class
def _member(self, member_id):
if member_id not in self._members:
raise GymError("no member " + str(member_id))
return self._members[member_id]
def _class(self, class_id):
if class_id not in self._classes:
raise GymError("no class " + str(class_id))
return self._classes[class_id]
def register(self, member_id, class_id):
member = self._member(member_id)
gym_class = self._class(class_id)
if not member.can_visit():
raise GymError(member.name + " reached the monthly limit")
gym_class.add(member)
member.use_visit()
def cancel(self, member_id, class_id):
member = self._member(member_id)
self._class(class_id).remove(member)
member.return_visit()
def schedule(self):
lines = []
for gym_class in self._classes.values():
lines.append(gym_class.time + " " + gym_class.name + " with " + gym_class.coach
+ " - " + str(gym_class.free_spots) + " free")
return lines
def classes_of(self, member_id):
member = self._member(member_id)
return [c.name for c in self._classes.values() if c.is_registered(member)]
def monthly_revenue(self):
return sum(member.plan.monthly_price() for member in self._members.values())Step 7 — testing. At the end of the file:
def main():
gym = Gym("KodFit")
gym.add_member(Member(1, "Dana", PremiumPlan()))
gym.add_member(Member(2, "Avi", BasicPlan()))
gym.add_class(GymClass("Y1", "Yoga", "Noa", "Sun 08:00", 2))
gym.add_class(GymClass("S1", "Spinning", "Tom", "Mon 19:00", 10))
gym.register(1, "Y1")
gym.register(2, "Y1")
gym.register(1, "S1")
attempts = [(1, "Y1"), (2, "Y9"), (9, "S1")]
for member_id, class_id in attempts:
try:
gym.register(member_id, class_id)
except GymError as error:
print("Rejected:", error)
gym.cancel(2, "Y1")
for line in gym.schedule():
print(line)
print("Dana's classes:", gym.classes_of(1))
print("Monthly revenue:", gym.monthly_revenue())
if __name__ == "__main__":
main()Output:
Rejected: Dana is already in Yoga
Rejected: no class Y9
Rejected: no member 9
Sun 08:00 Yoga with Noa - 1 free
Mon 19:00 Spinning with Tom - 9 free
Dana's classes: ['Yoga', 'Spinning']
Monthly revenue: 400What did the test reveal? — fixing the design
Let’s check the quota rule with a Basic member:
gym2 = Gym("Test")
avi = Member(2, "Avi", BasicPlan())
gym2.add_member(avi)
for i in range(9):
gym2.add_class(GymClass("C" + str(i), "Class " + str(i), "Coach", "Tue", 5))
for i in range(9):
try:
gym2.register(2, "C" + str(i))
except GymError as error:
print("Rejected:", error)
print("Avi used", avi.visits_used, "visits")Rejected: Avi reached the monthly limit
Avi used 8 visitsThe rule works. But notice the hole: what if gym_class.add() succeeded, and member.use_visit() failed? Then the member is registered for the class, but no visit was used. In our implementation this does not happen, because we checked can_visit() before add. But it shows an important rule: when a method changes two objects, check all the conditions before changing anything. Exactly as transfer_to in the bank calls withdraw (which may fail) before deposit.
Common mistake
Mistake 1: skipping steps 1–4 “because it’s obvious to me”. After 200 lines you discover that “registration” should have been somewhere else, and that Member and GymClass “know” each other in a way that causes double updates. 15 minutes of design save hours.
Mistake 2: inheritance instead of Composition — class PremiumMember(Member) looks natural, but what happens when a member upgrades from Basic to Premium? You cannot “change the Class” of an existing object. With Composition (member.plan = PremiumPlan()) it is one line. What may change during the object’s life → Composition.
Mistake 3: a rule checked in the wrong place. If GymClass.add also checked the member’s quota, the class would have to “know” the rules of the plans. Every Class checks its own rules, and Gym coordinates.
Mistake 4: not testing error cases. The test included IDs that do not exist, a double registration and exceeding the quota. Without them, we would only know that the happy path works.
Practice
The exercises use only what was taught in this lesson and the lessons before it. Try on your own first, and only then open the solution.
What you practice on this page: the whole way from a verbal requirement to code — in the example of a scooter rental service. Every exercise is one step of the process: first design, then Class after Class.
By the end of the page you will know how to:
- Break a requirement into Classes, rules and relationships before writing code
- Build a small hierarchy: a Parent and a Child that changes only a rate
- Put every rule in the Class that holds its data
- Connect several Classes into one system that works
Exercise 1: From a requirement to a list of classes
What you do: the design stage — without code.
The requirement:
“A scooter rental system: a user rents a scooter for a ride. Every scooter has a number and a battery in percent. You must not rent a scooter with less than 20% battery, or a scooter that is already in use. A ride costs 5 NIS to unlock plus 1 NIS per minute. A fast scooter costs 2 NIS per minute.”
The task:
- Which Classes are there here? For each — what does it know (Attributes)?
- Which rules are there, and in which Class should each rule sit?
- What are the relationships between them: who holds whom (has-a), and who is a kind of whom (is-a)?
What to submit: a list of Classes with Attributes, a list of rules, and a list of relationships.
Show solution
- Classes:
User,Scooter,FastScooter,Ride, andRentalServicethat manages all of them. - Rules:
- No rental below 20% battery.
- No rental of a scooter that is in use.
- The price of a ride: 5 + minutes × the rate per minute (1 regular, 2 fast).
- Relationships:
FastScooteris a kind ofScooter(is-a) — only the rate is different.Rideholds a user and a scooter (has-a).RentalServiceholds many scooters and users.
Exercise 2: Scooter and FastScooter
What you build: the scooter — and a ride price that depends on its type.
What to write:
- A Class named
Scooter, with a number, a battery percentage, andin_usethat starts asFalse. - In
Scooter: a Class Attribute namedPER_MINUTEwith the value 1, andpricethat receives minutes and returns 5 plus the rate per minute times the minutes. - A Class named
FastScooterthat inherits fromScooter, and changes onlyPER_MINUTEto 2.
Test code:
print(Scooter(1, 80).price(10))
print(FastScooter(2, 90).price(10))The desired output:
15
25Show solution
UNLOCK_FEE = 5
class Scooter:
PER_MINUTE = 1
def __init__(self, number, battery):
self.number = number
self.battery = battery
self.in_use = False
def price(self, minutes):
return UNLOCK_FEE + minutes * self.PER_MINUTE
class FastScooter(Scooter):
PER_MINUTE = 2
print(Scooter(1, 80).price(10))
print(FastScooter(2, 90).price(10))15
25self.PER_MINUTE reads the value from the object’s Class — so FastScooter uses 2 without overriding price at all.
Exercise 3: The rules in one place
What you build: continuing Scooter — the two rental rules, in one place.
What to write:
- In
Scooter, addunlock. - If the battery is below 20%: a
ValueErrorwith the first message from the output. - If the scooter is already in use: a
ValueErrorwith the second message from the output. - Otherwise: mark the scooter as in use.
Test code:
s = Scooter(1, 15)
try:
s.unlock()
except ValueError as error:
print("error:", error)
s.battery = 60
s.unlock()
print(s.in_use)
try:
s.unlock()
except ValueError as error:
print("error:", error)The desired output:
error: battery too low: 15%
True
error: scooter 1 is in useShow solution
UNLOCK_FEE = 5
MIN_BATTERY = 20
class Scooter:
PER_MINUTE = 1
def __init__(self, number, battery):
self.number = number
self.battery = battery
self.in_use = False
def price(self, minutes):
return UNLOCK_FEE + minutes * self.PER_MINUTE
def unlock(self):
if self.battery < MIN_BATTERY:
raise ValueError("battery too low: " + str(self.battery) + "%")
if self.in_use:
raise ValueError("scooter " + str(self.number) + " is in use")
self.in_use = True
s = Scooter(1, 15)
try:
s.unlock()
except ValueError as error:
print("error:", error)
s.battery = 60
s.unlock()
print(s.in_use)
try:
s.unlock()
except ValueError as error:
print("error:", error)error: battery too low: 15%
True
error: scooter 1 is in useExercise 4: A ride
What you build: a ride — which connects a user to a scooter.
What to write:
- A Class named
Ride, which receives a user’s name and a scooter and holds them. - In the
__init__ofRide: call the scooter’sunlock— the ride opens only if it is allowed. finish— receives minutes, releases the scooter and returns the price of the ride.
Test code:
scooter = FastScooter(7, 90)
ride = Ride("Dana", scooter)
print(scooter.in_use)
print(ride.finish(12))
print(scooter.in_use)The desired output:
True
29
FalseShow solution
UNLOCK_FEE = 5
MIN_BATTERY = 20
class Scooter:
PER_MINUTE = 1
def __init__(self, number, battery):
self.number = number
self.battery = battery
self.in_use = False
def price(self, minutes):
return UNLOCK_FEE + minutes * self.PER_MINUTE
def unlock(self):
if self.battery < MIN_BATTERY:
raise ValueError("battery too low: " + str(self.battery) + "%")
if self.in_use:
raise ValueError("scooter " + str(self.number) + " is in use")
self.in_use = True
class FastScooter(Scooter):
PER_MINUTE = 2
class Ride:
def __init__(self, user, scooter):
scooter.unlock()
self.user = user
self.scooter = scooter
def finish(self, minutes):
self.scooter.in_use = False
return self.scooter.price(minutes)
scooter = FastScooter(7, 90)
ride = Ride("Dana", scooter)
print(scooter.in_use)
print(ride.finish(12))
print(scooter.in_use)True
29
FalseRide does not check the rules by itself — it calls unlock, and the rules stay inside Scooter.
Exercise 5: The rental service
What you build: the whole service — it holds all the scooters and knows which ones are free.
What to write:
- A Class named
RentalService, which holds a dictionary of scooters by number. add— adds a scooter to the dictionary.get— receives a number and returns the scooter.available— returns a list of the numbers of the scooters that can be rented right now: a battery of at least 20%, and not in use.
Test code:
service = RentalService()
service.add(Scooter(1, 80))
service.add(Scooter(2, 10))
service.add(FastScooter(3, 70))
Ride("Dana", service.get(3))
print(service.available())The desired output:
[1]Show solution
UNLOCK_FEE = 5
MIN_BATTERY = 20
class Scooter:
PER_MINUTE = 1
def __init__(self, number, battery):
self.number = number
self.battery = battery
self.in_use = False
def can_rent(self):
return self.battery >= MIN_BATTERY and not self.in_use
def unlock(self):
if self.battery < MIN_BATTERY:
raise ValueError("battery too low: " + str(self.battery) + "%")
if self.in_use:
raise ValueError("scooter " + str(self.number) + " is in use")
self.in_use = True
class FastScooter(Scooter):
PER_MINUTE = 2
class Ride:
def __init__(self, user, scooter):
scooter.unlock()
self.user = user
self.scooter = scooter
class RentalService:
def __init__(self):
self._scooters = {}
def add(self, scooter):
self._scooters[scooter.number] = scooter
def get(self, number):
return self._scooters[number]
def available(self):
result = []
for number, scooter in self._scooters.items():
if scooter.can_rent():
result.append(number)
return result
service = RentalService()
service.add(Scooter(1, 80))
service.add(Scooter(2, 10))
service.add(FastScooter(3, 70))
Ride("Dana", service.get(3))
print(service.available())[1]Scooter 2 — a low battery. Scooter 3 — in use. We added can_rent() to Scooter, so that the service asks the scooter and does not check its data by itself.
Summary
- The design process: marking → Classes → responsibility and data → relationships → skeleton → implementation → testing.
- Every line in the requirement should “land” in a Class and a method. Every Class needs a reason.
- Different behavior by type → a hierarchy + Polymorphism (Plan). Something that may change during the object’s life → Composition (member.plan).
- Every Class checks its own rules, and a coordinating Class (Gym) connects them.
- When an action changes several objects: check all the conditions before changing anything.
- A new requirement = an update to the design. And a good design lets you add without breaking.