Guided Practice: From a Verbal Requirement to a Class Structure

Lesson goals
  • 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:

  1. Reading and marking.
  2. Deciding on Classes.
  3. Responsibility and data for every Class.
  4. Relationships between the Classes.
  5. A code skeleton.
  6. Implementation.
  7. 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

NounsVerbsRules and numbers
Gym, member, name, member number, membership type, Basic, Premium, class, coach, day and time, spots, registration, visit, revenueRegister, cancel, displayBasic: 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?

CandidateDecisionReasoning
Member✓ Class MemberIdentity, data, rules (a quota).
Membership type✓ A hierarchy: Plan → BasicPlan, PremiumPlanEvery type has different behavior (price, limit), and that is exactly Polymorphism. If “Student” is added in the future, you add a Class.
Class✓ Class GymClassIdentity, 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 GymHolds all the members and the classes, and coordinates between them.
Coach, day and time, nameAttributesValues, without behavior.
RevenueMethodCalculated from the sum of the prices.

Step 3 — responsibility and data

ClassResponsibility (one sentence)DataInterface
PlanDefines the price and the visit limit of a membership type—monthly_price(), visit_limit()
MemberManages the monthly quota of one memberName, number, plan, visits usedcan_visit(), use_visit(), return_visit()
GymClassManages the list of people registered for one className, coach, time, capacity, registered membershas_room(), add(), remove(), is_registered()
GymCoordinates registrations and provides reportsMembers, classesadd_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 ── PremiumPlan
  • Gym has many Member and GymClass → Composition (internal dicts).
  • Member has a Plan → Composition. (A member is not a kind of plan!)
  • BasicPlan is a Plan → Inheritance + Polymorphism.
  • GymClass holds references to Member, 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:

RequirementWho 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: 400

What 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 visits

The 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:

  1. Which Classes are there here? For each — what does it know (Attributes)?
  2. Which rules are there, and in which Class should each rule sit?
  3. 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
  1. Classes: User, Scooter, FastScooter, Ride, and RentalService that manages all of them.
  2. 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).
  3. Relationships:
    • FastScooter is a kind of Scooter (is-a) — only the rate is different.
    • Ride holds a user and a scooter (has-a).
    • RentalService holds 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:

  1. A Class named Scooter, with a number, a battery percentage, and in_use that starts as False.
  2. In Scooter: a Class Attribute named PER_MINUTE with the value 1, and price that receives minutes and returns 5 plus the rate per minute times the minutes.
  3. A Class named FastScooter that inherits from Scooter, and changes only PER_MINUTE to 2.

Test code:

print(Scooter(1, 80).price(10))
print(FastScooter(2, 90).price(10))

The desired output:

15
25
Show 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
25

self.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:

  1. In Scooter, add unlock.
  2. If the battery is below 20%: a ValueError with the first message from the output.
  3. If the scooter is already in use: a ValueError with the second message from the output.
  4. 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 use
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 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 use

Exercise 4: A ride

What you build: a ride — which connects a user to a scooter.

What to write:

  1. A Class named Ride, which receives a user’s name and a scooter and holds them.
  2. In the __init__ of Ride: call the scooter’s unlock — the ride opens only if it is allowed.
  3. 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
False
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 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
False

Ride 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:

  1. A Class named RentalService, which holds a dictionary of scooters by number.
  2. add — adds a scooter to the dictionary.
  3. get — receives a number and returns the scooter.
  4. 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.

Official documentation