Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Beginner
  • 25 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7 and Node.js 24.21.0
  • By MySmartCoPilot
Free lesson, Pro answers: 25 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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 of ABC that still has an @abstractmethod cannot 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:

A class diagram drawn from the code, at three levels

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)

That D2 text, rendered, is the diagram below:

Class diagram of a parking lot with nine classes, their public members and eight relationships with multiplicities.«enumeration»SpotSize+SMALL+MEDIUM+LARGEVehicle {abstract}+platestr+size()SpotSize {abstract}Car+size()SpotSizeMotorcycle+size()SpotSizeSpot+numberint+sizeSpotSize+fits(vehicle)bool+park(vehicle)None+leave()Vehicle[0..1]Ticket+numberint+entered_atint+minutes_until(now)int«interface»FeePolicy+fee(minutes)intHourlyFee+rateint+fee(minutes)intParkingLot+enter(vehicle, now)Ticket+exit(ticket_number, now)int+free_spots(size)intvehicle0..10..1spot0..11spots10..*tickets10..*fee_policy0..*1

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() and get_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 to Spot labelled 0..* says the same thing in UML.
  • Database columns in a domain model. ticketId : int on Spot is a foreign key. Draw an association to Ticket and 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 ParkingManager at 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 returns Copy[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 carries OWNED, from the class that holds the attribute to the class it refers to;
  • the multiplicity at the to end comes from the type hint: Copy is "1", Copy | None would 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.

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.

  1. Question 1 of 7 Which line declares, in UML notation, a private attribute paidAt of type Instant that may be empty?

    Choose one answer.

    Show the answer to question 1

    Answer: - paidAt : Instant [0..1]

    An attribute is written visibility, name, a colon and the type, then an optional multiplicity in brackets. "-" is private and [0..1] says the value may be missing. The third option is an operation, and the last one is Java syntax, not UML.

  2. Question 2 of 7 What does # in front of a member mean?

    Choose one answer.

    Show the answer to question 2

    Answer: Protected: visible to the class and its subclasses

    The four visibility symbols are + public, - private, # protected and ~ package. Static members are underlined and derived attributes start with a slash.

  3. Question 3 of 7 Spot has a size that is one of the literals of the enumeration SpotSize. How does an interview diagram show it?

    Choose one answer.

    Show the answer to question 3

    Answer: As the attribute size : SpotSize in Spot's box

    An attribute whose type is an enumeration stays an attribute; the enumeration's own box lists its literals. Lines are for references to other classes whose objects have identity and behaviour.

  4. Question 4 of 7 A diagram shows ParkingLot with the attribute spots : Spot[0..*] and also a composition line from ParkingLot to Spot labelled 0..*. What is wrong?

    Choose one answer.

    Show the answer to question 4

    Answer: The same reference is drawn twice; keep the line and drop the attribute

    In UML an attribute and an association end are the same kind of element, a property. When the type is another class on the diagram, the line shows it, with its multiplicities and ownership, so the attribute only repeats it.

  5. Question 5 of 7 Which members belong on a parking lot's class diagram in a 45-minute low-level design round?

    Choose every answer that is right.

    Show the answer to question 5

    Answer:

    • + fits(vehicle) : bool on Spot, which the parking rule uses
    • + enter(vehicle, now) : Ticket

    Show public operations that requirements need and the members rules depend on. Getters, logging helpers and string conversions add lines to read without adding any rule an interviewer can check.

  6. Question 6 of 7 draw.py generates the class diagram from model.py. Which information did it need the extra Annotated notes for?

    Choose one answer.

    Show the answer to question 6

    Answer: Which parts a class owns, and the multiplicity at the far end of each association

    Type hints, base classes and signatures are in the code already. Ownership and how many objects sit at the other end of a link (a spot has at most one open ticket) are design rules the code does not state, so the model adds them with OWNED and Far.

  7. Question 7 of 7 How do you mark an abstract class when you draw by hand on a whiteboard?

    Choose one answer.

    Show the answer to question 7

    Answer: Write {abstract} after or under its name

    UML shows an abstract class with its name in italics, which is hard to see in handwriting, so {abstract} next to the name is the clearer choice. Underlining marks static members, and «interface» is for interfaces.

Python Online Compiler Paste model.py and draw.py into the runner, add a class to the model and see the counts change.

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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

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 as visibility 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.