Low-Level Design (Object-Oriented Design) Module 3 – Class relationships and UML
UML class diagrams that interviewers can read
Draw UML class diagrams for low-level design: class boxes, visibility, interfaces, enumerations, multiplicities and a level of detail that fits a round.
What you will learn
- Draw classes with attributes, operations, visibility and multiplicity
- Show interfaces, enumerations and abstract classes correctly
- Choose the level of detail that fits a 45-minute round
- Generate a class diagram from code and keep its source beside the code
Before you start
What you’ll learn · Before you start
What you will learn
- Draw classes with attributes, operations, visibility and multiplicity
- Show interfaces, enumerations and abstract classes correctly
- Choose the level of detail that fits a 45-minute round
- Generate a class diagram from code and keep its source beside the code
Before you start
On this page
A UML class diagram is the map of a design. Each class is a box with up to three compartments: its name, the attributes it holds and the operations it offers. Lines between the boxes are the relationships of the previous lesson, with a multiplicity at each end of every association. In a low-level design round the diagram has one job: to let the interviewer check your model in a minute, which classes exist, what each is responsible for and how many of each are linked.
A diagram that does that job is small. It shows the classes that hold rules, their public operations, the attributes those rules read, and every association with both multiplicities. This lesson covers the notation for visibility, types, multiplicity, abstract classes, interfaces and enumerations, then draws one parking lot at three levels of detail straight from its code and counts what each level shows.
The class box
The top compartment holds the class name. The second lists attributes and the third lists operations, each on its own line. UML lets you leave out any compartment, so a box that shows only its name is a view that hides the members, not a statement that the class has none.
An attribute is written visibility name : Type [multiplicity] = default, and every part after the name may be
left out. - paidAt : Instant [0..1] is a private attribute that may be empty, and + capacity : int = 40 has a
default value. A slash before the name marks a derived attribute, one computed from others, such as
/ minutesParked : int, and {readOnly} after it marks one that cannot change once set.
An operation is written visibility name(parameter : Type, …) : ReturnType, as in
+ exit(ticketNumber : int, now : Instant) : Money. A parameter may also carry a direction (in, out or
inout), which matters mainly for code generated in languages with output parameters. Static members are
underlined. An abstract operation, one that subclasses must provide, is shown in italics, or with {abstract} when
you draw by hand and italics are hard to see.
Visibility comes first on each line:
| Symbol | UML meaning | In Java | In Python |
|---|---|---|---|
+ |
Public: visible to any element that can see the class | public |
A name without a leading underscore |
- |
Private: visible only inside the class | private |
One leading underscore, by convention only |
# |
Protected: visible to the class and its subclasses | protected |
No equivalent |
~ |
Package: visible inside the same package | No modifier at all | No equivalent |
Other languages map less neatly. Go exports a name exactly when it starts with an upper-case letter, and a
lower-case name stays visible to the whole package, which is closer to ~ than to -. Rust items are private to
their module unless they are marked pub. Use the symbol that tells the reader what callers may use, and do not
invent visibility that the language cannot enforce: # on a Python diagram promises something Python does not
check.
Interfaces, abstract classes and enumerations
Three special kinds of box appear in almost every design:
- Interface. The keyword «interface» above the name, and only operations inside. A class that implements it is
joined to it by a realization, the dashed line with the hollow triangle. In Python that is an abstract base class
or a
typing.Protocol; in Go an interface; in Rust a trait. - Abstract class. The name in italics or followed by
{abstract}. It may hold attributes and code that its subclasses share, which an interface cannot. In Python, a subclass ofABCthat still has an@abstractmethodcannot be instantiated. - Enumeration. The keyword «enumeration» above the name and a compartment of literals:
SMALL,MEDIUM,LARGE. An attribute whose type is an enumeration stays an attribute,size : SpotSize, rather than a line to the enumeration’s box.
UML calls «interface» and «enumeration» keywords, and they look like the stereotypes a profile adds. Keep to these
few. A diagram where every box carries «entity», «service» or «manager» is harder to read, not more precise. One
language trap is worth knowing: a UML enumeration is a fixed list of named values, while a Rust enum whose variants
carry different data, or a TypeScript union of object types, is a set of different shapes. Draw those as an abstract
class or an interface with one subclass per variant.
Three levels of detail, measured
How much detail a diagram needs is easier to judge with numbers. model.py below is a complete parking lot with
nine classes, written with type hints. Two notes in typing.Annotated add what a type hint cannot say: OWNED
marks the parts a class creates and keeps, and Far("0..1") gives the multiplicity at the other end of an
association. draw.py reads the classes with inspect, counts what three levels of detail would show and prints
the D2 source of the middle one:
parking/draw.py
"""Draws the parking lot's class diagram from its code, at three levels of detail.
It reads model.py with inspect and typing, counts what each level would show, and
prints the D2 source of the level that suits an interview. A name that starts with
one underscore is private by convention: it is shown with "-" and without the
underscore. Every other name is shown with "+".
"""
import enum
import inspect
import types
import typing
from typing import Annotated, Protocol, get_args, get_origin
import model
CLASSES = [
obj
for obj in vars(model).values()
if inspect.isclass(obj) and obj.__module__ == "model" and obj not in (model.Far, model.Owned)
]
LEVELS = ("names", "interview", "full")
def shown(name):
"""+name for public names; -name, without the underscore, for private ones."""
private = name.startswith("_") and not name.startswith("__")
return f"-{name[1:]}" if private else f"+{name}"
def unpack(hint):
"""(the type, the multiplicity at its end, the Annotated extras) of one hint."""
extras = ()
if get_origin(hint) is Annotated:
hint, *extras = get_args(hint)
origin, args = get_origin(hint), get_args(hint)
if origin in (list, set, tuple):
return args[0], "0..*", extras
if origin is dict:
return args[1], "0..*", extras
if origin in (typing.Union, types.UnionType) and type(None) in args:
return next(a for a in args if a is not type(None)), "0..1", extras
return hint, "1", extras
def is_line(target):
"""Classes of the model are drawn as lines; enumerations stay attributes."""
return target in CLASSES and not issubclass(target, enum.Enum)
def type_name(t):
return getattr(t, "__name__", str(t))
def members(cls, level):
if level == "names":
return []
if issubclass(cls, enum.Enum):
return [(literal.name, "") for literal in cls]
rows = []
for name, hint in inspect.get_annotations(cls).items():
target, many, _ = unpack(hint)
if level == "interview" and (name.startswith("_") or is_line(target)):
continue
rows.append((shown(name), type_name(target) + ("" if many == "1" else f"[{many}]")))
for name, fn in vars(cls).items():
# Skip what Python adds by itself: annotation helpers and typing.Protocol's __init__.
if not inspect.isfunction(fn) or name.startswith("__annotate") or fn.__module__ != model.__name__:
continue
if level == "interview" and (name.startswith("_") or name.startswith("get_")):
continue
params = ", ".join(p for p in inspect.signature(fn).parameters if p != "self")
returns = inspect.get_annotations(fn).get("return")
if returns is None:
result = "None"
else:
target, many, _ = unpack(returns)
result = type_name(target) + ("" if many == "1" else f"[{many}]")
if getattr(fn, "__isabstractmethod__", False):
result += " {abstract}"
rows.append((f"{shown(name)}({params})", result))
return rows
def lines():
out = []
for cls in CLASSES:
for base in cls.__bases__:
if base in CLASSES:
kind = "realization" if Protocol in base.__bases__ else "generalization"
out.append((kind, cls, base, "", "", ""))
for name, hint in inspect.get_annotations(cls).items():
target, many, extras = unpack(hint)
if not is_line(target):
continue
owned = model.OWNED in extras
far = next((e.multiplicity for e in extras if isinstance(e, model.Far)), "1" if owned else "")
out.append(("composition" if owned else "association", cls, target, name, many, far))
return out
def label(cls):
if Protocol in cls.__bases__:
return f"«interface»\\n{cls.__name__}"
if issubclass(cls, enum.Enum):
return f"«enumeration»\\n{cls.__name__}"
if inspect.isabstract(cls):
return f"{cls.__name__} {{abstract}}"
return cls.__name__
def d2(level):
out = ["direction: down"]
for cls in CLASSES:
out.append(f"{cls.__name__}: {{")
out.append(" shape: class")
out.append(f' label: "{label(cls)}"')
out.extend(f' "{key}": "{value}"' for key, value in members(cls, level))
out.append("}")
for kind, a, b, role, many, far in lines():
if kind in ("generalization", "realization"):
dash = " style.stroke-dash: 4;" if kind == "realization" else ""
out.append(f"{a.__name__} -> {b.__name__}: {{{dash} target-arrowhead: {{shape: triangle; style.filled: false}} }}")
elif kind == "composition":
out.append(
f'{a.__name__} <-> {b.__name__}: "{role}" {{ source-arrowhead: {{shape: diamond; style.filled: true; label: "{far}"}};'
f' target-arrowhead: {{shape: arrow; label: "{many}"}} }}'
)
else:
out.append(f'{a.__name__} -> {b.__name__}: "{role}" {{ source-arrowhead.label: "{far}"; target-arrowhead: {{shape: arrow; label: "{many}"}} }}')
return "\n".join(out)
print(f"{'level':<10} {'boxes':>5} {'members':>8} {'lines':>6}")
for level in LEVELS:
count = sum(len(members(cls, level)) for cls in CLASSES)
print(f"{level:<10} {len(CLASSES):>5} {count:>8} {len(lines()):>6}")
print()
print("D2 source of the interview level:")
print(d2("interview")) Output
level boxes members lines
names 9 0 8
interview 9 21 8
full 9 38 8
D2 source of the interview level:
direction: down
SpotSize: {
shape: class
label: "«enumeration»\nSpotSize"
"SMALL": ""
"MEDIUM": ""
"LARGE": ""
}
Vehicle: {
shape: class
label: "Vehicle {abstract}"
"+plate": "str"
"+size()": "SpotSize {abstract}"
}
Car: {
shape: class
label: "Car"
"+size()": "SpotSize"
}
Motorcycle: {
shape: class
label: "Motorcycle"
"+size()": "SpotSize"
}
Spot: {
shape: class
label: "Spot"
"+number": "int"
"+size": "SpotSize"
"+fits(vehicle)": "bool"
"+park(vehicle)": "None"
"+leave()": "Vehicle[0..1]"
}
Ticket: {
shape: class
label: "Ticket"
"+number": "int"
"+entered_at": "int"
"+minutes_until(now)": "int"
}
FeePolicy: {
shape: class
label: "«interface»\nFeePolicy"
"+fee(minutes)": "int"
}
HourlyFee: {
shape: class
label: "HourlyFee"
"+rate": "int"
"+fee(minutes)": "int"
}
ParkingLot: {
shape: class
label: "ParkingLot"
"+enter(vehicle, now)": "Ticket"
"+exit(ticket_number, now)": "int"
"+free_spots(size)": "int"
}
Car -> Vehicle: { target-arrowhead: {shape: triangle; style.filled: false} }
Motorcycle -> Vehicle: { target-arrowhead: {shape: triangle; style.filled: false} }
Spot -> Vehicle: "vehicle" { source-arrowhead.label: "0..1"; target-arrowhead: {shape: arrow; label: "0..1"} }
Ticket -> Spot: "spot" { source-arrowhead.label: "0..1"; target-arrowhead: {shape: arrow; label: "1"} }
HourlyFee -> FeePolicy: { style.stroke-dash: 4; target-arrowhead: {shape: triangle; style.filled: false} }
ParkingLot <-> Spot: "spots" { source-arrowhead: {shape: diamond; style.filled: true; label: "1"}; target-arrowhead: {shape: arrow; label: "0..*"} }
ParkingLot <-> Ticket: "tickets" { source-arrowhead: {shape: diamond; style.filled: true; label: "1"}; target-arrowhead: {shape: arrow; label: "0..*"} }
ParkingLot -> FeePolicy: "fee_policy" { source-arrowhead.label: "0..*"; target-arrowhead: {shape: arrow; label: "1"} }
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 draw.py
parking/model.py
"""The parking lot of the lesson, written so that a program can read its structure.
Type hints give the type of every attribute. Annotated adds the two things a hint
cannot say: OWNED marks parts that the class creates and keeps (composition), and
Far gives the multiplicity at the other end of an association.
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass
from enum import Enum
from typing import Annotated, Protocol
@dataclass(frozen=True)
class Far:
multiplicity: str
class Owned:
pass
OWNED = Owned()
class SpotSize(Enum):
SMALL = 1
MEDIUM = 2
LARGE = 3
class Vehicle(ABC):
plate: str
def __init__(self, plate: str) -> None:
self.plate = plate
@abstractmethod
def size(self) -> SpotSize:
"""The smallest spot the vehicle fits in."""
def get_plate(self) -> str:
return self.plate
class Car(Vehicle):
def size(self) -> SpotSize:
return SpotSize.MEDIUM
class Motorcycle(Vehicle):
def size(self) -> SpotSize:
return SpotSize.SMALL
class Spot:
number: int
size: SpotSize
vehicle: Annotated[Vehicle | None, Far("0..1")]
def __init__(self, number: int, size: SpotSize) -> None:
self.number = number
self.size = size
self.vehicle = None
def fits(self, vehicle: Vehicle) -> bool:
return self.vehicle is None and vehicle.size().value <= self.size.value
def park(self, vehicle: Vehicle) -> None:
self.vehicle = vehicle
def leave(self) -> Vehicle | None:
vehicle, self.vehicle = self.vehicle, None
return vehicle
def get_number(self) -> int:
return self.number
def __repr__(self) -> str:
return f"Spot({self.number}, {self.size.name})"
class Ticket:
number: int
entered_at: int
spot: Annotated[Spot, Far("0..1")]
def __init__(self, number: int, spot: Spot, entered_at: int) -> None:
self.number = number
self.spot = spot
self.entered_at = entered_at
def minutes_until(self, now: int) -> int:
return now - self.entered_at
class FeePolicy(Protocol):
def fee(self, minutes: int) -> int: ...
class HourlyFee(FeePolicy):
rate: int
_free_minutes: int
def __init__(self, rate: int, free_minutes: int = 15) -> None:
self.rate = rate
self._free_minutes = free_minutes
def fee(self, minutes: int) -> int:
if minutes <= self._free_minutes:
return 0
return -(-minutes // 60) * self.rate
class ParkingLot:
spots: Annotated[list[Spot], OWNED]
tickets: Annotated[dict[int, Ticket], OWNED]
fee_policy: Annotated[FeePolicy, Far("0..*")]
_next_number: int
def __init__(self, sizes: list[SpotSize], fee_policy: FeePolicy) -> None:
self.spots = [Spot(n, size) for n, size in enumerate(sizes, start=1)]
self.tickets = {}
self.fee_policy = fee_policy
self._next_number = 1
def enter(self, vehicle: Vehicle, now: int) -> Ticket:
spot = self._find_spot(vehicle)
if spot is None:
raise LookupError(f"no free spot for {vehicle.plate}")
spot.park(vehicle)
ticket = Ticket(self._next_number, spot, now)
self._next_number += 1
self.tickets[ticket.number] = ticket
return ticket
def exit(self, ticket_number: int, now: int) -> int:
ticket = self.tickets.pop(ticket_number)
ticket.spot.leave()
return self.fee_policy.fee(ticket.minutes_until(now))
def free_spots(self, size: SpotSize) -> int:
return sum(1 for spot in self.spots if spot.vehicle is None and spot.size is size)
def _find_spot(self, vehicle: Vehicle) -> Spot | None:
return next((spot for spot in self.spots if spot.fits(vehicle)), None)
def _log(self, message: str) -> None:
print(message) Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
That D2 text, rendered, is the diagram below:
A parking lot at the level of detail that suits an interview, drawn from its code
Text description of the diagram
Nine class boxes, each with its public attributes and operations, and eight lines.
- SpotSize, an «enumeration» with the literals SMALL, MEDIUM and LARGE.
- Vehicle {abstract}: attribute +plate: str; operation +size(): SpotSize {abstract}.
- Car and Motorcycle: each has +size(): SpotSize, and each has a generalization line (a solid line with a hollow triangle) to Vehicle.
- Spot: +number: int, +size: SpotSize; +fits(vehicle): bool, +park(vehicle): None, +leave(): Vehicle[0..1].
- Ticket: +number: int, +entered_at: int; +minutes_until(now): int.
- FeePolicy, an «interface» with +fee(minutes): int.
- HourlyFee: +rate: int; +fee(minutes): int. A realization line (dashed, with a hollow triangle) goes from HourlyFee to FeePolicy.
- ParkingLot: +enter(vehicle, now): Ticket, +exit(ticket_number, now): int, +free_spots(size): int.
The other lines: - Spot to Vehicle, an association named vehicle with an arrowhead at Vehicle: a spot holds 0..1 vehicle, and a vehicle is in 0..1 spot. - Ticket to Spot, an association named spot: a ticket refers to exactly 1 spot, and a spot has 0..1 open ticket. - ParkingLot to Spot, a composition named spots with a filled diamond at ParkingLot: 1 lot owns 0..* spots. - ParkingLot to Ticket, a composition named tickets: 1 lot owns 0..* tickets. - ParkingLot to FeePolicy, an association named fee_policy: a lot uses exactly 1 fee policy, which 0..* lots may share.
The three levels differ only in members. All nine boxes and all eight lines appear at every level:
- Names only (0 members). Fine as the first two-minute sketch of which classes exist, but no rule on it can be verified, because you cannot see whether a spot knows its size or who calculates the fee.
- Interview level (21 members). Public operations, the attributes that rules read, and every association as a
line with multiplicities. Each operation can be traced to a requirement (
enter,exit,free_spots), and the multiplicities state the rules: a ticket refers to exactly one spot, and a spot has at most one open ticket. - Everything (38 members). Constructors, the getters
get_plate()andget_number(),__repr__, private helpers such as-find_spot(vehicle), and five references shown twice, once as a line and again as an attribute (spots,tickets,fee_policy,vehicle,spot). None of the 17 extra members tells the reader anything about the rules.
Showing a reference twice is a mistake, not just clutter. In UML an attribute and the end of an association are the same kind of element, a property, so a reference is drawn once: as a line when its type is another class on the diagram, and as an attribute otherwise.
Look at what the generator could not work out alone. Ownership and the far-end multiplicities came from the two
notes in model.py, because a type hint such as spot: Spot says nothing about how many tickets a spot may have.
Those are exactly the decisions an interviewer wants to hear you make, so even a generated diagram needs you to add
them.
Python 3.14 reads annotations later
Python 3.14 evaluates annotations only when something asks for them, so a class can name a class defined further
down the file without quotes. The exercise below relies on it; under Python 3.13 the same file would raise
NameError.
Keep the diagram’s source beside the code
A picture exported from a drawing tool goes stale silently: nobody sees it change when the code does. A diagram
kept as text, in D2, PlantUML or Mermaid, lives in the same repository, changes in the same commit as the code and
shows up in the diff, where a reviewer can see that Ticket gained a link to Vehicle. Generating it from the code,
as draw.py does, goes one step further: running the script again redraws the diagram, and only the decisions that
the code cannot state (ownership, far-end multiplicities) still need a person.
Mistakes that cost marks
- Arrowheads the wrong way. The hollow triangle points at the parent class or the interface, never at the subclass.
- Unlabelled association ends. Without multiplicities, the reader cannot tell a one-to-one link from a one-to-many one.
- Collection types instead of multiplicities.
spots : ArrayList<Spot>ties the diagram to one language; a line toSpotlabelled0..*says the same thing in UML. - Database columns in a domain model.
ticketId : intonSpotis a foreign key. Draw an association toTicketand leave keys to the database design. - Getters, setters, constructors and private helpers. They add members without adding rules.
- One class with a line to every other class. A box named
ParkingManagerat the centre of a star is a god class; spread its operations to the classes whose data they use.
Practise
The exercise gives you a library’s classes in Python. Write their class diagram as data: attributes with visibility, public operations, and every relationship with its multiplicity. The sample tests read the classes and compare.
Exercise · Medium · Python
Write a library's class diagram as data
library_diagram.py holds the classes of a small library: books, reference books that cannot be lent, copies, members, loans and the library itself. Read them without changing them, then write their class diagram as data in DIAGRAM at the bottom of the file. Book and one relationship are done for you.
Classes. DIAGRAM["classes"] has one entry per class of the file, the enumeration Format included, each with two lists:
"attributes": every annotated attribute that is not drawn as a line, written"+isbn: str". A name that starts with one underscore is private by convention: write-and drop the underscore ("-condition: str"). An attribute whose type is an enumeration stays an attribute. For an enumeration, list its literals ("PRINT")."operations": every public method defined in the class itself, without__init__, written with its parameter names and return type:"+borrow(copy, today): Loan". A method that returns a list of copies returnsCopy[0..*].
Relationships. DIAGRAM["relationships"] lists one tuple per line of the diagram, (kind, from_class, to_class, multiplicity):
"generalization"from a subclass to its parent, with""as the multiplicity;"association"for an attribute that refers to another class of the file, or"composition"when the attribute carriesOWNED, from the class that holds the attribute to the class it refers to;- the multiplicity at the
toend comes from the type hint:Copyis"1",Copy | Nonewould be"0..1", and a list or a dictionary of them is"0..*".
A reference stored on both sides, such as a member's loans and a loan's member, is two tuples, one per stored direction. The sample tests read the classes with inspect, so they compare your lists with what the code says, ignoring order and spaces. A failing test tells you how many entries match, are missing or should not be there.
Starter code · library_diagram.py
"""A library's classes, and the class diagram of them that you write as data.
Read the classes; do not change them. Then fill in DIAGRAM at the bottom (the
prompt gives the format). OWNED marks parts that a class creates and keeps.
"""
from enum import Enum
from typing import Annotated
class Owned:
pass
OWNED = Owned()
class Format(Enum):
PRINT = "print"
EBOOK = "ebook"
class Book:
isbn: str
title: str
format: Format
def __init__(self, isbn: str, title: str, format: Format) -> None:
self.isbn = isbn
self.title = title
self.format = format
def loan_days(self) -> int:
return 14
class ReferenceBook(Book):
def loan_days(self) -> int:
return 0
class Copy:
barcode: str
book: Book
_condition: str
def __init__(self, barcode: str, book: Book) -> None:
self.barcode = barcode
self.book = book
self._condition = "good"
def is_available(self, loans: list[Loan]) -> bool:
return all(loan.copy is not self or loan.returned for loan in loans)
def _mark_damaged(self) -> None:
self._condition = "damaged"
class Member:
member_id: str
name: str
loans: list[Loan]
def __init__(self, member_id: str, name: str) -> None:
self.member_id = member_id
self.name = name
self.loans = []
def borrow(self, copy: Copy, today: int) -> Loan:
loan = Loan(copy, self, today + copy.book.loan_days())
self.loans.append(loan)
return loan
class Loan:
copy: Copy
member: Member
due: int
returned: bool
def __init__(self, copy: Copy, member: Member, due: int) -> None:
self.copy = copy
self.member = member
self.due = due
self.returned = False
def is_overdue(self, today: int) -> bool:
return not self.returned and today > self.due
class Library:
copies: Annotated[list[Copy], OWNED]
members: Annotated[dict[str, Member], OWNED]
_opened: bool
def __init__(self) -> None:
self.copies = []
self.members = {}
self._opened = True
def add_copy(self, book: Book, barcode: str) -> Copy:
copy = Copy(barcode, book)
self.copies.append(copy)
return copy
def find(self, isbn: str) -> list[Copy]:
return [c for c in self.copies if c.book.isbn == isbn]
def _audit(self) -> int:
return len(self.copies)
# The class diagram, as data. Book is done for you; add every other class and every relationship.
DIAGRAM = {
"classes": {
"Book": {
"attributes": ["+isbn: str", "+title: str", "+format: Format"],
"operations": ["+loan_days(): int"],
},
},
"relationships": [
("generalization", "ReferenceBook", "Book", ""),
],
} The sample tests · test_library_diagram.py
import enum
import inspect
import types
import typing
from typing import Annotated, get_args, get_origin
import library_diagram as lib
from library_diagram import DIAGRAM
MODEL = [c for c in vars(lib).values() if inspect.isclass(c) and c.__module__ == lib.__name__ and c is not lib.Owned]
KINDS = {"generalization", "realization", "association", "composition"}
def unpack(hint):
extras = ()
if get_origin(hint) is Annotated:
hint, *extras = get_args(hint)
origin, args = get_origin(hint), get_args(hint)
if origin in (list, set, tuple):
return args[0], "0..*", extras
if origin is dict:
return args[1], "0..*", extras
if origin in (typing.Union, types.UnionType) and type(None) in args:
return next(a for a in args if a is not type(None)), "0..1", extras
return hint, "1", extras
def is_link(t):
return t in MODEL and not issubclass(t, enum.Enum)
def vis(name):
return "-" if name.startswith("_") else "+"
def name_of(t):
return getattr(t, "__name__", str(t))
def expected_attributes(c):
if issubclass(c, enum.Enum):
return {m.name for m in c}
return {f"{vis(n)}{n.lstrip('_')}: {name_of(unpack(h)[0])}" for n, h in inspect.get_annotations(c).items() if not is_link(unpack(h)[0])}
def expected_operations(c):
out = set()
for n, fn in vars(c).items():
if inspect.isfunction(fn) and not n.startswith("_") and fn.__module__ == lib.__name__:
params = ", ".join(p for p in inspect.signature(fn).parameters if p != "self")
target, many, _ = unpack(inspect.get_annotations(fn)["return"])
out.add(f"+{n}({params}): {name_of(target)}" + ("" if many == "1" else f"[{many}]"))
return out
def expected_relationships():
out = set()
for c in MODEL:
for base in c.__bases__:
if base in MODEL:
out.add(("generalization", c.__name__, base.__name__, ""))
for n, h in inspect.get_annotations(c).items():
target, many, extras = unpack(h)
if is_link(target):
out.add(("composition" if lib.OWNED in extras else "association", c.__name__, target.__name__, many))
return out
def squash(items):
return {" ".join(str(i).split()) for i in items}
def compare(what, got, want):
got, want = squash(got), squash(want)
missing, extra = len(want - got), len(got - want)
assert not missing and not extra, f"{what}: {len(want & got)} of {len(want)} match, {missing} missing, {extra} that should not be there"
def test_every_class_is_listed():
"""DIAGRAM lists every class of the module, the enumeration included, and nothing else"""
compare("classes", DIAGRAM["classes"].keys(), {c.__name__ for c in MODEL})
def test_attributes_with_visibility():
"""each class lists its attributes with visibility and type, but not the ones drawn as lines"""
for c in MODEL:
compare(f"{c.__name__} attributes", DIAGRAM["classes"].get(c.__name__, {}).get("attributes", []), expected_attributes(c))
def test_public_operations():
"""each class lists its public operations with parameters and return type"""
for c in MODEL:
compare(f"{c.__name__} operations", DIAGRAM["classes"].get(c.__name__, {}).get("operations", []), expected_operations(c))
def test_links_are_not_also_attributes():
"""a reference drawn as a line is not repeated as an attribute"""
for c in MODEL:
roles = {n for n, h in inspect.get_annotations(c).items() if is_link(unpack(h)[0])}
listed = {a.split(":")[0].strip().lstrip("+-#~") for a in DIAGRAM["classes"].get(c.__name__, {}).get("attributes", [])}
assert not roles & listed, f"{c.__name__} lists {sorted(roles & listed)} as attributes, but they are drawn as lines"
def test_relationships_with_multiplicity():
"""every generalization, association and composition, with the multiplicity at its target end"""
rels = DIAGRAM["relationships"]
assert all(len(r) == 4 and r[0] in KINDS for r in rels), "each relationship is (kind, from, to, multiplicity at the 'to' end)"
compare("relationships", rels, expected_relationships())
def test_owned_parts_are_composition():
"""the parts the library creates and keeps are compositions"""
owned = {(r[1], r[2]) for r in DIAGRAM["relationships"] if r[0] == "composition"}
assert owned == {("Library", "Copy"), ("Library", "Member")}, f"compositions found: {sorted(owned)}"
def test_the_classes_still_work():
"""the library's classes behave as given (the exercise changes only DIAGRAM)"""
library = lib.Library()
novel = lib.Book("978-0", "A Novel", lib.Format.PRINT)
atlas = lib.ReferenceBook("978-1", "An Atlas", lib.Format.PRINT)
first = library.add_copy(novel, "C1")
library.add_copy(atlas, "C2")
asha = lib.Member("M1", "Asha")
loan = asha.borrow(first, today=10)
assert loan.due == 24 and atlas.loan_days() == 0
assert not loan.is_overdue(24) and loan.is_overdue(25)
assert not first.is_available(asha.loans)
loan.returned = True
assert first.is_available(asha.loans) and not loan.is_overdue(30)
assert [c.barcode for c in library.find("978-0")] == ["C1"]
assert library.find("978-9") == [] A hint
Go through the classes one at a time. For each annotated attribute, first ask whether its type is another class of the file (not the enumeration): if it is, it becomes a relationship tuple and stays out of "attributes"; if it is not, it is an attribute. Library has two attributes marked OWNED and one private attribute.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
7 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Interview questions
Fresher · What do the symbols +, -, # and ~ mean in a UML class diagram?
They give a member’s visibility, the code that may use it. + is public: any code that can see the class may use
the member. - is private: only the class itself. # is protected: the class and its subclasses. ~ is package
visibility: classes in the same package or module. They map to Java’s public, private, protected and the
default with no modifier. In an interview diagram, most members you show should be +, because the diagram is
about the operations other classes call; a private member earns its place only when a rule depends on it, such as a
private balance that only withdraw() may change.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
You have 45 minutes to design the order flow of a food-delivery app, and the interviewer asks for a class diagram first. What do you put on it, what do you leave off, and how long do you spend on it?
-
Mid
A teammate's diagram gives Order the attribute customerId: int and also draws a line from Order to Customer, while Customer has orders: List<Order>. What is wrong, and how would you fix the diagram and the code it describes?
Key takeaways
- A class box has a name, attributes and operations; a hidden compartment is not an empty one.
- Write attributes as
visibility name : Type [multiplicity]and operations asvisibility name(params) : Type. - Mark interfaces and enumerations with their keywords and abstract classes with italics or
{abstract}, and use stereotypes sparingly. - For an interview, show public operations, the attributes rules read and every association with both multiplicities; leave out getters, constructors and private helpers.
- Draw each reference once: a line to another class on the diagram, or an attribute, never both.
- Keep the diagram’s source as text beside the code, so changes show up in review.
References
- Unified Modeling Language (UML) 2.5.1, formal specification (Object Management Group)
- D2 documentation: UML classes (Terrastruct (D2))
- The Python Tutorial: private variables (Python Software Foundation)
- inspect.get_annotations (Python Software Foundation)
- typing.Annotated (Python Software Foundation)
- annotationlib: deferred evaluation of annotations (Python Software Foundation)
- The Java Tutorials: controlling access to members of a class (Oracle)
- The Go Programming Language Specification: exported identifiers (The Go Authors)
- The Rust Reference: visibility and privacy (The Rust Project)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress