1

Your First Class Diagram

Welcome to UML Class Diagrams

Learning objective: After this step you will be able to identify the three compartments of a UML class box and write a Python class that matches a given diagram.

💡 Light mode recommended. The UML diagrams in this tutorial are easier to read on a light background. If you are in dark mode, consider switching with the Dark mode toggle in the tutorial navbar.

Heads up — learning UML feels weird at first. You are about to map two things that look very different: boxes with symbols on one side, Python code on the other. The first few connections take effort to see. If a notation feels arbitrary, that’s normal — keep going. By Step 4 you’ll be reading diagrams as fluently as you read code.

What Is a UML Class Diagram?

A UML class diagram is a visual blueprint of your software’s structure. It shows what classes exist, what data they hold, what behavior they provide, and how they relate to each other. Think of it as a floor plan — you can understand the building without inspecting every brick.

The Three Compartments

Every class in UML is drawn as a box with three sections:

Compartment Contains Python Equivalent
Top Class name class ClassName:
Middle Attributes (data) Instance variables in __init__
Bottom Methods (behavior) Method definitions

Your Target Diagram

Write Python code until the live diagram below matches this target:

Reading the Diagram

  • Top: The class name is Studentclass Student:
  • Middle: Two attributes name and student_id → instance variables set in __init__
  • Bottom: One method get_info() → a method definition

That is all there is to it — the diagram is a visual summary of the class.

Note: You may see symbols like +, -, and types like : str in other UML diagrams. We will cover those in the next steps. For now, focus on the three compartments.

Your Task

Open student.py and create a Student class that:

  1. Defines a constructor __init__(self, name, student_id)
  2. Stores both parameters as instance attributes (self.name = name)
  3. Has a get_info() method returning "name (student_id)" — for example "Alice (S001)"

Watch the UML Diagram panel — it updates live as you type!

Starter files
student.py
# Your task: create a Student class that matches the target diagram.
#
# The class needs:
#   - An __init__ that accepts name and student_id
#   - Both stored as instance attributes
#   - A get_info() method returning "name (student_id)"
2

Visibility: Who Can See What?

Visibility Markers

Learning objective: After this step you will be able to map UML visibility symbols to Python naming conventions and explain why encapsulation is a design decision.

The Four UML Visibility Levels

UML uses symbols to show who can access each attribute or method (source: UML@Classroom, Seidl et al., Table 4.1):

UML Symbol Meaning Access Scope
+ Public Any object in the system
- Private Only the implementing class itself
# Protected The class and its subclasses
~ Package Classes in the same package

Python Is Different — and That’s Part of the Lesson

Unlike Java or C++, Python has no private or protected keywords. Access control in Python is entirely convention-based. This tutorial uses the following Python-to-UML mapping that the live diagram renderer recognises:

UML Python (as read by this renderer)
+ Public self.name (no prefix)
# Protected self._name (single leading underscore)
- Private self.__name (double leading underscore)

What _ and __ Really Mean in Python

Single underscore _ — the “internal use” signal (PEP 8)

self._internal_cache = []   # "Implementation detail — don't rely on this"

A leading _ is a social contract. Python does nothing to enforce it; tools like from module import * skip these names, and the broader community treats them as non-public. Most Pythonistas use _ to mean “non-public” whether the intent is protected or private.

Double underscore __ — name mangling, NOT privacy

self.__balance = 100

Python rewrites __balance to _BankAccount__balance. Per the official Python tutorial:

“Name mangling is intended to give classes an easy way to define ‘private’ instance variables… without having to worry about instance variables defined by derived classes.”

The primary purpose of __ is avoiding name clashes in deep inheritance hierarchies (PEP 8), not privacy. It happens to make accidental external access harder, which is why many tools (and this renderer) treat it as the closest Python analog of UML -. But don’t reach for __ just to “make something private” — idiomatic Python rarely uses it.

account = BankAccount(100)
account.__balance              # AttributeError (mangled)
account._BankAccount__balance  # Works — a determined caller can always get in

Key takeaway: UML visibility expresses design intent; Python conventions express that intent through naming, not enforcement. In this tutorial we use __ for private so the UML renderer displays -, but in real Python code many teams standardise on _ for anything non-public.

Why This Matters

Python does not enforce visibility — but UML forces you to decide what should be accessible. When you model a class in UML, you make a deliberate architectural choice about which parts are the public interface and which are internal implementation details that could change without warning.

Your Target Diagram

Your Task

The starter code has a BankAccount where everything is public. Refactor it:

  1. Make balance private → rename to __balance (matches - in UML)
  2. Make validate_amount protected → rename to _validate_amount (matches #)
  3. Keep deposit, withdraw, and get_balance public (they stay as-is)
  4. Update all internal references to use the new names

Watch the UML diagram update — the visibility markers should change from + to - and #.

Starter files
bank_account.py
class BankAccount:
    """A bank account — but everything is public!
    Your job: apply proper visibility using Python naming conventions."""

    def __init__(self, initial_balance: float) -> None:
        self.balance: float = initial_balance  # Should be private (-)

    def deposit(self, amount: float) -> None:
        if self.validate_amount(amount):       # Update reference
            self.balance += amount              # Update reference

    def withdraw(self, amount: float) -> bool:
        if self.validate_amount(amount) and self.balance >= amount:
            self.balance -= amount              # Update reference
            return True
        return False

    def get_balance(self) -> float:
        return self.balance                     # Update reference

    def validate_amount(self, amount: float) -> bool:  # Should be protected (#)
        return amount > 0


if __name__ == "__main__":
    account = BankAccount(100.0)
    account.deposit(50.0)
    print(f"Balance: ${account.get_balance():.2f}")
    account.withdraw(30.0)
    print(f"Balance: ${account.get_balance():.2f}")
3

Types Matter: Explicit Contracts

Explicit Types in UML

Learning objective: After this step you will be able to add Python type hints that make UML diagrams show complete type information and explain why explicit types improve software design.

What Are Type Hints?

You may not have seen Python type hints before. They are optional annotations that tell both humans and tools what type a variable or return value should be:

# Without type hints (what you are used to):
def __init__(self, name, price):
    self.name = name

# With type hints:
def __init__(self, name: str, price: float) -> None:
    self.name: str = name
Syntax Meaning Example
param: Type Parameter has this type name: str
self.x: Type = value Attribute has this type self.name: str = name
-> Type Method returns this type def get_price(self) -> float:
-> None Method returns nothing def __init__(self, ...) -> None:

Type hints do not change how Python runs your code — Python ignores them at runtime. But they serve two critical purposes:

  1. UML diagrams — the live diagram renderer reads type hints to show types. Without them, the diagram only shows names.
  2. Communication — type hints document the contracts of your class for other developers.

(Type hints can also be enforced at build time with tools like mypy. That’s a topic for another tutorial — see the reference at the end of this one for a pointer.)

The Problem with Duck Typing

Python is dynamically typed — you can write def get_price(self) without ever specifying that it returns a float. This flexibility is convenient, but it hides the contracts between components. Another developer reading your code has to trace through the logic to figure out what types flow where.

UML does not allow this ambiguity. Every attribute must show its type, and every method must show its parameter types and return type.

UML Type Notation

UML Python
- name: str self.__name: str = name
+ get_price(): float def get_price(self) -> float:
+ apply_discount(percent: float): float def apply_discount(self, percent: float) -> float:

Your Target Diagram

Your Task

The starter code works perfectly — but has zero type hints. The UML diagram shows the class without any type information. Add type hints to:

  1. All __init__ parameters
  2. All instance attributes (e.g., self.__name: str = name)
  3. All method return types (e.g., -> float)
  4. All method parameters (e.g., percent: float)

Watch the UML diagram fill in with types as you add annotations.

Starter files
product.py
class Product:
    """A product in an online store.
    Everything works — but there are no type hints!
    Add type annotations so the UML diagram shows types."""

    def __init__(self, name, price, in_stock):
        self.__name = name
        self.__price = price
        self.__in_stock = in_stock

    def get_name(self):
        return self.__name

    def get_price(self):
        return self.__price

    def is_available(self):
        return self.__in_stock

    def apply_discount(self, percent):
        discount = self.__price * (percent / 100)
        return self.__price - discount


if __name__ == "__main__":
    p = Product("Laptop", 999.99, True)
    print(f"{p.get_name()}: ${p.get_price():.2f}")
    print(f"After 10% off: ${p.apply_discount(10):.2f}")
    print(f"In stock: {p.is_available()}")
4

Inheritance: Is-A Relationships

The Generalization Arrow

Learning objective: After this step you will be able to refactor duplicated classes into an inheritance hierarchy and draw the correct generalization arrow direction.

Heads up — the arrow direction trips up almost everyone the first time. Even developers who use inheritance every day sometimes have to pause and think. Expect to re-read the “Is-a test” below once or twice. That is the skill forming, not a sign you’re confused.

Inheritance in UML

When a class extends another class (an “is-a” relationship), UML draws a solid line with a hollow triangle pointing at the parent (superclass):

Child Parent

⚠ Common mistake: Students often draw the triangle pointing away from the parent, from superclass down to subclass. The correct direction is the opposite: the child points up to the parent.

“Is-a” test: Before drawing, check the sentence “A [Child] is a [Parent]” makes sense. “A Dog is an Animal” → yes. “An Animal is a Dog” → no. The inheriting class is the subject; the triangle points at the parent.

Your Target Diagram

Notice: Circle and Rectangle only list their own attributes. They inherit color and describe() from Shape — they do not repeat them.

Your Task

The starter code has three independent classes with duplicated color and describe(). Refactor them:

  1. Make Shape the base class with color, area(), and describe()
  2. Make Circle and Rectangle inherit from Shape using class Circle(Shape):
  3. Remove the duplicated color attribute and describe() method from the subclasses
  4. Each subclass should call super().__init__(color) and override area()

Watch the inheritance arrows appear in the live diagram.

Starter files
shapes.py
import math


class Shape:
    def __init__(self, color: str) -> None:
        self.color: str = color

    def area(self) -> float:
        return 0.0

    def describe(self) -> str:
        return f"{self.color} shape with area {self.area():.2f}"


class Circle:
    """Independent class — duplicates color and describe from Shape!"""

    def __init__(self, color: str, radius: float) -> None:
        self.color: str = color          # Duplicated!
        self.radius: float = radius

    def area(self) -> float:
        return math.pi * self.radius ** 2

    def describe(self) -> str:            # Duplicated!
        return f"{self.color} shape with area {self.area():.2f}"


class Rectangle:
    """Independent class — duplicates color and describe from Shape!"""

    def __init__(self, color: str, width: float, height: float) -> None:
        self.color: str = color          # Duplicated!
        self.width: float = width
        self.height: float = height

    def area(self) -> float:
        return self.width * self.height

    def describe(self) -> str:            # Duplicated!
        return f"{self.color} shape with area {self.area():.2f}"


if __name__ == "__main__":
    c = Circle("red", 5.0)
    r = Rectangle("blue", 3.0, 4.0)
    print(c.describe())
    print(r.describe())
5

Association: Classes That Know Each Other

Association Arrows

Learning objective: After this step you will be able to identify when a UML association exists between two classes and refactor string-based references into proper object associations.

What Is an Association?

An association means one class stores a reference to another class as an instance variable. In UML, this is drawn as a solid arrow from the class that holds the reference to the class it references.

The key rule: If a class stores another object as a persistent instance variable (self.instructor: Instructor), that is an association. If it only uses another class temporarily inside a method, that is a weaker relationship (a dependency, which we will skip for now).

Your Target Diagram

Notice the association arrow from Course to Instructor — it appears because Course has an instructor: Instructor attribute.

Your Task

The starter code stores the instructor as a plain string (instructor_name: str). This hides the relationship — the UML shows no connection between the classes.

  1. Create an Instructor class with name: str, department: str, and a get_title() method returning "name (department)"
  2. Refactor Course to accept and store an Instructor object instead of a string
  3. Update get_instructor_name() to return self.instructor.name

Watch the association arrow appear in the UML diagram!

Starter files
enrollment.py
class Course:
    """A course — but the instructor is just a string!
    There is no Instructor class, so the UML shows no relationship."""

    def __init__(self, name: str, instructor_name: str) -> None:
        self.name: str = name
        self.instructor_name: str = instructor_name  # Just a string!

    def get_instructor_name(self) -> str:
        return self.instructor_name


# TODO: Create an Instructor class with name, department, and get_title()
# TODO: Refactor Course to store an Instructor object instead of a string


if __name__ == "__main__":
    # After your refactoring, this code should work:
    # instructor = Instructor("Dr. Smith", "Computer Science")
    # course = Course("CS 101", instructor)
    # print(f"{course.name} taught by {course.get_instructor_name()}")

    course = Course("CS 101", "Dr. Smith")
    print(f"{course.name} taught by {course.get_instructor_name()}")
6

Composition vs Aggregation

Ownership and Lifecycle

Learning objective: After this step you will be able to distinguish composition from aggregation and implement both patterns correctly in Python.

Heads up — this is the distinction working developers most often get wrong. If the rule feels fuzzy after this step, that is honest confusion, not a learning failure — the UML spec itself calls aggregation’s semantics “intentionally informal.”

Warm-Up (Retrieval from Step 5)

Before you read on — close your eyes for five seconds and answer: in Step 5, what exactly made the UML association arrow appear between Course and Instructor? Was it importing the class, storing an instance as an attribute, calling a method, or something else? Pick the answer you would bet on, then check the next paragraph.

An association appears when a class stores another object as a persistent instance variable — not when it merely imports or uses it. Keep that rule in your head: this step’s composition and aggregation are both special cases of it.

Two Kinds of “Has-A”

Both composition and aggregation model a “whole-part” relationship. The difference is ownership and lifecycle:

Aspect Composition (filled diamond) Aggregation (hollow diamond)
Symbol filled diamond hollow diamond
Ownership Whole owns the part exclusively (no sharing) Whole references the part (can be shared)
Lifecycle Part is destroyed with the whole Part survives independently
Python pattern Part created inside __init__ Part passed in from outside

Honest caveat. Composition has sharp semantics in the UML spec: a part belongs to exactly one composite at a time, and is deleted with it. Aggregation, however, is deliberately fuzzy — the UML 2 specification calls its semantics “intentionally informal”. For this tutorial we’ll use the common textbook interpretation (conceptual whole-part relationship). Aggregation is a domain decision, not a code decision. Whether a relationship is aggregation or plain association cannot be read reliably from code alone — it depends on the meaning of the domain. Is a professor a part of a department or does a department merely know some professors? That answer comes from domain knowledge, not from Python syntax. This tutorial’s live diagram uses heuristics, which works well as a learning scaffold — but in the real world, rely on domain knowledge rather than on tools to infer it.

The File System Metaphor

  • Composition = a directory and its files. If you run rm -rf directory/, the files inside are destroyed. Their lifecycle is bound to the directory.
  • Aggregation = a directory containing symbolic links. If you delete the directory, the symlinks vanish but the original files they pointed to survive.

Your Target Diagram

Notice the two different diamonds:

  • Filled diamond between University and Department → composition. The university creates its departments. If the university ceases to exist, so do its departments.
  • Hollow diamond between Department and Professor → aggregation. Professors are independent people who are assigned to departments. If a department is dissolved, the professors still exist.

Note: You may notice that the live diagram does not show how many departments or professors participate. Those numbers (called multiplicity) are covered in the next step.

Your Task

Complete the starter code:

  1. University.add_department(dept_name) should create a new Department internally (composition — the part is born inside the whole)
  2. Department.add_professor(prof) should receive an existing Professor from outside (aggregation — the part exists independently)
Starter files
university.py
class Professor:
    def __init__(self, name: str, field: str) -> None:
        self.name: str = name
        self.field: str = field


class Department:
    def __init__(self, name: str) -> None:
        self.name: str = name
        self.professors: list[Professor] = []

    def add_professor(self, prof: Professor) -> None:
        # TODO: Store the professor (aggregation — received from outside)
        pass


class University:
    def __init__(self, name: str) -> None:
        self.name: str = name
        self.departments: list[Department] = []

    def add_department(self, dept_name: str) -> None:
        # TODO: Create a new Department and add it (composition — created inside)
        pass

    def get_department(self, name: str) -> Department:
        for dept in self.departments:
            if dept.name == name:
                return dept
        raise ValueError(f"Department '{name}' not found")


if __name__ == "__main__":
    # Professors exist independently — they are created outside
    prof_alice = Professor("Dr. Alice", "AI")
    prof_bob = Professor("Dr. Bob", "Systems")

    # University creates its own departments (composition)
    uni = University("State University")
    uni.add_department("Computer Science")
    uni.add_department("Mathematics")
    assert len(uni.departments) == 2, "add_department needs to actually store the new department"

    # Professors are assigned to departments (aggregation)
    cs = uni.get_department("Computer Science")
    cs.add_professor(prof_alice)
    cs.add_professor(prof_bob)
    assert len(cs.professors) == 2, "add_professor needs to store the received professor"

    print(f"{uni.name} has {len(uni.departments)} departments")
    print(f"CS has {len(cs.professors)} professors")
7

Multiplicity: How Many?

Multiplicity Notation

Learning objective: After this step you will be able to read multiplicity notation on UML associations and implement one-to-many relationships using Python lists.

What Is Multiplicity?

Multiplicity tells you how many instances participate in a relationship. It is written as a number or range next to each end of an association line.

Notation Meaning Equivalent
1 Exactly one  
0..1 Zero or one (optional)  
* (or 0..*) Zero or more a collection that may be empty
1..* One or more a collection that must have at least one element

Style tip: Prefer * over verbose 0..*. The UML spec defines them as identical, and * is the more concise and widely recognized shorthand. Use the explicit 0..* only when you want to emphasize the lower bound in context (e.g., contrasting it with 1..* nearby).

Reading Multiplicity as a Sentence

Read from each end toward the other. Multiplicity sits next to the class end it quantifies:

Playlist “0..*“ Song

  • Left-to-right: “One Playlist contains zero or more Songs.”
  • Right-to-left: “Each Song belongs to some Playlist” — but we can’t say how many from a diagram with only one multiplicity shown.

⚠ Unidirectional diagrams only tell half the story. When the Playlist end is blank, the Song-to-Playlist multiplicity is unspecified, not “1.” In a real music app a song typically lives on many playlists — modeling that requires a multiplicity at the Playlist end too (e.g., Playlist "0..*" <-- "*" Song). This tutorial keeps one end hidden to teach one idea at a time; real designs usually show both.

Placement rule: The number sits next to the class it quantifies. The 0..* goes next to Song because one playlist has many songs, not because there are “many songs in general.”

⚠ Common mistake (Chren et al., 2019): Beginners flip the multiplicities — putting * next to the playlist end to mean “there are many playlists.” That is wrong. Multiplicity always answers: “For one instance of the opposite class, how many of this class participate?”

Your Target Diagram

Your Task

The starter code has a Playlist that holds a single Song. Refactor it to hold many songs:

  1. Change self.song to self.songs: list[Song] = [] (a list of songs)
  2. Add an add_song(song: Song) method that appends to the list
  3. Add get_total_duration() returning the sum of all song durations
  4. Add get_song_count() returning the number of songs

The * multiplicity means the playlist can have zero or more songs.

Starter files
playlist.py
class Song:
    def __init__(self, title: str, artist: str, duration_sec: int) -> None:
        self.title: str = title
        self.artist: str = artist
        self.duration_sec: int = duration_sec


class Playlist:
    """Currently holds a single song. Refactor to hold many songs!"""

    def __init__(self, name: str, song: Song) -> None:
        self.name: str = name
        self.song: Song = song  # Only ONE song — change to a list!


if __name__ == "__main__":
    s1 = Song("Bohemian Rhapsody", "Queen", 354)
    p = Playlist("Road Trip", s1)
    print(f"Playlist: {p.name}")
8

Abstract Classes: Designing for Extension

Abstract Classes in UML

Learning objective: After this step you will be able to implement abstract classes using Python’s abc module and recognize italic notation in UML diagrams.

Flashback to Step 4

Remember Step 4’s Shape?

class Shape:
    def area(self) -> float:
        return 0.0   # ← wait, what is the area of a generic "shape"?

That 0.0 was always a lie. A Shape isn’t a thing you can actually measure — only specific shapes (circles, rectangles) have areas. We hid the lie behind a default value and let Circle and Rectangle override it. That worked, but it left a bug-shaped hole: if you ever wrote Shape("red").area(), Python cheerfully returned 0.0 instead of telling you that you made a design mistake.

Abstract classes are how you fix that hole. By the end of this step, you will know how to say “this class is a blueprint; you must not instantiate it directly, and every subclass must implement these methods.”

What Is an Abstract Class?

An abstract class is a class that cannot be instantiated directly — it serves as a blueprint that subclasses must complete. In UML, abstract classes and abstract methods are shown in italics.

Python’s abc Module

Python does not have an abstract keyword like Java or C++. Instead, you use the abc (Abstract Base Classes) module:

from abc import ABC, abstractmethod

class Shape(ABC):                    # Inherit from ABC
    @abstractmethod                  # Mark as abstract
    def area(self) -> float:
        pass                         # No implementation

Trying to instantiate Shape() directly will raise a TypeError.

Your Target Diagram

Notice: PaymentMethod and its methods appear in italics — this signals they are abstract.

Your Task

The starter code has a concrete PaymentMethod base class. Make it abstract:

  1. Import ABC and abstractmethod from the abc module
  2. Make PaymentMethod inherit from ABC
  3. Mark process() and get_name() with @abstractmethod
  4. Complete the CreditCard and BankTransfer subclasses
Starter files
payments.py
# TODO: Import ABC and abstractmethod from the abc module


class PaymentMethod:
    """This should be abstract — you should NOT be able to create
    a plain PaymentMethod(). Make it inherit from ABC."""

    def process(self, amount: float) -> bool:
        # This should be abstract — mark with @abstractmethod
        return False

    def get_name(self) -> str:
        # This should be abstract — mark with @abstractmethod
        return "Unknown"


class CreditCard(PaymentMethod):
    def __init__(self, card_number: str) -> None:
        self.card_number: str = card_number

    # TODO: Implement process() — print and return True
    # TODO: Implement get_name() — return "Credit Card"


class BankTransfer(PaymentMethod):
    def __init__(self, account_number: str) -> None:
        self.account_number: str = account_number

    # TODO: Implement process() — print and return True
    # TODO: Implement get_name() — return "Bank Transfer"


if __name__ == "__main__":
    cc = CreditCard("4111-1111-1111-1111")
    bt = BankTransfer("DE89370400440532013000")
    print(f"Paying with {cc.get_name()}: {cc.process(49.99)}")
    print(f"Paying with {bt.get_name()}: {bt.process(150.00)}")
9

The Fixer-Upper: Diagnose a Bad Design

The God Class Anti-Pattern

Learning objective: After this step you will be able to identify the God Class anti-pattern in a UML diagram and refactor a monolithic class into cohesive, collaborating classes.

Spotting the Problem

Look at the UML diagram for the starter code. You will see ONE massive class with dozens of attributes and methods, and no other classes at all. This is called a God Class (also known as “The Blot”) — a single class that tries to do everything.

In a UML diagram, the God Class is easy to spot: one huge box surrounded by nothing. No relationships, no collaboration, no distribution of responsibility.

Why It Matters

A God Class is invisible in 500 lines of Python — you might not realize how bloated it is until you try to modify it. But in a UML diagram, the problem screams at you. This is one of the most valuable uses of UML: making bad architecture visible before it becomes a maintenance nightmare.

Your Target Diagram

Refactor the monolithic OnlineStore into this well-structured system:

New Notation: Dependency

The diagram introduces one arrow you have not learned before: the dashed arrow ().

Symbol Name Meaning Python Pattern
Dependency “temporarily uses” — the weakest link A class appears only as a method parameter or local variable — never stored in self

In the target diagram, OnlineStore ..> Customer means OnlineStore uses Customer only inside place_order() — as a method parameter that is immediately handed off to Order. There is no self.customer attribute on OnlineStore; the Customer object passes through and leaves.

Rule of thumb:

  • self.x: Other = otherassociation / composition / aggregation (persistent reference)
  • def method(self, other: Other) or local = Other(...) inside a method, never stored → dependency (temporary use)

This is the weakest possible relationship — the dashed line signals “I know this class exists, but I do not hold onto it.”

Your Task

The starter code is a single OnlineStore class that manages products, customers, orders, and notifications all by itself. Refactor it:

  1. Extract Product — name, price, stock, is_available(), reduce_stock()
  2. Extract Customer — name, email
  3. Extract Order — stores customer and items, calculates total
  4. Slim down OnlineStore — coordinates the other classes

Watch the UML diagram transform from a single blob into an interconnected network.

Starter files
store.py
class OnlineStore:
    """THE GOD CLASS — does everything, knows everything, fears nothing.
    Look at the UML diagram: one giant box, no collaborators.

    Your mission: extract Product, Customer, and Order classes."""

    def __init__(self) -> None:
        # Product data (should be its own class)
        self._product_names: list[str] = []
        self._product_prices: list[float] = []
        self._product_stocks: list[int] = []

        # Order data (should be its own class)
        self._order_customer_names: list[str] = []
        self._order_customer_emails: list[str] = []
        self._order_items: list[Product] = []
        self._order_totals: list[float] = []

    # ── Product management ──────────────────────────────────
    def add_product(self, name: str, price: float, stock: int) -> None:
        self._product_names.append(name)
        self._product_prices.append(price)
        self._product_stocks.append(stock)

    def is_product_available(self, name: str) -> bool:
        idx = self._product_names.index(name)
        return self._product_stocks[idx] > 0

    def get_product_price(self, name: str) -> float:
        idx = self._product_names.index(name)
        return self._product_prices[idx]

    def reduce_product_stock(self, name: str) -> None:
        idx = self._product_names.index(name)
        self._product_stocks[idx] -= 1

    # ── Order management ────────────────────────────────────
    def place_order(self, customer_name: str, customer_email: str,
                    product_names: list) -> int:
        total = 0.0
        for pname in product_names:
            total += self.get_product_price(pname)
            self.reduce_product_stock(pname)

        self._order_customer_names.append(customer_name)
        self._order_customer_emails.append(customer_email)
        self._order_items.append(product_names)
        self._order_totals.append(total)

        order_id = len(self._order_totals) - 1
        print(f"[EMAIL] To: {customer_email} | Order #{order_id} confirmed: ${total:.2f}")
        return order_id

    def get_order_total(self, order_id: int) -> float:
        return self._order_totals[order_id]


if __name__ == "__main__":
    store = OnlineStore()
    store.add_product("Laptop", 999.99, 5)
    store.add_product("Mouse", 29.99, 50)
    store.add_product("Keyboard", 79.99, 30)

    order_id = store.place_order("Alice", "alice@example.com",
                                 ["Laptop", "Mouse"])
    print(f"Order total: ${store.get_order_total(order_id):.2f}")
10

UML Class Diagram Reference

Congratulations!

You have learned to read and create UML class diagrams. This final page summarizes every notation element covered in this tutorial — use it as a quick reference.


The Class Box

Every class is drawn as a box with three compartments:

Compartment Contains Python
Top Class name class ClassName:
Middle Attributes self.x = value
Bottom Methods def method(self):

Visibility

UML Meaning Python Convention
+ Public self.name (no prefix)
- Private self.__name (double underscore)
# Protected self._name (single underscore)

Types

UML Python
name: str self.name: str = name
get_price(): float def get_price(self) -> float:
process(amount: float): bool def process(self, amount: float) -> bool:

Relationships

Symbol Name Meaning Python Pattern
Inheritance “is-a” — child extends parent class Child(Parent):
Association “knows-about” — stores a reference self.other: OtherClass = other
Composition “owns” — part destroyed with whole self.part = Part(...) (created inside)
Aggregation “uses” — part survives independently self.parts.append(part) (passed in)
Dependency “temporarily uses” — weakest link Uses a class inside a method body only

Dependency

A dependency is the weakest relationship between classes. It means one class temporarily uses another — typically as a method parameter or local variable inside a single method — without storing a persistent reference.

class ReportGenerator:
    def generate(self, data: list) -> str:
        formatter = HTMLFormatter()   # Used locally, not stored
        return formatter.format(data)

In UML, this is drawn as a dashed arrow from ReportGenerator to HTMLFormatter. The key difference from association: the ReportGenerator does NOT have an HTMLFormatter attribute — it only creates and uses one temporarily inside generate().

Rule of thumb:

  • self.x = OtherClass(...)association or composition (persistent reference)
  • local_var = OtherClass(...) inside a method → dependency (temporary use)

Multiplicity

Notation Meaning
1 Exactly one
0..1 Zero or one (optional)
* (preferred shorthand for zero or more) Zero or more
1..* One or more
n..m Between n and m

Placement: the number sits next to the class it quantifies — it answers “for one of the opposite class, how many of this class?”

Style (Ambler G117): Show multiplicity on both ends of every relationship; prefer * over verbose 0..*.


Abstract Classes

UML Meaning Python
Italic class name Abstract class — cannot be instantiated class Name(ABC):
Italic method name / {abstract} Abstract method — must be overridden @abstractmethod

Choosing the Right Relationship — a Decision Flowchart

When you’re writing a class, ask these questions in order:

  1. Does this class’s __init__ create the other object internally, and the other object makes no sense outside this one?Composition (e.g., InvoiceLineItem)
  2. Does a persistent self.x: Other store an object that was created outside, and survives this object being destroyed?Aggregation (e.g., TeamPlayer)If aggregation feels contested, a plain Association is always safer.
  3. Is this class a kind of the other, sharing its interface and some behavior?Inheritance (apply the “Is-a” test first)
  4. Does the class only mention the other inside a method body, with no persistent reference?Dependency

If none of these apply, there is no relationship — don’t draw one.

What You Learned

UML class diagrams are a communication tool. They make invisible design decisions visible — turning implicit code relationships into explicit, communicable blueprints. You can now:

  1. Read a UML class diagram and understand its structure
  2. Write Python code that matches a given diagram
  3. Identify anti-patterns like the God Class
  4. Distinguish between association, composition, and aggregation
  5. Communicate software architecture without showing code
  6. Recognise the limits of UML — aggregation’s fuzzy semantics, the language-specific gap between Python’s _/__ and UML -/#, and when to leave notation off rather than force it
Starter files
store.py
# This is the reference page — no coding task here.
# Review the summary above and use it as a quick reference!