Class Diagrams

Object-Oriented Design with UML

Course 1 · Chapter 5 · Class Diagrams in Depth

Chapter 4's domain model used the class diagram's simplest features: boxes, attributes, plain lines. A design class diagram uses the rest: visibility, operations, interfaces, and six kinds of arrow that each make a different promise about code. This chapter teaches the notation and, for each relationship, shows the Python it becomes and runs it, so that you can tell what a diagram is claiming and check that code keeps the claim.

What was run, and what was not
All diagrams were rendered here with Mermaid 12.1.0 (class boxes and relationships) or PlantUML 1.2026.8 (association classes, which Mermaid cannot draw: I ran the attempt and it failed with a lexical error). All Python (3.14) was run with output pasted. I checked the arrow shapes against the rendered pictures, so the descriptions below say what the tools actually draw. From general knowledge, not re-read: the exact wording of the UML specification on aggregation (that "shared aggregation" has little precise meaning), and Martin Fowler's advice to avoid it. The library is invented.

Anatomy of a Class Box

A class is a box with up to three compartments: name, attributes, operations. In a domain model you show only the first two; in a design you show what the reader needs, and no more. This diagram uses every piece of box notation this chapter covers:

Member

+String name

-String pin

#int loanLimit

~int branchCode

+borrow(copy) : Loan

+nextCardNumber() : int

«interface»

FineCalculator

+fineFor(loan, today) : int

«abstract»

Policy

+limit() : int

«enumeration»

CopyStatus

AVAILABLE

ON_LOAN

HELD

WITHDRAWN

Mermaid. Top to bottom: a class with all four visibility marks and a static operation; an interface; an abstract class with an abstract operation; an enumeration.

%%{init: {'themeVariables': {'fontSize': '20px'}}}%% classDiagram direction LR class Member { +String name -String pin #int loanLimit ~int branchCode +borrow(copy) Loan +nextCardNumber()$ int } class FineCalculator { <<interface>> +fineFor(loan, today) int } class Policy { <<abstract>> +limit()* int } class CopyStatus { <<enumeration>> AVAILABLE ON_LOAN HELD WITHDRAWN }
NotationMeaningIn Python
+ publicAnyone may use itAn ordinary name
- privateOnly this class__name (name-mangled), or _name by convention
# protectedThis class and its subclasses_name, by convention only
~ packageSame package or moduleNo direct equivalent: a leading underscore on a module-level name, or a convention
Underlined ($ in Mermaid)Static: belongs to the class, not an instance@staticmethod, @classmethod, a class attribute
Italic name or operation (*)Abstract: cannot be instantiated; must be implemented by a subclassABC with @abstractmethod
«interface»A contract: operations without an implementationA typing.Protocol (structural) or an ABC (nominal)
«enumeration»A fixed set of named valuesenum.Enum

Python enforces much less than the notation promises. The run below (the first sections of the output) shows what the language actually does with each mark:

"""Class-diagram notation, one concept at a time, as Python. Each comment names the diagram element it implements.""" from abc import ABC, abstractmethod from dataclasses import dataclass, field from datetime import date from enum import Enum from itertools import count from typing import Protocol, runtime_checkable # --- enumeration: <<enumeration>> CopyStatus ---------------------------------------------------------------- class CopyStatus(Enum): AVAILABLE = "available" ON_LOAN = "on loan" HELD = "held" WITHDRAWN = "withdrawn" # --- interface and realisation: <<interface>> FineCalculator <|.. DailyRateFine --------------------------------- @runtime_checkable class FineCalculator(Protocol): def fine_for(self, due_on: date, returned_on: date) -> int: ... class DailyRateFine: # realises FineCalculator without inheriting from it def __init__(self, pence_per_day: int = 10): self.pence_per_day = pence_per_day def fine_for(self, due_on: date, returned_on: date) -> int: return max(0, (returned_on - due_on).days) * self.pence_per_day class NoFine: def fine_for(self, due_on: date, returned_on: date) -> int: return 0 # --- abstract class and generalisation: <<abstract>> Policy <|-- StandardPolicy, JuniorPolicy ----------------- class Policy(ABC): @abstractmethod def limit(self) -> int: ... # +limit()* : the abstract operation, shown in italics class StandardPolicy(Policy): def limit(self) -> int: return 5 class JuniorPolicy(Policy): def limit(self) -> int: return 3 # --- visibility and static: +name, -pin, #loanLimit, +nextCardNumber()$ --------------------------------------- class Member: _cards = count(1) # class-level state: the "static" in the diagram def __init__(self, name: str, pin: str): self.name = name # + public self.__pin = pin # - private (name-mangled) self._loan_limit = 5 # # protected (a convention only) self.loans: list["Loan"] = [] # association Member 1 --> 0..* Loan (navigable this way only) @staticmethod def next_card_number() -> int: # +nextCardNumber()$ : underlined in the diagram return next(Member._cards) @dataclass class Loan: # note: no reference back to Member: navigability is one-way copy: "Copy" borrowed_on: date # --- composition: Title *-- Copy (a Copy cannot exist without its Title, and goes when the Title goes) ---------- @dataclass class Copy: barcode: str title: "Title" # required: there is no Copy without a Title status: CopyStatus = CopyStatus.AVAILABLE @dataclass class Title: isbn: str name: str copies: list[Copy] = field(default_factory=list) def add_copy(self, barcode: str) -> Copy: # the whole creates and owns its parts copy = Copy(barcode, self) self.copies.append(copy) return copy def withdraw(self) -> list[Copy]: # the whole's end of life ends the parts' too gone, self.copies = self.copies, [] for c in gone: c.status = CopyStatus.WITHDRAWN return gone # --- aggregation: Shelf o-- Copy (copies are grouped by a shelf but live on without it) ----------------------- @dataclass class Shelf: label: str copies: list[Copy] = field(default_factory=list) # --- dependency: LoanDesk ..> FineCalculator (used in a call, never stored) --------------------------------------- class LoanDesk: def close(self, due_on: date, returned_on: date, calc: FineCalculator) -> int: return calc.fine_for(due_on, returned_on) # --- association class: (Member, Title) .. Reservation -------------------------------------------------------------- @dataclass class Reservation: member: Member title: Title placed_on: date class ReservationBook: """One Reservation per (Member, Title) pair: that is what an association class means.""" def __init__(self): self._by_pair: dict[tuple[int, str], Reservation] = {} def place(self, member: Member, title: Title, today: date) -> Reservation: key = (id(member), title.isbn) if key in self._by_pair: raise ValueError(f"{member.name} has already reserved {title.name}") self._by_pair[key] = Reservation(member, title, today) return self._by_pair[key]
from datetime import date from library_classes import (Copy, CopyStatus, DailyRateFine, FineCalculator, JuniorPolicy, Loan, LoanDesk, Member, NoFine, Policy, ReservationBook, Shelf, StandardPolicy, Title) print("== visibility: Python only has conventions") ada = Member("Ada", "1234") print(" +name:", ada.name) print(" #_loan_limit readable from outside:", ada._loan_limit) try: ada.__pin except AttributeError as e: print(" -__pin from outside:", type(e).__name__, "(name-mangled)") print(" ...but reachable as _Member__pin:", ada._Member__pin, " <- private by politeness, not enforcement") print("== static: +nextCardNumber()$") print(" ", Member.next_card_number(), Member.next_card_number(), Member.next_card_number(), "(shared counter, no instance needed)") print("== abstract: <<abstract>> Policy") try: Policy() except TypeError as e: print(" Policy():", e) print(" generalisation: StandardPolicy.limit() =", StandardPolicy().limit(), " JuniorPolicy.limit() =", JuniorPolicy().limit()) print("== enumeration: CopyStatus") try: CopyStatus("lost") except ValueError as e: print(" CopyStatus('lost'):", e) print("== realisation: DailyRateFine realises FineCalculator without inheriting") print(" isinstance(DailyRateFine(), FineCalculator):", isinstance(DailyRateFine(), FineCalculator), "| DailyRateFine.__mro__:", [c.__name__ for c in DailyRateFine.__mro__]) print(" isinstance(object(), FineCalculator):", isinstance(object(), FineCalculator)) print("== dependency: LoanDesk uses a calculator only for the length of one call") desk = LoanDesk() print(" fields stored on LoanDesk:", vars(desk), "| fine (3 days late, 10p/day):", desk.close(date(2026, 10, 10), date(2026, 10, 13), DailyRateFine()), "| with NoFine:", desk.close(date(2026, 10, 10), date(2026, 10, 13), NoFine())) print("== association with navigability: Member --> Loan") dune = Title("111", "Dune"); c1 = dune.add_copy("B-1") loan = Loan(c1, date(2026, 10, 10)); ada.loans.append(loan) print(" member -> loans: ", len(ada.loans), "loan(s) | loan -> member: has attribute 'member'?", hasattr(loan, "member")) print("== composition: Title *-- Copy") try: Copy("B-9") except TypeError as e: print(" Copy('B-9') alone:", e) dune.add_copy("B-2") gone = dune.withdraw() print(" withdrawing the title:", [(c.barcode, c.status.value) for c in gone], "| title now has", len(dune.copies), "copies") print("== aggregation: Shelf o-- Copy") t2 = Title("222", "Emma"); a, b = t2.add_copy("E-1"), t2.add_copy("E-2") shelf = Shelf("F1", [a, b]); del shelf print(" shelf deleted; copies still exist and are", [c.status.value for c in (a, b)]) print("== association class: (Member, Title) .. Reservation") book = ReservationBook() book.place(ada, t2, date(2026, 10, 10)) try: book.place(ada, t2, date(2026, 10, 11)) except ValueError as e: print(" second reservation of the same title by the same member:", e)
== visibility: Python only has conventions +name: Ada #_loan_limit readable from outside: 5 -__pin from outside: AttributeError (name-mangled) ...but reachable as _Member__pin: 1234 <- private by politeness, not enforcement == static: +nextCardNumber()$ 1 2 3 (shared counter, no instance needed) == abstract: <<abstract>> Policy Policy(): Can't instantiate abstract class Policy without an implementation for abstract method 'limit' generalisation: StandardPolicy.limit() = 5 JuniorPolicy.limit() = 3 == enumeration: CopyStatus CopyStatus('lost'): 'lost' is not a valid CopyStatus == realisation: DailyRateFine realises FineCalculator without inheriting isinstance(DailyRateFine(), FineCalculator): True | DailyRateFine.__mro__: ['DailyRateFine', 'object'] isinstance(object(), FineCalculator): False == dependency: LoanDesk uses a calculator only for the length of one call fields stored on LoanDesk: {} | fine (3 days late, 10p/day): 30 | with NoFine: 0 == association with navigability: Member --> Loan member -> loans: 1 loan(s) | loan -> member: has attribute 'member'? False == composition: Title *-- Copy Copy('B-9') alone: Copy.__init__() missing 1 required positional argument: 'title' withdrawing the title: [('B-1', 'withdrawn'), ('B-2', 'withdrawn')] | title now has 0 copies == aggregation: Shelf o-- Copy shelf deleted; copies still exist and are ['available', 'available'] == association class: (Member, Title) .. Reservation second reservation of the same title by the same member: Ada has already reserved Emma

The visibility section is the one to read twice. -pin is "private" in the diagram and in the code (__pin), yet it is reachable as _Member__pin: Python's privacy is politeness. When you draw - you are stating the design intent; in Java or C# the compiler would enforce it, in Python a test or a reviewer must. The abstract class and the enumeration, by contrast, are enforced: Policy() and CopyStatus("lost") both fail.

The Six Relationships

Every line between classes is one of these. They run, roughly, from the weakest dependency to the strongest coupling. Here they are in the library, rendered:

association

1

0..*

composition

1

1..*

aggregation

0..1

0..*

generalisation

realisation

dependency

Member

Loan

Title

Copy

Shelf

«abstract»

Policy

StandardPolicy

JuniorPolicy

«interface»

FineCalculator

DailyRateFine

LoanDesk

Mermaid. Each label names the relationship drawn. Check the shapes: an open arrow, a filled diamond at the whole, a hollow diamond at the whole, a hollow triangle on a solid line, a hollow triangle on a dotted line, and a dotted line with an open arrow.

%%{init: {'themeVariables': {'fontSize': '18px'}}}%% classDiagram direction LR class Member class Loan class Title class Copy class Shelf class Policy { <<abstract>> } class StandardPolicy class JuniorPolicy class FineCalculator { <<interface>> } class DailyRateFine class LoanDesk Member "1" --> "0..*" Loan : association Title "1" *-- "1..*" Copy : composition Shelf "0..1" o-- "0..*" Copy : aggregation StandardPolicy --|> Policy : generalisation JuniorPolicy --|> Policy DailyRateFine ..|> FineCalculator : realisation LoanDesk ..> FineCalculator : dependency
RelationshipDrawn asTest questionIn code
DependencyDotted line, open arrow, to the thing usedDoes it use the other only briefly: a parameter, a local variable, a call?No field. LoanDesk.close(..., calc) takes the calculator as an argument and keeps nothing
AssociationSolid line, usually an open arrow showing who can navigate to whomDoes it know about the other over time?A field: Member.loans
AggregationSolid line, hollow diamond at the whole"Has a", and the parts live on without the whole?A field holding a collection; the parts have a life elsewhere (a Shelf of Copys)
CompositionSolid line, filled diamond at the wholeIf the whole dies, do the parts die with it, and can a part belong to only one whole?The whole creates and owns the parts (Title.add_copy); the part cannot be built without it
GeneralisationSolid line, hollow triangle at the parentCan the child be used anywhere the parent is expected, and behave as promised?Subclassing: StandardPolicy(Policy)
RealisationDotted line, hollow triangle at the interfaceDoes the class fulfil the contract of an interface?Implementing a Protocol/ABC: DailyRateFine for FineCalculator

The same run's output for each relationship (read the lines under each heading in the output above):

  • Dependency. fields stored on LoanDesk: {}. It computed a fine with DailyRateFine (30p for 3 days) and with NoFine (0) and remembered neither. Dependency is the cheapest coupling: change the calculator and nothing that merely passes it in is affected.
  • Association with navigability. A member reaches its loans; hasattr(loan, "member") is False. See the next section.
  • Composition. Copy("B-9") alone fails for want of its title, and withdrawing the title withdraws its copies. Honesty about the demonstration: Python garbage-collects, so "lifetime" here is bookkeeping I wrote (marking copies withdrawn), not a language guarantee.
  • Aggregation. del shelf and the copies are still there. That is true of any plain reference, which is why the next point matters.
  • Generalisation and realisation. Abstract Policy cannot be created; StandardPolicy (limit 5) and JuniorPolicy (limit 3) can. DailyRateFine satisfies FineCalculator with no inheritance at all: isinstance is true, and its inheritance chain is just DailyRateFine and object. That is realisation in Python: a Protocol matches by shape.

Aggregation: Usually Not Worth Drawing

Aggregation is the relationship people argue about, and the argument is the lesson. As I remember it, the UML specification says aggregation (the hollow diamond) carries no precise meaning beyond "association", and Fowler's UML Distilled advises not using it. The code agrees: a Shelf holding Copy references is indistinguishable from any association with a list. A practical rule: draw an association unless the filled diamond (composition) is genuinely true. If you feel you must use the hollow diamond, say in a note what you mean by it.

Composition is meaningful, because it makes two promises: exclusive ownership (a part belongs to one whole) and shared lifetime. Use it when both are true. The library's "a Copy exists only as a copy of a Title" qualifies, if the library's policy is that withdrawing a title withdraws its copies; if copies could be re-catalogued under a different title, it would not.

Multiplicity and Navigability

Multiplicity works as in chapter 4. Navigability is the arrowhead: it says which class holds the reference. In the code above Member has loans, but Loan has no member: the diagram's arrow from Member to Loan is exactly that, one way. A line with no arrowheads says "undecided" (chapter 4's model) or "both ways"; be sure which you mean. Navigability has real costs:

  • One way is simpler. From a loan you cannot reach its member without searching every member. If you need that, add a reference, not a hope.
  • Two ways must be kept in step. If Member.loans and Loan.member both exist, every place that creates or removes a loan must update both, and a mismatch is a bug that tests rarely catch. Decide who owns the link, and have only that side change it.

Association Classes

Sometimes a fact belongs to the relationship, not to either end: a reservation's date belongs to the pairing of a member and a title. The notation is a class attached by a dashed line to the middle of the association. Mermaid cannot draw it (I tried: Lexical error on line 6. Unrecognized text.), so this one is PlantUML:

MembernameTitletitleReservationplacedOnqueuePositionreserves0..*0..*

PlantUML. Reservation holds the facts about one (Member, Title) link.

@startuml scale 1.4 hide empty methods left to right direction class Member { name } class Title { title } class Reservation { placedOn queuePosition } Member "0..*" -- "0..*" Title : reserves (Member, Title) .. Reservation @enduml

The last two lines of the notation demo show the catch. An association class means one instance per pair: a member cannot have two reservations for the same title, and the second attempt fails. If your domain allows it (reserve again after a lapsed hold), the thing is an ordinary class with its own identity and two associations, not an association class. Choose the notation by the rule you mean.

Inheritance for Reuse: The Classic Mistake

Generalisation is the most overused arrow. The question is not "does a librarian have a name and a list of loans like a member?" (reuse) but "can a librarian be used anywhere a member is expected and still behave as the member's code assumes?" (substitutability). Clean Code, SOLID & Refactoring 6 and 7 cover the Liskov Substitution Principle properly; here is the failure it prevents, run:

"""Inheritance for reuse, and what it does to code that trusts the parent's promises. Then the composition alternative.""" from dataclasses import dataclass, field @dataclass class Member: name: str loans: list = field(default_factory=list) def loan_limit(self) -> int: return 5 def can_borrow(member: Member) -> bool: # written for Members, trusting the promise "loan_limit() is an int" return len(member.loans) < member.loan_limit() # --- the shortcut: a Librarian "is a" Member, because it saves re-typing name and loans --- class LibrarianIsAMember(Member): def loan_limit(self): return None # "staff have no limit": the parent's promise is broken print("1. Member: can_borrow ->", can_borrow(Member("Ada"))) try: print("2. Librarian-as-Member: can_borrow ->", can_borrow(LibrarianIsAMember("Bo"))) except TypeError as e: print("2. Librarian-as-Member:", type(e).__name__ + ":", e) # --- the alternative: a Librarian HAS a membership, and does not pretend to be one --- @dataclass class Librarian: name: str membership: Member | None = None # staff may also borrow, as ordinary members bo = Librarian("Bo", membership=Member("Bo")) print("3. Librarian with an ordinary membership: can_borrow ->", can_borrow(bo.membership)) print(" isinstance(librarian, Member):", isinstance(bo, Member), "(no lie told, so no code is surprised)")
1. Member: can_borrow -> True 2. Librarian-as-Member: TypeError: '<' not supported between instances of 'int' and 'NoneType' 3. Librarian with an ordinary membership: can_borrow -> True isinstance(librarian, Member): False (no lie told, so no code is surprised)

can_borrow was written for members and trusts that loan_limit() returns a number. A librarian-as-member returns None ("no limit") and the check crashes: the subclass broke the parent's promise. The alternative in the last lines, a librarian who has an ordinary membership, tells no lie, so no code is surprised. On a diagram this is the difference between a hollow triangle and an association. Design Patterns chapters on Strategy and State show the usual way to vary behaviour without subclassing the owner.

Reading a Class Diagram Quickly

  1. Find the centre. The class with the most lines is usually the heart (here, Loan or RepairJob).
  2. Read the nouns first, then the diamonds and triangles: ownership and "is a" are the strong claims.
  3. Follow one arrow in words: "A Member holds zero or more Loans; a Loan cannot find its Member."
  4. Check each multiplicity against reality: exactly-one and zero-or-more are where wrong assumptions hide.
  5. Look for what is missing: the thing in the use cases that has no box; the arrow you would need to answer a question the system must answer.

Usual Mistakes

MistakeWhy it hurtsFix
Inheritance to share codeSubclass breaks the parent's promises; fragile hierarchiesComposition and delegation; inheritance only for true substitutability
Hollow diamond everywhereIt adds no meaning; readers guessAssociation; composition only when ownership and lifetime are real
Diamond on the wrong endSays the part owns the wholeThe diamond sits at the whole
Arrows both ways by defaultTwo references to keep in stepOne direction unless both are needed
Every attribute and operation shownThe diagram stops being readable and goes out of date at onceShow what the reader needs; omit getters and setters
Interface and implementation arrows confusedSolid triangle line means inheritance; dotted means implementationCheck solid versus dotted
Drawing the databaseJoin tables and keys are not classesKeep the domain model; map later
Mistakes while preparing this chapter
  • A shell command that wasted a step. I wrote the demonstration files with a shell here-document whose text contained <<enumeration>> and similar, and the shell tried to parse them as redirections, failing with a syntax error before anything ran. How to avoid it: write source files with a file-writing tool, not a here-document, whenever the text contains angle brackets or quotes of its own.
  • My first notation diagram was tiny. Mermaid draws class boxes in a small font, so four stacked boxes occupied a corner of the picture. Raising the font size with an init directive made it legible. Takeaway: look at a diagram at the size the reader will see it.
  • A demonstration that proves less than it appears to. Python's garbage collection means del shelf leaves copies alive for any reason at all, and "withdrawing a title" is bookkeeping I wrote. The page now says so beside the output. Takeaway: when a diagram promise (lifetime) is not something the language enforces, say what the demo does and does not show.
  • A claim tested only by trying it. I believed Mermaid could not draw association classes and verified it by running a diagram (it fails). I still cannot say it never will: it is a limit of Mermaid 12.1.0 as run here.

Hands-On Exercises

Exercise 1

Read the bike shop diagram in the solution file (do not render it first) and answer: (a) when a RepairJob is deleted, what happens to its JobParts and to the Parts? (b) can you get from a Mechanic to their jobs? (c) can a Bike exist without a Customer? (d) which line is a dependency, and what does that say about RepairJob's fields? (e) can you create a Pricing object?

📄 View solution
Exercise 2

Here is some Python (in the solution file). Draw its class diagram in Mermaid, choosing for each link whether it is an association, composition, realisation or dependency, and say how you decided. Check by running the code.

📄 View solution
Exercise 3

A mechanic can be certified for several bike types and a bike type has several certified mechanics; each certification has a date. Draw it with an association class in PlantUML, implement it in Python with tests (many-to-many, a fact on the link, one certification per pair), and say when an association class is the wrong choice.

📄 View solution

Chapter 5 Quick Reference

  • Class box: name / attributes / operations; + - # ~ visibility (intent only in Python); underlined = static; italic = abstract; «interface», «enumeration»
  • Dependency (dotted, open arrow): used briefly, no field. Association (solid, open arrow): a field. Composition (filled diamond at the whole): owns, shared lifetime. Aggregation (hollow diamond): rarely worth it. Generalisation (solid, hollow triangle): substitutable. Realisation (dotted, hollow triangle): fulfils an interface
  • Navigability = the arrowhead = who holds the reference; two-way links must be kept in step
  • Association class: a fact about a pair; implies one instance per pair; Mermaid cannot draw it, PlantUML can
  • Inheritance is for substitutability, not reuse; prefer composition when the child would break the parent's promises
  • Read a diagram: find the centre, read nouns then diamonds and triangles, follow one arrow in words, check multiplicities, look for what is missing
Coming next
Object-Oriented Design with UML 6 (Assigning Responsibilities): CRC cards, the GRASP patterns that matter (Information Expert, Creator, Controller, Low Coupling, High Cohesion, Polymorphism, Pure Fabrication) with measured before and after Python examples, the anemic domain model, and deciding who does what in the library.