UML for interviews
The UML that low level design interviews use, from class boxes and six arrows to sequence and state diagrams, with one checkout model in five languages.
- Read first
- Object modeling
- Patterns
- State
UML 2.5 defines fourteen kinds of diagram, in a specification several hundred pages long. A low level design round uses a tiny slice of it. The diagram there is a sketch. It settles three things before code depends on them: which classes exist, who owns whom, and where the extension points are. It takes about two minutes per stage. And it is wrong in some detail almost every time. That’s fine, because it is cheap to change.
Five symbols carry nearly every interview diagram:
- The class box.
- The plain arrow, for “knows about”.
- The filled diamond, for “owns”.
- The hollow triangle, for “is a” (a solid line for extends, a dashed one for implements).
- Multiplicity numbers on the ends.
Two more are worth reading when someone else draws them: the dashed arrow for a short-lived
dependency, and the hollow diamond for aggregation. Each symbol becomes a specific shape in code: a
field, a constructor parameter, an extends, or a part no one else can reach. This page builds one
checkout model, from bare boxes to a sequence diagram, with the code each arrow turns into.
The notation
Section titled “The notation”The class box
Section titled “The class box”A class is a box with three compartments: the name, the attributes, the operations. Marks in front
of a member give its visibility. A few more say whether it is static or abstract. A stereotype
in guillemets above the name says what kind of thing the box is: <<interface>>, <<abstract>>,
<<enumeration>>, or one the team invents, like <<value>> for an immutable value object.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
Static members are underlined ($ in Mermaid) and abstract ones are in italics (* in
Mermaid). Each language spells the markings its own way, and two of them have no direct
equivalent in some languages:
| Marking | Means | Java | Python | C# | Go | TypeScript |
|---|---|---|---|---|---|---|
+ |
public | public |
plain name | public |
Capitalized name | default, public |
- |
private | private |
_name (convention), __name |
private |
lowercase name (package-wide) | private or #x |
# |
protected | protected |
_name (convention) |
protected |
none | protected |
~ |
package | no modifier | none | internal (assembly) |
lowercase name | not exported |
underlined, $ |
static, one per class | static |
class attribute, @staticmethod |
static |
package-level var or func | static |
italics, * |
abstract | abstract |
ABC + @abstractmethod |
abstract |
none: use an interface | abstract |
<<interface>> |
a contract with no state | interface |
Protocol or ABC |
interface |
interface, satisfied implicitly |
interface |
What goes in the box is a choice. Put in the fields that hold state the use cases depend on, and the
methods that carry the use cases. Leave out getters and setters, constructors, equals, toString,
and types that are obvious from the name. A box with three members that matter reads in a second.
A box with fifteen hides the three.
The relationships
Section titled “The relationships”There are six relationships between classes, and they split into three families: knows about (association, aggregation, composition), is a (inheritance, realization) and uses briefly (dependency).
| Relationship | Symbol | Mermaid | Means | In code |
|---|---|---|---|---|
| Association | solid line, open arrowhead | LineItem --> Product |
holds a reference and can reach it | a field, often set through the constructor |
| Aggregation | hollow diamond on the whole | Catalog o-- Product |
a whole made of parts that live on without it | a field holding a collection of objects made elsewhere |
| Composition | filled diamond on the whole | Order *-- LineItem |
owns its parts: one owner, same lifetime | parts created inside the owner, never shared or handed out |
| Inheritance | solid line, hollow triangle on the parent | Discount <|-- PercentOff |
is a, inheriting fields and code | extends, :, a Python base class |
| Realization | dashed line, hollow triangle on the interface | PaymentGateway <|.. FakeCardGateway |
fulfills a contract | implements, : on an interface, or Go’s implicit match |
| Dependency | dashed line, open arrowhead | CheckoutService ..> Order |
uses it briefly, and changes when it changes | a parameter, a local variable, a return type, a static call |
Three pairs are where the confusion lives:
- Association or dependency? Ask whether the class keeps the other one in a field. Kept means association, a solid line. Seen only during a call means dependency, a dashed line. Ask the same question about constructor injection. A collaborator passed to the constructor, and stored in a field, is an association, not a dependency. That’s true even though the technique is called dependency injection.
- Composition or aggregation? Both say “whole and parts”. Composition adds two promises. A part belongs to one whole at a time. And it goes when the whole goes. Aggregation promises neither. That’s why the UML 2.5 specification leaves its exact meaning to the modeler. And it’s why Martin Fowler’s UML Distilled recommends not using it. In code, aggregation and plain association are the same: a field of references.
- Inheritance or realization? Inheritance takes fields and code from a class. Realization takes only a promise from an interface. The solid line versus the dashed line is the whole difference. And it matters. A subclass breaks when its parent’s code changes. An implementer breaks only when the contract does.
One rule settles every direction question: the arrowhead or triangle points at the class that is
known, used or inherited from, and the diamond sits on the owner. PercentOff points at
Discount, because the child knows its parent, and the parent knows nothing of its children.
Multiplicity and navigability
Section titled “Multiplicity and navigability”A number at each end of a line counts the objects at that end, for one object at the other end. So
it sits next to the class it counts. Take Order "1" *-- "1..*" LineItem. The 1..* beside
LineItem says one order has one or more lines. The 1 beside Order says each line belongs to
exactly one order.
| Multiplicity | Means | Becomes in code |
|---|---|---|
1 |
exactly one | a field set in the constructor, never null |
0..1 |
optional | a nullable field, Optional, None, a nil pointer or interface |
* or 0..* |
any number | a list, set or map, empty to start |
1..* |
at least one | a collection plus a check that refuses to create or finish without one |
2..4 |
a fixed range | a collection plus a range check, like players at a table |
Navigability is the arrowhead on an association. It shows which side can reach the other.
LineItem --> Product means a line has a field pointing at its product. A product has no field
listing its lines. A line with no arrowheads usually means “not decided yet”. A two-way association
means two fields that have to be kept in step, and that’s a source of bugs. Add the second
direction only when a use case needs to walk it often. Otherwise, answer that question with a
lookup.
How this site’s diagrams map to UML
Section titled “How this site’s diagrams map to UML”The class diagrams on this site are standard UML drawn with Mermaid, with one addition: color marks the role a class plays. Color is not part of UML, so on paper you would write the role as a stereotype instead. The key sits under each diagram.
| Color on this site | Role | The UML you would write |
|---|---|---|
| Purple | Facade: the entry point, coordinates | a plain class, sometimes <<service>> |
| Blue | Entity: has identity, changes over time | a plain class, sometimes <<entity>> |
| Teal | Value object: immutable, equal by its fields | <<value>> |
| Gray | Enum | <<enumeration>> |
| Amber, dashed border | Extension point: an interface or abstract class | <<interface>> or <<abstract>>, italic name |
| Orange | Implementation of an extension point | the class at the tail of a realization or inheritance arrow |
| Any color, thick border | New in this step of a staged design | nothing: it is a teaching aid |
The arrows follow Mermaid’s notation, which is UML’s. -->, o--, *--, <|--, <|.. and ..>
are the six relationships in the table above. Quoted numbers next to a class name are
multiplicities.
Worked example: an online checkout
Section titled “Worked example: an online checkout”The requirements are short. A customer checks out a cart of products from the store’s catalog. A discount may apply. The order is charged through a payment gateway. If the charge is approved, the order is paid, and a receipt email goes out. If it is declined, the order is cancelled. Paid orders are later shipped. The model grows in four steps, the way it would in a round. First boxes, then relationships, then numbers, then one use case as a sequence.
Stage 1: the boxes
Section titled “Stage 1: the boxes”Start from the nouns that carry rules: an order with lines, products in a catalog, discounts, a payment gateway, and a service that runs checkout. Draw them as boxes with only the members the use cases need, and no lines yet.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
Three decisions are already visible. LineItem copies the unit price when it is made, so a
price change tomorrow does not rewrite today’s order. Discount is abstract because there is no
plain discount, only kinds of discount. PaymentGateway is an interface because checkout must
not care which card network is behind it. The customer, the warehouse and the email system
are left out: no use case needs them as classes yet.
Before you draw any lines, which classes do you need, and what is on each?
Whether the boxes come from the requirements and carry only the members that do work. A strong answer names the price copied onto the line and why. The red flag is a box per noun in the prompt with every getter listed.
Stage 2: the relationships
Section titled “Stage 2: the relationships”Now connect them. For each pair, ask the questions from the table. Does one keep the other in a field? Did it create it? Can the part outlive it? Is one a kind of the other?
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
Each arrow, and the reason for it:
Order *-- LineItem, composition. Order creates every line insideaddLine, never accepts one from outside, and never hands out its list. A line belongs to one order and has no meaning without it.Catalog o-- Product, aggregation. The catalog holds products it did not create. They come from the database, and the same product can sit in a seasonal catalog too. A plain association arrow would say the same thing in code, and many teams would draw that instead.LineItem --> Product, association. A line keeps a reference to its product. The arrowhead says the product does not know its lines.CheckoutService --> Catalogand--> PaymentGateway, associations. Both arrive through the constructor and stay in fields for the service’s whole life. The service did not create them and does not own them, so no diamond.CheckoutService ..> Order, dependency. The service builds an order and returns it without keeping it. A label likecreatessays why the dashed line is there.Discount <|-- PercentOffandPaymentGateway <|.. FakeCardGateway. The first extends an abstract class, and inherits its label anddescribe. The second only promises achargemethod. That’s a solid triangle line versus a dashed one.
Here is the model in code. The comments name the arrow each part implements:
import java.util.ArrayList;import java.util.HashMap;import java.util.List;import java.util.Map;
// An entity the store sells. It knows nothing about orders or catalogs: every arrow that// touches Product points at it, so Product never changes when they do.class Product { private final String sku; private final String name; private int priceCents;
Product(String sku, String name, int priceCents) { this.sku = sku; this.name = name; this.priceCents = priceCents; }
String sku() { return sku; } String name() { return name; } int priceCents() { return priceCents; } void changePrice(int cents) { priceCents = cents; }}
// Catalog o-- Product (aggregation). Products are made elsewhere and handed in, so the// catalog holds references it did not create. In code this is the same as an association:// a field. The hollow diamond only adds "these are its members, but they live on without it".class Catalog { private final Map<String, Product> bySku = new HashMap<>();
void add(Product p) { bySku.put(p.sku(), p); }
Product find(String sku) { Product p = bySku.get(sku); if (p == null) throw new IllegalArgumentException("unknown sku " + sku); return p; }}
// LineItem --> Product (association, one way). The line holds a reference to its product;// the product has no list of lines. The price is copied when the line is made, so a later// price change does not rewrite an order that was already placed.final class LineItem { private final Product product; private final int qty; private final int unitPriceCents;
// Package-private constructor: code outside the package cannot make a line. Inside it, // only Order calls this, by convention. LineItem(Product product, int qty) { this.product = product; this.qty = qty; this.unitPriceCents = product.priceCents(); }
Product product() { return product; } int subtotalCents() { return unitPriceCents * qty; }}
// Discount <|-- PercentOff (generalization). Abstract, so there is no plain "discount":// in UML the name is in italics and the abstract method is marked the same way.abstract class Discount { protected final String label; // # protected: subclasses build on it, callers cannot see it
protected Discount(String label) { this.label = label; }
public abstract int amountOffCents(int subtotalCents);
public String describe() { return label; }}
class PercentOff extends Discount { private final int percent;
PercentOff(int percent) { super(percent + "% off"); this.percent = percent; }
@Override public int amountOffCents(int subtotalCents) { return subtotalCents * percent / 100; }}
// Order *-- LineItem (composition). Order creates every line inside addLine, never// accepts a LineItem from outside and never hands out its own list. Java cannot enforce// "dies with the whole": the garbage collector frees the lines once nothing refers to them,// and a caller could keep one it read from lines(). So composition here is a convention the// class keeps, and LineItem being immutable makes a kept reference harmless.class Order { private static int nextId = 1; // static: underlined in UML, one counter for the class private final int id; private final List<LineItem> lines = new ArrayList<>(); private Discount discount; // Order --> Discount, and may be null
Order() { id = nextId++; }
int id() { return id; }
void addLine(Product product, int qty) { lines.add(new LineItem(product, qty)); }
void apply(Discount d) { discount = d; }
// A read-only copy: callers can look at the parts but cannot change the order's list. List<LineItem> lines() { return List.copyOf(lines); }
int totalCents() { int subtotal = lines.stream().mapToInt(LineItem::subtotalCents).sum(); return discount == null ? subtotal : subtotal - discount.amountOffCents(subtotal); }}
// <<interface>>: the dashed box on this site. Checkout depends on this, never on a card network.interface PaymentGateway { boolean charge(int orderId, int cents);}
// FakeCardGateway ..|> PaymentGateway (realization): the dashed line with a hollow triangle.class FakeCardGateway implements PaymentGateway { private final int limitCents;
FakeCardGateway(int limitCents) { this.limitCents = limitCents; }
public boolean charge(int orderId, int cents) { return cents <= limitCents; }}
// CheckoutService --> Catalog and --> PaymentGateway (associations). Both arrive through the// constructor: built outside, passed in, kept in fields. The service uses them for its whole// life but does not own them, so the arrow is plain, not a diamond.// CheckoutService ..> Order (dependency, "creates"): it builds an order and hands it back.class CheckoutService { private final Catalog catalog; private final PaymentGateway gateway;
CheckoutService(Catalog catalog, PaymentGateway gateway) { this.catalog = catalog; this.gateway = gateway; }
Order placeOrder(Map<String, Integer> cart, Discount discount) { Order order = new Order(); cart.forEach((sku, qty) -> order.addLine(catalog.find(sku), qty)); if (discount != null) order.apply(discount); if (!gateway.charge(order.id(), order.totalCents())) throw new IllegalStateException("card declined"); return order; // not stored in a field: that is why the arrow is dashed }}
class Main { public static void main(String[] args) { Product mug = new Product("MUG-1", "Mug", 1200); Product tea = new Product("TEA-9", "Green tea", 800); Catalog catalog = new Catalog(); catalog.add(mug); catalog.add(tea);
// main wires the associations: the service gets what it uses, it does not build it. CheckoutService checkout = new CheckoutService(catalog, new FakeCardGateway(5_000)); Map<String, Integer> cart = new java.util.LinkedHashMap<>(); cart.put("MUG-1", 2); cart.put("TEA-9", 1); Order order = checkout.placeOrder(cart, new PercentOff(10)); System.out.println("order " + order.id() + ": " + order.totalCents()); // order 1: 2880
// Association: the line and the catalog point at the same Product object. System.out.println(order.lines().get(0).product() == mug); // true // The copied price keeps the placed order stable when the product changes. mug.changePrice(1500); System.out.println("after price change: " + order.totalCents()); // 2880 // Aggregation: drop the order and the product is still in the catalog. order = null; System.out.println(catalog.find("MUG-1").name()); // Mug }}from abc import ABC, abstractmethodfrom itertools import countfrom typing import Protocol
# An entity the store sells. It knows nothing about orders or catalogs: every arrow that# touches Product points at it, so Product never changes when they do.class Product: def __init__(self, sku: str, name: str, price_cents: int) -> None: self.sku = sku self.name = name self.price_cents = price_cents
# Catalog o-- Product (aggregation). Products are made elsewhere and handed in, so the# catalog holds references it did not create. In code this is the same as an association:# a field. The hollow diamond only adds "these are its members, but they live on without it".class Catalog: def __init__(self) -> None: self._by_sku: dict[str, Product] = {}
def add(self, p: Product) -> None: self._by_sku[p.sku] = p
def find(self, sku: str) -> Product: if sku not in self._by_sku: raise KeyError(f"unknown sku {sku}") return self._by_sku[sku]
# LineItem --> Product (association, one way). The line holds a reference to its product;# the product has no list of lines. The price is copied when the line is made, so a later# price change does not rewrite an order that was already placed.class LineItem: def __init__(self, product: Product, qty: int) -> None: self.product = product self._qty = qty self._unit_price_cents = product.price_cents
def subtotal_cents(self) -> int: return self._unit_price_cents * self._qty
# Discount <|-- PercentOff (generalization). Abstract, so there is no plain "discount":# in UML the name is in italics and the abstract method is marked the same way.class Discount(ABC): def __init__(self, label: str) -> None: self._label = label # "#" protected: by convention, for subclasses only
@abstractmethod def amount_off_cents(self, subtotal_cents: int) -> int: ...
def describe(self) -> str: return self._label
class PercentOff(Discount): def __init__(self, percent: int) -> None: super().__init__(f"{percent}% off") self._percent = percent
def amount_off_cents(self, subtotal_cents: int) -> int: return subtotal_cents * self._percent // 100
# Order *-- LineItem (composition). Order creates every line inside add_line, never# accepts a LineItem from outside and never hands out its own list. Python cannot enforce# "dies with the whole": the garbage collector frees the lines once nothing refers to them,# and a caller could keep one it read from lines. So composition here is a convention the# class keeps, and LineItem being immutable makes a kept reference harmless.class Order: _ids = count(1) # class attribute: "static", underlined in UML
def __init__(self) -> None: self.id = next(Order._ids) self._lines: list[LineItem] = [] self._discount: Discount | None = None # Order --> Discount, and may be None
def add_line(self, product: Product, qty: int) -> None: self._lines.append(LineItem(product, qty))
def apply(self, d: Discount) -> None: self._discount = d
# A tuple: callers can look at the parts but cannot change the list. @property def lines(self) -> tuple[LineItem, ...]: return tuple(self._lines)
def total_cents(self) -> int: subtotal = sum(line.subtotal_cents() for line in self._lines) off = self._discount.amount_off_cents(subtotal) if self._discount else 0 return subtotal - off
# <<interface>>: the dashed box on this site. A Protocol, so any class with a matching# charge method satisfies it without naming it.class PaymentGateway(Protocol): def charge(self, order_id: int, cents: int) -> bool: ...
# FakeCardGateway ..|> PaymentGateway (realization). Python checks the shape, not a keyword,# but the diagram still draws the dashed line with a hollow triangle.class FakeCardGateway: def __init__(self, limit_cents: int) -> None: self._limit_cents = limit_cents
def charge(self, order_id: int, cents: int) -> bool: return cents <= self._limit_cents
# CheckoutService --> Catalog and --> PaymentGateway (associations). Both arrive through the# constructor: built outside, passed in, kept in fields. The service uses them for its whole# life but does not own them, so the arrow is plain, not a diamond.# CheckoutService ..> Order (dependency, "creates"): it builds an order and hands it back.class CheckoutService: def __init__(self, catalog: Catalog, gateway: PaymentGateway) -> None: self._catalog = catalog self._gateway = gateway
def place_order(self, cart: list[tuple[str, int]], discount: Discount | None) -> Order: order = Order() for sku, qty in cart: order.add_line(self._catalog.find(sku), qty) if discount: order.apply(discount) if not self._gateway.charge(order.id, order.total_cents()): raise RuntimeError("card declined") return order # not stored in a field: that is why the arrow is dashed
if __name__ == "__main__": mug = Product("MUG-1", "Mug", 1200) tea = Product("TEA-9", "Green tea", 800) catalog = Catalog() catalog.add(mug) catalog.add(tea)
# main wires the associations: the service gets what it uses, it does not build it. checkout = CheckoutService(catalog, FakeCardGateway(5_000)) order = checkout.place_order([("MUG-1", 2), ("TEA-9", 1)], PercentOff(10)) print(f"order {order.id}: {order.total_cents()}") # order 1: 2880
# Association: the line and the catalog point at the same Product object. print(order.lines[0].product is mug) # True # The copied price keeps the placed order stable when the product changes. mug.price_cents = 1500 print(f"after price change: {order.total_cents()}") # 2880 # Aggregation: drop the order and the product is still in the catalog. del order print(catalog.find("MUG-1").name) # Mugusing System;using System.Collections.Generic;using System.Linq;
// An entity the store sells. It knows nothing about orders or catalogs: every arrow that// touches Product points at it, so Product never changes when they do.class Product{ public Product(string sku, string name, int priceCents) { Sku = sku; Name = name; PriceCents = priceCents; }
public string Sku { get; } public string Name { get; } public int PriceCents { get; set; }}
// Catalog o-- Product (aggregation). Products are made elsewhere and handed in, so the// catalog holds references it did not create. In code this is the same as an association:// a field. The hollow diamond only adds "these are its members, but they live on without it".class Catalog{ private readonly Dictionary<string, Product> _bySku = new();
public void Add(Product p) => _bySku[p.Sku] = p;
public Product Find(string sku) => _bySku.TryGetValue(sku, out var p) ? p : throw new ArgumentException($"unknown sku {sku}");}
// LineItem --> Product (association, one way). The line holds a reference to its product;// the product has no list of lines. The price is copied when the line is made, so a later// price change does not rewrite an order that was already placed.sealed class LineItem{ private readonly int _qty; private readonly int _unitPriceCents;
// internal (~ package in UML): only code in this assembly, which includes Order, creates lines. internal LineItem(Product product, int qty) { Product = product; _qty = qty; _unitPriceCents = product.PriceCents; }
public Product Product { get; } public int SubtotalCents() => _unitPriceCents * _qty;}
// Discount <|-- PercentOff (generalization). Abstract, so there is no plain "discount":// in UML the name is in italics and the abstract method is marked the same way.abstract class Discount{ protected readonly string Label; // # protected: subclasses build on it, callers cannot see it
protected Discount(string label) => Label = label;
public abstract int AmountOffCents(int subtotalCents);
public string Describe() => Label;}
class PercentOff : Discount{ private readonly int _percent;
public PercentOff(int percent) : base($"{percent}% off") => _percent = percent;
public override int AmountOffCents(int subtotalCents) => subtotalCents * _percent / 100;}
// Order *-- LineItem (composition). Order creates every line inside AddLine, never// accepts a LineItem from outside and never hands out its own list. C# cannot enforce// "dies with the whole": the garbage collector frees the lines once nothing refers to them,// and a caller could keep one it read from Lines. So composition here is a convention the// class keeps, and LineItem being immutable makes a kept reference harmless.class Order{ private static int _nextId = 1; // static: underlined in UML, one counter for the class private readonly List<LineItem> _lines = new(); private Discount? _discount; // Order --> Discount, and may be null
public Order() => Id = _nextId++;
public int Id { get; }
public void AddLine(Product product, int qty) => _lines.Add(new LineItem(product, qty));
public void Apply(Discount d) => _discount = d;
// A read-only view: callers can look at the parts but cannot change the list. public IReadOnlyList<LineItem> Lines => _lines.AsReadOnly();
public int TotalCents() { int subtotal = _lines.Sum(l => l.SubtotalCents()); return subtotal - (_discount?.AmountOffCents(subtotal) ?? 0); }}
// <<interface>>: the dashed box on this site. Checkout depends on this, never on a card network.interface IPaymentGateway{ bool Charge(int orderId, int cents);}
// FakeCardGateway ..|> IPaymentGateway (realization): the dashed line with a hollow triangle.class FakeCardGateway : IPaymentGateway{ private readonly int _limitCents;
public FakeCardGateway(int limitCents) => _limitCents = limitCents;
public bool Charge(int orderId, int cents) => cents <= _limitCents;}
// CheckoutService --> Catalog and --> IPaymentGateway (associations). Both arrive through the// constructor: built outside, passed in, kept in fields. The service uses them for its whole// life but does not own them, so the arrow is plain, not a diamond.// CheckoutService ..> Order (dependency, "creates"): it builds an order and hands it back.class CheckoutService{ private readonly Catalog _catalog; private readonly IPaymentGateway _gateway;
public CheckoutService(Catalog catalog, IPaymentGateway gateway) { _catalog = catalog; _gateway = gateway; }
public Order PlaceOrder(IEnumerable<(string Sku, int Qty)> cart, Discount? discount) { var order = new Order(); foreach (var (sku, qty) in cart) order.AddLine(_catalog.Find(sku), qty); if (discount != null) order.Apply(discount); if (!_gateway.Charge(order.Id, order.TotalCents())) throw new InvalidOperationException("card declined"); return order; // not stored in a field: that is why the arrow is dashed }}
static class Program{ static void Main() { var mug = new Product("MUG-1", "Mug", 1200); var tea = new Product("TEA-9", "Green tea", 800); var catalog = new Catalog(); catalog.Add(mug); catalog.Add(tea);
// Main wires the associations: the service gets what it uses, it does not build it. var checkout = new CheckoutService(catalog, new FakeCardGateway(5_000)); Order? order = checkout.PlaceOrder(new[] { ("MUG-1", 2), ("TEA-9", 1) }, new PercentOff(10)); Console.WriteLine($"order {order.Id}: {order.TotalCents()}"); // order 1: 2880
// Association: the line and the catalog point at the same Product object. Console.WriteLine(ReferenceEquals(order.Lines[0].Product, mug)); // True // The copied price keeps the placed order stable when the product changes. mug.PriceCents = 1500; Console.WriteLine($"after price change: {order.TotalCents()}"); // 2880 // Aggregation: drop the order and the product is still in the catalog. order = null; Console.WriteLine(catalog.Find("MUG-1").Name); // Mug }}package main
import ( "errors" "fmt")
// Product is an entity the store sells. It knows nothing about orders or catalogs: every// arrow that touches Product points at it, so Product never changes when they do.type Product struct { SKU string Name string PriceCents int}
// Catalog o-- Product (aggregation). Products are made elsewhere and handed in, so the// catalog holds pointers it did not create. In code this is the same as an association:// a field. The hollow diamond only adds "these are its members, but they live on without it".type Catalog struct { bySKU map[string]*Product}
func NewCatalog() *Catalog { return &Catalog{bySKU: map[string]*Product{}} }
func (c *Catalog) Add(p *Product) { c.bySKU[p.SKU] = p }
func (c *Catalog) Find(sku string) (*Product, error) { p, ok := c.bySKU[sku] if !ok { return nil, fmt.Errorf("unknown sku %s", sku) } return p, nil}
// LineItem --> Product (association, one way). The line holds a pointer to its product;// the product has no list of lines. The price is copied when the line is made, so a later// price change does not rewrite an order that was already placed.// Lowercase fields: only code in this package, which includes Order, can build or change one.type LineItem struct { product *Product qty int unitPriceCents int}
func (l LineItem) Product() *Product { return l.product }func (l LineItem) SubtotalCents() int { return l.unitPriceCents * l.qty }
// Discount <|-- PercentOff (generalization). Go has no subclassing, so "is a discount"// becomes an interface that PercentOff satisfies. The diagram still draws the solid line and// hollow triangle: the relationship is the same, the language spells it differently.type Discount interface { AmountOffCents(subtotalCents int) int Describe() string}
// baseDiscount holds what the abstract class shared. Embedding it gives PercentOff the// Describe method, but embedding is composition with forwarding, not inheritance:// a PercentOff cannot be passed where a baseDiscount is expected.type baseDiscount struct{ label string }
func (b baseDiscount) Describe() string { return b.label }
type PercentOff struct { baseDiscount percent int}
func NewPercentOff(percent int) PercentOff { return PercentOff{baseDiscount{fmt.Sprintf("%d%% off", percent)}, percent}}
func (p PercentOff) AmountOffCents(subtotalCents int) int { return subtotalCents * p.percent / 100 }
// nextOrderID is package level: Go's version of a static field, underlined in UML.var nextOrderID = 1
// Order *-- LineItem (composition). Order creates every line inside AddLine and stores the// lines by value in an unexported slice, and Lines hands out copies, so no other code holds// the order's own lines. Go cannot enforce "dies with the whole": the garbage collector frees// the slice once the order is unreachable. Composition is a convention the type keeps.type Order struct { ID int lines []LineItem discount Discount // Order --> Discount, and may be nil}
func NewOrder() *Order { o := &Order{ID: nextOrderID} nextOrderID++ return o}
func (o *Order) AddLine(p *Product, qty int) { o.lines = append(o.lines, LineItem{product: p, qty: qty, unitPriceCents: p.PriceCents})}
func (o *Order) Apply(d Discount) { o.discount = d }
// Lines returns a copy: callers can look at the parts but cannot change the order's slice.func (o *Order) Lines() []LineItem { return append([]LineItem(nil), o.lines...) }
func (o *Order) TotalCents() int { subtotal := 0 for _, l := range o.lines { subtotal += l.SubtotalCents() } if o.discount == nil { return subtotal } return subtotal - o.discount.AmountOffCents(subtotal)}
// PaymentGateway is <<interface>>: the dashed box on this site.type PaymentGateway interface { Charge(orderID, cents int) bool}
// FakeCardGateway ..|> PaymentGateway (realization). Go needs no "implements": having the// method is enough. The diagram still draws the dashed line with a hollow triangle.type FakeCardGateway struct{ LimitCents int }
func (g FakeCardGateway) Charge(orderID, cents int) bool { return cents <= g.LimitCents }
// CartEntry is the input: one SKU and how many.type CartEntry struct { SKU string Qty int}
// CheckoutService --> Catalog and --> PaymentGateway (associations). Both arrive through the// constructor: built outside, passed in, kept in fields. The service uses them for its whole// life but does not own them, so the arrow is plain, not a diamond.// CheckoutService ..> Order (dependency, "creates"): it builds an order and hands it back.type CheckoutService struct { catalog *Catalog gateway PaymentGateway}
func NewCheckoutService(c *Catalog, g PaymentGateway) *CheckoutService { return &CheckoutService{catalog: c, gateway: g}}
func (s *CheckoutService) PlaceOrder(cart []CartEntry, discount Discount) (*Order, error) { order := NewOrder() for _, e := range cart { p, err := s.catalog.Find(e.SKU) if err != nil { return nil, err } order.AddLine(p, e.Qty) } if discount != nil { order.Apply(discount) } if !s.gateway.Charge(order.ID, order.TotalCents()) { return nil, errors.New("card declined") } return order, nil // not stored in a field: that is why the arrow is dashed}
func main() { mug := &Product{"MUG-1", "Mug", 1200} tea := &Product{"TEA-9", "Green tea", 800} catalog := NewCatalog() catalog.Add(mug) catalog.Add(tea)
// main wires the associations: the service gets what it uses, it does not build it. checkout := NewCheckoutService(catalog, FakeCardGateway{LimitCents: 5_000}) order, err := checkout.PlaceOrder([]CartEntry{{"MUG-1", 2}, {"TEA-9", 1}}, NewPercentOff(10)) if err != nil { panic(err) } fmt.Printf("order %d: %d\n", order.ID, order.TotalCents()) // order 1: 2880
// Association: the line and the catalog point at the same Product. fmt.Println(order.Lines()[0].Product() == mug) // true // The copied price keeps the placed order stable when the product changes. mug.PriceCents = 1500 fmt.Printf("after price change: %d\n", order.TotalCents()) // 2880 // Aggregation: drop the order and the product is still in the catalog. order = nil p, _ := catalog.Find("MUG-1") fmt.Println(p.Name) // Mug}// An entity the store sells. It knows nothing about orders or catalogs: every arrow that// touches Product points at it, so Product never changes when they do.class Product { constructor( readonly sku: string, readonly name: string, public priceCents: number, ) {}}
// Catalog o-- Product (aggregation). Products are made elsewhere and handed in, so the// catalog holds references it did not create. In code this is the same as an association:// a field. The hollow diamond only adds "these are its members, but they live on without it".class Catalog { private readonly bySku = new Map<string, Product>();
add(p: Product): void { this.bySku.set(p.sku, p); }
find(sku: string): Product { const p = this.bySku.get(sku); if (!p) throw new Error(`unknown sku ${sku}`); return p; }}
// LineItem --> Product (association, one way). The line holds a reference to its product;// the product has no list of lines. The price is copied when the line is made, so a later// price change does not rewrite an order that was already placed.class LineItem { private readonly unitPriceCents: number;
constructor( readonly product: Product, private readonly qty: number, ) { this.unitPriceCents = product.priceCents; }
subtotalCents(): number { return this.unitPriceCents * this.qty; }}
// Discount <|-- PercentOff (generalization). Abstract, so there is no plain "discount":// in UML the name is in italics and the abstract method is marked the same way.abstract class Discount { // # protected: subclasses build on it, callers cannot see it. protected constructor(protected readonly label: string) {}
abstract amountOffCents(subtotalCents: number): number;
describe(): string { return this.label; }}
class PercentOff extends Discount { constructor(private readonly percent: number) { super(`${percent}% off`); }
amountOffCents(subtotalCents: number): number { return Math.floor((subtotalCents * this.percent) / 100); }}
// Order *-- LineItem (composition). Order creates every line inside addLine, never// accepts a LineItem from outside and never hands out its own list. TypeScript cannot enforce// "dies with the whole": the garbage collector frees the lines once nothing refers to them,// and a caller could keep one it read from lines. So composition here is a convention the// class keeps, and LineItem being immutable makes a kept reference harmless.class Order { private static nextId = 1; // static: underlined in UML, one counter for the class readonly id = Order.nextId++; private readonly items: LineItem[] = []; private discount?: Discount; // Order --> Discount, and may be missing
addLine(product: Product, qty: number): void { this.items.push(new LineItem(product, qty)); }
apply(d: Discount): void { this.discount = d; }
// A read-only view: callers can look at the parts but cannot change the list. get lines(): readonly LineItem[] { return this.items; }
totalCents(): number { const subtotal = this.items.reduce((sum, l) => sum + l.subtotalCents(), 0); return subtotal - (this.discount?.amountOffCents(subtotal) ?? 0); }}
// <<interface>>: the dashed box on this site. Checkout depends on this, never on a card network.interface PaymentGateway { charge(orderId: number, cents: number): boolean;}
// FakeCardGateway ..|> PaymentGateway (realization). TypeScript checks the shape, so// "implements" is optional, but writing it makes the dashed arrow visible in the code.class FakeCardGateway implements PaymentGateway { constructor(private readonly limitCents: number) {}
charge(orderId: number, cents: number): boolean { return cents <= this.limitCents; }}
// CheckoutService --> Catalog and --> PaymentGateway (associations). Both arrive through the// constructor: built outside, passed in, kept in fields. The service uses them for its whole// life but does not own them, so the arrow is plain, not a diamond.// CheckoutService ..> Order (dependency, "creates"): it builds an order and hands it back.class CheckoutService { constructor( private readonly catalog: Catalog, private readonly gateway: PaymentGateway, ) {}
placeOrder(cart: Array<[string, number]>, discount?: Discount): Order { const order = new Order(); for (const [sku, qty] of cart) order.addLine(this.catalog.find(sku), qty); if (discount) order.apply(discount); if (!this.gateway.charge(order.id, order.totalCents())) throw new Error("card declined"); return order; // not stored in a field: that is why the arrow is dashed }}
const mug = new Product("MUG-1", "Mug", 1200);const tea = new Product("TEA-9", "Green tea", 800);const catalog = new Catalog();catalog.add(mug);catalog.add(tea);
// The wiring code builds the associations: the service gets what it uses, it does not build it.const checkout = new CheckoutService(catalog, new FakeCardGateway(5_000));let order: Order | undefined = checkout.placeOrder( [ ["MUG-1", 2], ["TEA-9", 1], ], new PercentOff(10),);console.log(`order ${order.id}: ${order.totalCents()}`); // order 1: 2880
// Association: the line and the catalog point at the same Product object.console.log(order.lines[0].product === mug); // true// The copied price keeps the placed order stable when the product changes.mug.priceCents = 1500;console.log(`after price change: ${order.totalCents()}`); // 2880// Aggregation: drop the order and the product is still in the catalog.order = undefined;console.log(catalog.find("MUG-1").name); // MugWhat to notice
Section titled “What to notice”- In these five languages, composition is a rule the owner keeps. All five are garbage
collected. So no destructor shows “dies with the whole”, and nothing stops a caller from holding
on to a line it read. The class keeps the promise by how it is written. Lines are built only
inside
Order, and stored privately. They’re never accepted from outside, and shown only as a read-only list (Go hands out copies).LineItemis immutable, so a reference someone kept cannot change the order. C++ and Rust can enforce the lifetime. These languages cannot. - Aggregation and association are the same code.
CatalogandLineItemboth hold references to products made elsewhere. The hollow diamond adds intent, not structure. - Constructor injection draws as a plain arrow.
CheckoutServicereceives its catalog and gateway from outside and keeps them. The wiring code inmaincreates them, which is exactly why the service does not own them. - Go spells “is a” differently, and the diagram does not change. Go has no subclassing. So
Discountbecomes an interface, and the sharedDescribecomes from an embedded struct. Embedding is composition with forwarding. APercentOffcannot be passed where abaseDiscountis expected. Realization needs no keyword at all in Go, Python or TypeScript. The method set is enough. - Some decisions don’t show in any arrow.
LineItempoints atProduct, and also copies its price. So changing the product’s price inmainleaves the placed order at 2880 cents. The diagram records that only as theunitPriceCentsfield. A short note next to the arrow says it better than a new symbol would.
Why is
OrdertoLineItema filled diamond, butCatalogtoProducthollow?
Whether you tie the symbols to lifetime and exclusive ownership, then to code. A good answer: “Order creates its lines, and nobody else can hold one. Products are created elsewhere, and shared between catalogs and orders.” The red flag is “composition is a stronger has-a”, with nothing about who creates the part or what happens when the whole goes.
Pick each arrow below, to see its symbol and the code it becomes in each language. Then delete the object on the left, and watch what happens to the one on the right. The quiz underneath gives a plain description, and asks which arrow fits.
Pick an arrow
Association (solid line, open arrowhead) LineItem holds a reference to a Product and can reach it. The arrowhead says which way: Product has no list of lines.
MermaidLineItem "*" --> "1" Product
class LineItem {
// A field holding an object made somewhere else.
private final Product product;
LineItem(Product product, int qty) {
this.product = product;
}
}Name the arrow
Pick the arrow that fits. Some scenarios accept more than one.
Order creates its line items and never hands them out, so that’s composition. The catalog only holds products that live elsewhere, so it gets a hollow diamond, and in code that’s just a map of references.
Stage 3: multiplicities and navigability
Section titled “Stage 3: multiplicities and navigability”Numbers turn a sketch into something that constrains the code. Add them where the answer is not obvious or where a rule hides in it. The service and gateway arrows are unchanged and left out here.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
Each number is a rule the code has to keep:
| End | Rule | Where the code keeps it |
|---|---|---|
Order "1" *-- ... |
a line belongs to exactly one order | only Order creates lines, and never accepts one |
... *-- "1..*" LineItem |
an order has at least one line | placeOrder refuses an empty cart |
LineItem "*" --> "1" Product |
every line has a product, and a product is on many lines | a non-null field set in the constructor |
Order "*" --> "0..1" Discount |
at most one discount per order | a nullable field, not a list |
Catalog "*" o-- "*" Product |
a product can be in several catalogs | a map of references, with no back-pointer on Product |
The 1 at the whole end of a composition is not a coincidence. A part with two owners would not
know whose lifetime to share. So composition always has 1 or 0..1 there. If the honest answer
is “a line item can be on several orders”, it is not composition.
Navigability is a cost decision. Every arrowhead above points one way. “Which orders contain this
product?” is a real question for the warehouse team. But adding Product --> Order would mean every
new order edits a product, and the two lists can drift apart. A query over orders answers it,
without a second field to keep in step.
Stage 4: one use case as a sequence diagram
Section titled “Stage 4: one use case as a sequence diagram”A class diagram shows structure. It cannot show order: that the price is computed before the charge, or what happens when the charge is declined. A sequence diagram shows one use case over time. It earns its place when a flow crosses three or more objects, when the order of calls is the point, or when a failure branch changes the outcome. Checkout has all three.
The notation. Everything but par appears in the diagram below:
| Element | Drawn as | Means |
|---|---|---|
| Lifeline | a box at the top with a dashed line down | one object, or the actor, over time |
| Synchronous message | solid line, filled arrowhead | a call; the caller waits |
| Return | dashed line, arrowhead | the reply to a call; often left out when obvious |
| Asynchronous message | solid line, open arrowhead | send and move on: a queue, an event, a new thread |
| Create | a dashed arrow to a lifeline that starts partway down (Mermaid draws a plain message) | new: the object did not exist before this point |
loop, alt, opt frames |
a labeled box around messages, guard in [ ] |
repeat, choose one branch, or do it only if |
par frame |
a box split by dashed lines | the parts run at the same time |
- Color key
- Entity
- Queue or stream or Interface
- Facade
The diagram and the code match message for message. The stage 4 code adds the order’s status, a check for an empty cart, and the receipt queue:
import java.util.ArrayDeque;import java.util.ArrayList;import java.util.HashMap;import java.util.LinkedHashMap;import java.util.List;import java.util.Map;import java.util.Queue;
// Product, Catalog, LineItem, Discount and PercentOff are unchanged from the model above.class Product { private final String sku; private final String name; private final int priceCents;
Product(String sku, String name, int priceCents) { this.sku = sku; this.name = name; this.priceCents = priceCents; }
String sku() { return sku; } String name() { return name; } int priceCents() { return priceCents; }}
class Catalog { private final Map<String, Product> bySku = new HashMap<>();
void add(Product p) { bySku.put(p.sku(), p); }
Product find(String sku) { Product p = bySku.get(sku); if (p == null) throw new IllegalArgumentException("unknown sku " + sku); return p; }}
final class LineItem { private final Product product; private final int qty; private final int unitPriceCents;
LineItem(Product product, int qty) { if (qty < 1) throw new IllegalArgumentException("qty must be at least 1"); this.product = product; this.qty = qty; this.unitPriceCents = product.priceCents(); }
int subtotalCents() { return unitPriceCents * qty; }}
abstract class Discount { protected final String label;
protected Discount(String label) { this.label = label; }
public abstract int amountOffCents(int subtotalCents);
public String describe() { return label; }}
class PercentOff extends Discount { private final int percent;
PercentOff(int percent) { super(percent + "% off"); this.percent = percent; }
@Override public int amountOffCents(int subtotalCents) { return subtotalCents * percent / 100; }}
// The states from the state diagram. An enum, because the set is fixed and small.enum OrderStatus { CREATED, PAID, CANCELLED, SHIPPED }
// Multiplicities become types and checks:// "1..*" LineItem -> a list, and placeOrder refuses an empty cart// "0..1" Discount -> a field that may be null// Each transition method is one arrow of the state diagram; any other move throws.class Order { private static int nextId = 1; private final int id; private final List<LineItem> lines = new ArrayList<>(); private Discount discount; private OrderStatus status = OrderStatus.CREATED;
Order() { id = nextId++; }
int id() { return id; } OrderStatus status() { return status; }
void addLine(Product product, int qty) { lines.add(new LineItem(product, qty)); }
void apply(Discount d) { discount = d; }
int totalCents() { int subtotal = lines.stream().mapToInt(LineItem::subtotalCents).sum(); return discount == null ? subtotal : subtotal - discount.amountOffCents(subtotal); }
void markPaid() { moveTo(OrderStatus.PAID, OrderStatus.CREATED); } void cancel() { moveTo(OrderStatus.CANCELLED, OrderStatus.CREATED); } void ship() { moveTo(OrderStatus.SHIPPED, OrderStatus.PAID); }
private void moveTo(OrderStatus next, OrderStatus from) { if (status != from) throw new IllegalStateException("cannot go from " + status + " to " + next); status = next; }}
interface PaymentGateway { boolean charge(int orderId, int cents);}
class FakeCardGateway implements PaymentGateway { private final int limitCents;
FakeCardGateway(int limitCents) { this.limitCents = limitCents; }
public boolean charge(int orderId, int cents) { return cents <= limitCents; }}
// placeOrder is the sequence diagram, message by message.class CheckoutService { private final Catalog catalog; private final PaymentGateway gateway; // Stands in for a message queue. Adding to it is the async arrow: send and move on, // and a mailer drains it later. The customer never waits for the email. private final Queue<Integer> receiptQueue;
CheckoutService(Catalog catalog, PaymentGateway gateway, Queue<Integer> receiptQueue) { this.catalog = catalog; this.gateway = gateway; this.receiptQueue = receiptQueue; }
Order placeOrder(Map<String, Integer> cart, Discount discount) { if (cart.isEmpty()) throw new IllegalArgumentException("an order needs at least one line"); Order order = new Order(); // the create message: a new lifeline starts here for (var entry : cart.entrySet()) { // the loop frame Product p = catalog.find(entry.getKey()); // sync call, waits for the return order.addLine(p, entry.getValue()); } if (discount != null) order.apply(discount); // an opt frame, if the diagram shows it if (gateway.charge(order.id(), order.totalCents())) { // the alt frame, first branch order.markPaid(); receiptQueue.add(order.id()); // async: no return arrow } else { // the alt frame, else branch order.cancel(); } return order; // the dashed return arrow to the caller }}
class Main { public static void main(String[] args) { Catalog catalog = new Catalog(); catalog.add(new Product("MUG-1", "Mug", 1200)); catalog.add(new Product("TEA-9", "Green tea", 800)); Queue<Integer> receipts = new ArrayDeque<>(); CheckoutService checkout = new CheckoutService(catalog, new FakeCardGateway(5_000), receipts);
Map<String, Integer> cart = new LinkedHashMap<>(); cart.put("MUG-1", 2); cart.put("TEA-9", 1); Order paid = checkout.placeOrder(cart, new PercentOff(10)); System.out.println(paid.id() + " " + paid.status() + " " + paid.totalCents()); // 1 PAID 2880
Order declined = checkout.placeOrder(Map.of("MUG-1", 5), null); System.out.println(declined.id() + " " + declined.status()); // 2 CANCELLED
paid.ship(); System.out.println(paid.status() + ", receipts queued for " + receipts); // SHIPPED, receipts queued for [1] try { declined.ship(); // no arrow from CANCELLED to SHIPPED in the state diagram } catch (IllegalStateException e) { System.out.println(e.getMessage()); // cannot go from CANCELLED to SHIPPED } }}from abc import ABC, abstractmethodfrom collections import dequefrom enum import Enumfrom itertools import countfrom typing import Protocol
# Product, Catalog, LineItem, Discount and PercentOff are unchanged from the model above.class Product: def __init__(self, sku: str, name: str, price_cents: int) -> None: self.sku = sku self.name = name self.price_cents = price_cents
class Catalog: def __init__(self) -> None: self._by_sku: dict[str, Product] = {}
def add(self, p: Product) -> None: self._by_sku[p.sku] = p
def find(self, sku: str) -> Product: if sku not in self._by_sku: raise KeyError(f"unknown sku {sku}") return self._by_sku[sku]
class LineItem: def __init__(self, product: Product, qty: int) -> None: if qty < 1: raise ValueError("qty must be at least 1") self.product = product self._qty = qty self._unit_price_cents = product.price_cents
def subtotal_cents(self) -> int: return self._unit_price_cents * self._qty
class Discount(ABC): def __init__(self, label: str) -> None: self._label = label
@abstractmethod def amount_off_cents(self, subtotal_cents: int) -> int: ...
def describe(self) -> str: return self._label
class PercentOff(Discount): def __init__(self, percent: int) -> None: super().__init__(f"{percent}% off") self._percent = percent
def amount_off_cents(self, subtotal_cents: int) -> int: return subtotal_cents * self._percent // 100
# The states from the state diagram. An enum, because the set is fixed and small.class OrderStatus(Enum): CREATED = "CREATED" PAID = "PAID" CANCELLED = "CANCELLED" SHIPPED = "SHIPPED"
# Multiplicities become types and checks:# "1..*" LineItem -> a list, and place_order refuses an empty cart# "0..1" Discount -> an optional field# Each transition method is one arrow of the state diagram; any other move raises.class Order: _ids = count(1)
def __init__(self) -> None: self.id = next(Order._ids) self._lines: list[LineItem] = [] self._discount: Discount | None = None self.status = OrderStatus.CREATED
def add_line(self, product: Product, qty: int) -> None: self._lines.append(LineItem(product, qty))
def apply(self, d: Discount) -> None: self._discount = d
def total_cents(self) -> int: subtotal = sum(line.subtotal_cents() for line in self._lines) off = self._discount.amount_off_cents(subtotal) if self._discount else 0 return subtotal - off
def mark_paid(self) -> None: self._move_to(OrderStatus.PAID, OrderStatus.CREATED)
def cancel(self) -> None: self._move_to(OrderStatus.CANCELLED, OrderStatus.CREATED)
def ship(self) -> None: self._move_to(OrderStatus.SHIPPED, OrderStatus.PAID)
def _move_to(self, nxt: OrderStatus, frm: OrderStatus) -> None: if self.status is not frm: raise RuntimeError(f"cannot go from {self.status.value} to {nxt.value}") self.status = nxt
class PaymentGateway(Protocol): def charge(self, order_id: int, cents: int) -> bool: ...
class FakeCardGateway: def __init__(self, limit_cents: int) -> None: self._limit_cents = limit_cents
def charge(self, order_id: int, cents: int) -> bool: return cents <= self._limit_cents
# place_order is the sequence diagram, message by message.class CheckoutService: def __init__( self, catalog: Catalog, gateway: PaymentGateway, receipt_queue: deque[int] ) -> None: self._catalog = catalog self._gateway = gateway # Stands in for a message queue. Adding to it is the async arrow: send and move on, # and a mailer drains it later. The customer never waits for the email. self._receipt_queue = receipt_queue
def place_order(self, cart: list[tuple[str, int]], discount: Discount | None) -> Order: if not cart: raise ValueError("an order needs at least one line") order = Order() # the create message: a new lifeline starts here for sku, qty in cart: # the loop frame product = self._catalog.find(sku) # sync call, waits for the return order.add_line(product, qty) if discount: # an opt frame, if the diagram shows it order.apply(discount) if self._gateway.charge(order.id, order.total_cents()): # the alt frame, first branch order.mark_paid() self._receipt_queue.append(order.id) # async: no return arrow else: # the alt frame, else branch order.cancel() return order # the dashed return arrow to the caller
if __name__ == "__main__": catalog = Catalog() catalog.add(Product("MUG-1", "Mug", 1200)) catalog.add(Product("TEA-9", "Green tea", 800)) receipts: deque[int] = deque() checkout = CheckoutService(catalog, FakeCardGateway(5_000), receipts)
paid = checkout.place_order([("MUG-1", 2), ("TEA-9", 1)], PercentOff(10)) print(paid.id, paid.status.value, paid.total_cents()) # 1 PAID 2880
declined = checkout.place_order([("MUG-1", 5)], None) print(declined.id, declined.status.value) # 2 CANCELLED
paid.ship() print(f"{paid.status.value}, receipts queued for {list(receipts)}") # SHIPPED, receipts queued for [1] try: declined.ship() # no arrow from CANCELLED to SHIPPED in the state diagram except RuntimeError as e: print(e) # cannot go from CANCELLED to SHIPPEDusing System;using System.Collections.Generic;using System.Linq;
// Product, Catalog, LineItem, Discount and PercentOff are unchanged from the model above.class Product{ public Product(string sku, string name, int priceCents) { Sku = sku; Name = name; PriceCents = priceCents; }
public string Sku { get; } public string Name { get; } public int PriceCents { get; }}
class Catalog{ private readonly Dictionary<string, Product> _bySku = new();
public void Add(Product p) => _bySku[p.Sku] = p;
public Product Find(string sku) => _bySku.TryGetValue(sku, out var p) ? p : throw new ArgumentException($"unknown sku {sku}");}
sealed class LineItem{ private readonly int _qty; private readonly int _unitPriceCents;
internal LineItem(Product product, int qty) { if (qty < 1) throw new ArgumentException("qty must be at least 1"); Product = product; _qty = qty; _unitPriceCents = product.PriceCents; }
public Product Product { get; } public int SubtotalCents() => _unitPriceCents * _qty;}
abstract class Discount{ protected readonly string Label;
protected Discount(string label) => Label = label;
public abstract int AmountOffCents(int subtotalCents);
public string Describe() => Label;}
class PercentOff : Discount{ private readonly int _percent;
public PercentOff(int percent) : base($"{percent}% off") => _percent = percent;
public override int AmountOffCents(int subtotalCents) => subtotalCents * _percent / 100;}
// The states from the state diagram. An enum, because the set is fixed and small.enum OrderStatus { Created, Paid, Cancelled, Shipped }
// Multiplicities become types and checks:// "1..*" LineItem -> a list, and PlaceOrder refuses an empty cart// "0..1" Discount -> a nullable field// Each transition method is one arrow of the state diagram; any other move throws.class Order{ private static int _nextId = 1; private readonly List<LineItem> _lines = new(); private Discount? _discount;
public Order() => Id = _nextId++;
public int Id { get; } public OrderStatus Status { get; private set; } = OrderStatus.Created;
public void AddLine(Product product, int qty) => _lines.Add(new LineItem(product, qty));
public void Apply(Discount d) => _discount = d;
public int TotalCents() { int subtotal = _lines.Sum(l => l.SubtotalCents()); return subtotal - (_discount?.AmountOffCents(subtotal) ?? 0); }
public void MarkPaid() => MoveTo(OrderStatus.Paid, OrderStatus.Created); public void Cancel() => MoveTo(OrderStatus.Cancelled, OrderStatus.Created); public void Ship() => MoveTo(OrderStatus.Shipped, OrderStatus.Paid);
private void MoveTo(OrderStatus next, OrderStatus from) { if (Status != from) throw new InvalidOperationException($"cannot go from {Status} to {next}"); Status = next; }}
interface IPaymentGateway{ bool Charge(int orderId, int cents);}
class FakeCardGateway : IPaymentGateway{ private readonly int _limitCents;
public FakeCardGateway(int limitCents) => _limitCents = limitCents;
public bool Charge(int orderId, int cents) => cents <= _limitCents;}
// PlaceOrder is the sequence diagram, message by message.class CheckoutService{ private readonly Catalog _catalog; private readonly IPaymentGateway _gateway; // Stands in for a message queue. Adding to it is the async arrow: send and move on, // and a mailer drains it later. The customer never waits for the email. private readonly Queue<int> _receiptQueue;
public CheckoutService(Catalog catalog, IPaymentGateway gateway, Queue<int> receiptQueue) { _catalog = catalog; _gateway = gateway; _receiptQueue = receiptQueue; }
public Order PlaceOrder(IReadOnlyList<(string Sku, int Qty)> cart, Discount? discount) { if (cart.Count == 0) throw new ArgumentException("an order needs at least one line"); var order = new Order(); // the create message: a new lifeline starts here foreach (var (sku, qty) in cart) // the loop frame { var product = _catalog.Find(sku); // sync call, waits for the return order.AddLine(product, qty); } if (discount != null) order.Apply(discount); // an opt frame, if the diagram shows it if (_gateway.Charge(order.Id, order.TotalCents())) // the alt frame, first branch { order.MarkPaid(); _receiptQueue.Enqueue(order.Id); // async: no return arrow } else // the alt frame, else branch { order.Cancel(); } return order; // the dashed return arrow to the caller }}
static class Program{ static void Main() { var catalog = new Catalog(); catalog.Add(new Product("MUG-1", "Mug", 1200)); catalog.Add(new Product("TEA-9", "Green tea", 800)); var receipts = new Queue<int>(); var checkout = new CheckoutService(catalog, new FakeCardGateway(5_000), receipts);
var paid = checkout.PlaceOrder(new[] { ("MUG-1", 2), ("TEA-9", 1) }, new PercentOff(10)); Console.WriteLine($"{paid.Id} {paid.Status} {paid.TotalCents()}"); // 1 Paid 2880
var declined = checkout.PlaceOrder(new[] { ("MUG-1", 5) }, null); Console.WriteLine($"{declined.Id} {declined.Status}"); // 2 Cancelled
paid.Ship(); Console.WriteLine($"{paid.Status}, receipts queued for [{string.Join(", ", receipts)}]"); // Shipped, receipts queued for [1] try { declined.Ship(); // no arrow from Cancelled to Shipped in the state diagram } catch (InvalidOperationException e) { Console.WriteLine(e.Message); // cannot go from Cancelled to Shipped } }}package main
import ( "errors" "fmt")
// Product, Catalog, LineItem, Discount and PercentOff are unchanged from the model above.type Product struct { SKU string Name string PriceCents int}
type Catalog struct{ bySKU map[string]*Product }
func NewCatalog() *Catalog { return &Catalog{bySKU: map[string]*Product{}} }
func (c *Catalog) Add(p *Product) { c.bySKU[p.SKU] = p }
func (c *Catalog) Find(sku string) (*Product, error) { p, ok := c.bySKU[sku] if !ok { return nil, fmt.Errorf("unknown sku %s", sku) } return p, nil}
type LineItem struct { product *Product qty int unitPriceCents int}
func (l LineItem) SubtotalCents() int { return l.unitPriceCents * l.qty }
type Discount interface { AmountOffCents(subtotalCents int) int Describe() string}
type baseDiscount struct{ label string }
func (b baseDiscount) Describe() string { return b.label }
type PercentOff struct { baseDiscount percent int}
func NewPercentOff(percent int) PercentOff { return PercentOff{baseDiscount{fmt.Sprintf("%d%% off", percent)}, percent}}
func (p PercentOff) AmountOffCents(subtotalCents int) int { return subtotalCents * p.percent / 100 }
// OrderStatus holds the states from the state diagram. A fixed, small set, so constants.type OrderStatus string
const ( Created OrderStatus = "CREATED" Paid OrderStatus = "PAID" Cancelled OrderStatus = "CANCELLED" Shipped OrderStatus = "SHIPPED")
var nextOrderID = 1
// Order: multiplicities become types and checks.//// "1..*" LineItem -> a slice, and PlaceOrder refuses an empty cart// "0..1" Discount -> an interface field that may be nil//// Each transition method is one arrow of the state diagram; any other move is an error.type Order struct { ID int status OrderStatus // unexported, so only the transition methods change it lines []LineItem discount Discount}
func NewOrder() *Order { o := &Order{ID: nextOrderID, status: Created} nextOrderID++ return o}
func (o *Order) AddLine(p *Product, qty int) error { if qty < 1 { return errors.New("qty must be at least 1") } o.lines = append(o.lines, LineItem{product: p, qty: qty, unitPriceCents: p.PriceCents}) return nil}
func (o *Order) Apply(d Discount) { o.discount = d }
func (o *Order) TotalCents() int { subtotal := 0 for _, l := range o.lines { subtotal += l.SubtotalCents() } if o.discount == nil { return subtotal } return subtotal - o.discount.AmountOffCents(subtotal)}
// Status reads the state. There is no setter: moves go through the methods below.func (o *Order) Status() OrderStatus { return o.status }
func (o *Order) MarkPaid() error { return o.moveTo(Paid, Created) }func (o *Order) Cancel() error { return o.moveTo(Cancelled, Created) }func (o *Order) Ship() error { return o.moveTo(Shipped, Paid) }
func (o *Order) moveTo(next, from OrderStatus) error { if o.status != from { return fmt.Errorf("cannot go from %s to %s", o.status, next) } o.status = next return nil}
type PaymentGateway interface { Charge(orderID, cents int) bool}
type FakeCardGateway struct{ LimitCents int }
func (g FakeCardGateway) Charge(orderID, cents int) bool { return cents <= g.LimitCents }
type CartEntry struct { SKU string Qty int}
// CheckoutService.PlaceOrder is the sequence diagram, message by message.type CheckoutService struct { catalog *Catalog gateway PaymentGateway // Stands in for a message queue. Sending on it is the async arrow: send and move on, // and a mailer drains it later. The customer never waits for the email. receipts chan<- int}
func NewCheckoutService(c *Catalog, g PaymentGateway, receipts chan<- int) *CheckoutService { return &CheckoutService{catalog: c, gateway: g, receipts: receipts}}
func (s *CheckoutService) PlaceOrder(cart []CartEntry, discount Discount) (*Order, error) { if len(cart) == 0 { return nil, errors.New("an order needs at least one line") } order := NewOrder() // the create message: a new lifeline starts here for _, e := range cart { // the loop frame p, err := s.catalog.Find(e.SKU) // sync call, waits for the return if err != nil { return nil, err } if err := order.AddLine(p, e.Qty); err != nil { return nil, err } } if discount != nil { // an opt frame, if the diagram shows it order.Apply(discount) } if s.gateway.Charge(order.ID, order.TotalCents()) { // the alt frame, first branch _ = order.MarkPaid() s.receipts <- order.ID // async: no return arrow } else { // the alt frame, else branch _ = order.Cancel() } return order, nil // the dashed return arrow to the caller}
func main() { catalog := NewCatalog() catalog.Add(&Product{"MUG-1", "Mug", 1200}) catalog.Add(&Product{"TEA-9", "Green tea", 800}) // Buffered: a send returns at once while fewer than 16 receipts wait. A full buffer would // block checkout, which is why a real system puts a durable queue here. receipts := make(chan int, 16) checkout := NewCheckoutService(catalog, FakeCardGateway{LimitCents: 5_000}, receipts)
paid, _ := checkout.PlaceOrder([]CartEntry{{"MUG-1", 2}, {"TEA-9", 1}}, NewPercentOff(10)) fmt.Println(paid.ID, paid.Status(), paid.TotalCents()) // 1 PAID 2880
declined, _ := checkout.PlaceOrder([]CartEntry{{"MUG-1", 5}}, nil) fmt.Println(declined.ID, declined.Status()) // 2 CANCELLED
_ = paid.Ship() fmt.Printf("%s, receipts queued for [%d]\n", paid.Status(), <-receipts) // SHIPPED, receipts queued for [1] // No arrow goes from CANCELLED to SHIPPED in the state diagram, so this is refused. if err := declined.Ship(); err != nil { fmt.Println(err) // cannot go from CANCELLED to SHIPPED }}// Product, Catalog, LineItem, Discount and PercentOff are unchanged from the model above.class Product { constructor( readonly sku: string, readonly name: string, readonly priceCents: number, ) {}}
class Catalog { private readonly bySku = new Map<string, Product>();
add(p: Product): void { this.bySku.set(p.sku, p); }
find(sku: string): Product { const p = this.bySku.get(sku); if (!p) throw new Error(`unknown sku ${sku}`); return p; }}
class LineItem { private readonly unitPriceCents: number;
constructor( readonly product: Product, private readonly qty: number, ) { if (qty < 1) throw new Error("qty must be at least 1"); this.unitPriceCents = product.priceCents; }
subtotalCents(): number { return this.unitPriceCents * this.qty; }}
abstract class Discount { protected constructor(protected readonly label: string) {}
abstract amountOffCents(subtotalCents: number): number;
describe(): string { return this.label; }}
class PercentOff extends Discount { constructor(private readonly percent: number) { super(`${percent}% off`); }
amountOffCents(subtotalCents: number): number { return Math.floor((subtotalCents * this.percent) / 100); }}
// The states from the state diagram. A union of names, because the set is fixed and small.type OrderStatus = "CREATED" | "PAID" | "CANCELLED" | "SHIPPED";
// Multiplicities become types and checks:// "1..*" LineItem -> an array, and placeOrder refuses an empty cart// "0..1" Discount -> an optional field// Each transition method is one arrow of the state diagram; any other move throws.class Order { private static nextId = 1; readonly id = Order.nextId++; private readonly items: LineItem[] = []; private discount?: Discount; private current: OrderStatus = "CREATED";
get status(): OrderStatus { return this.current; }
addLine(product: Product, qty: number): void { this.items.push(new LineItem(product, qty)); }
apply(d: Discount): void { this.discount = d; }
totalCents(): number { const subtotal = this.items.reduce((sum, l) => sum + l.subtotalCents(), 0); return subtotal - (this.discount?.amountOffCents(subtotal) ?? 0); }
markPaid(): void { this.moveTo("PAID", "CREATED"); }
cancel(): void { this.moveTo("CANCELLED", "CREATED"); }
ship(): void { this.moveTo("SHIPPED", "PAID"); }
private moveTo(next: OrderStatus, from: OrderStatus): void { if (this.current !== from) throw new Error(`cannot go from ${this.current} to ${next}`); this.current = next; }}
interface PaymentGateway { charge(orderId: number, cents: number): boolean;}
class FakeCardGateway implements PaymentGateway { constructor(private readonly limitCents: number) {}
charge(orderId: number, cents: number): boolean { return cents <= this.limitCents; }}
// placeOrder is the sequence diagram, message by message.class CheckoutService { constructor( private readonly catalog: Catalog, private readonly gateway: PaymentGateway, // Stands in for a message queue. Pushing to it is the async arrow: send and move on, // and a mailer drains it later. The customer never waits for the email. private readonly receiptQueue: number[], ) {}
placeOrder(cart: Array<[string, number]>, discount?: Discount): Order { if (cart.length === 0) throw new Error("an order needs at least one line"); const order = new Order(); // the create message: a new lifeline starts here for (const [sku, qty] of cart) { // the loop frame const product = this.catalog.find(sku); // sync call, waits for the return order.addLine(product, qty); } if (discount) order.apply(discount); // an opt frame, if the diagram shows it if (this.gateway.charge(order.id, order.totalCents())) { // the alt frame, first branch order.markPaid(); this.receiptQueue.push(order.id); // async: no return arrow } else { // the alt frame, else branch order.cancel(); } return order; // the dashed return arrow to the caller }}
const catalog = new Catalog();catalog.add(new Product("MUG-1", "Mug", 1200));catalog.add(new Product("TEA-9", "Green tea", 800));const receipts: number[] = [];const checkout = new CheckoutService(catalog, new FakeCardGateway(5_000), receipts);
const paid = checkout.placeOrder( [ ["MUG-1", 2], ["TEA-9", 1], ], new PercentOff(10),);console.log(paid.id, paid.status, paid.totalCents()); // 1 PAID 2880
const declined = checkout.placeOrder([["MUG-1", 5]]);console.log(declined.id, declined.status); // 2 CANCELLED
paid.ship();console.log(`${paid.status}, receipts queued for [${receipts}]`); // SHIPPED, receipts queued for [1]try { declined.ship(); // no arrow from CANCELLED to SHIPPED in the state diagram} catch (e) { console.log((e as Error).message); // cannot go from CANCELLED to SHIPPED}Reading one against the other:
- Each solid arrow with a filled head is a method call on the field or local the class
diagram promised:
catalog.find,gateway.charge. If the sequence needs to call something the class diagram does not connect, one of the two diagrams is wrong. - The
loopframe is theforloop, and the guard names what it iterates. - The
altframe is theif/elseon the charge result. Each branch shows only its own messages, so the reader sees that a declined card never queues a receipt. - The async arrow is the queue write. It has no return arrow, because nothing comes back. The customer does not wait for the email, and a mailer drains the queue later.
- The create message starts
Order’s lifeline partway down, which shows that the order does not exist before checkout starts.
Walk me through what happens when the card is declined.
Whether you can trace one flow across objects, in order, and name the branch. The order exists
and has a total. The charge comes back declined. The order is cancelled, and no receipt is
queued. Drawing the alt frame takes thirty seconds, and answers it. The red flag is describing
only the happy path, or a design where the receipt is sent before the charge.
A state diagram, briefly
Section titled “A state diagram, briefly”When an object has a lifecycle with rules about which moves are allowed, a state diagram
says it faster than prose. Order is one: it can be paid only once, shipped only after payment,
and never shipped once cancelled.
- Color key
- State
- Ends well
Each arrow is one method in the stage 4 code, and every move the diagram lacks throws. That’s why
declined.ship() fails with “cannot go from CANCELLED to SHIPPED”. Strict UML writes a transition
label as trigger [guard] / effect, as in charge [approved] / markPaid. A plain phrase like the
ones above is enough in a sketch. A state diagram is worth drawing for vending machines, elevators,
ATMs, bookings and orders. Sometimes states start carrying different behavior, not just different
allowed moves. Then the state pattern turns each state into a class.
What to skip
Section titled “What to skip”Most of UML does not belong in a 45 minute design round. Knowing what to leave out is part of the skill.
| Diagram or feature | In an LLD round |
|---|---|
| Class diagram | Always, in stages, with the five symbols |
| Sequence diagram | For the one or two flows that cross several objects or have a failure branch |
| State diagram | When an entity has a lifecycle with forbidden moves |
| Use case diagram | Skip. A numbered list of use cases says the same thing faster |
| Activity diagram, flowchart | Skip. A short method or a sequence diagram shows the logic better |
| Component, deployment, package diagrams | Skip in LLD. Service boundaries belong to a system design round |
| Object, communication, timing, interaction overview | Skip |
| Qualifiers, association classes, derived attributes, OCL constraints | Skip. A one-line note next to the arrow does the job |
| Getters, setters, constructors, every type | Skip. Show what carries a use case |
Commonly taught wrong
Section titled “Commonly taught wrong”Commonly confused with
Section titled “Commonly confused with”| Idea | Looks like UML composition because | The difference |
|---|---|---|
| “Favor composition over inheritance” | Same word | The design advice means object composition: holding other objects in fields and delegating to them. In UML terms that is usually a plain association. See composition vs inheritance. |
| Dependency injection | It contains “dependency” | An injected object kept in a field is a UML association. UML’s dependency is a use that leaves no field behind. |
| ER diagram | Boxes, lines, cardinalities | ER diagrams model tables and foreign keys, with crow’s feet for “many”. Class diagrams model objects with behavior. A foreign key in the database is often an association in code. |
| Go struct embedding | Looks like extends |
Embedding forwards fields and methods, but the outer type is not the inner type. It is composition, not inheritance. |
| Flowchart or activity diagram | Shows a process | A flowchart shows steps inside one routine. A sequence diagram shows which object does each step, which is what a design round is checking. |
Where it shows up
Section titled “Where it shows up”On this site, every LLD problem uses these symbols. A few show one idea especially clearly:
- Parking lot: the lot aggregates levels, and each level is composed of its spots, a filled and a hollow diamond in the same diagram.
- File system:
FileandDirectoryboth inherit fromEntry, the hollow triangle at the center of the composite pattern. - Logging framework: appenders and formatters are interfaces with several implementations, a page full of dashed triangles.
- ATM: the withdrawal sequence diagram uses an
altframe for a cash jam, the failure branch that decides the design. - Vending machine: a state diagram of the machine first, then one class per state.
- Elevator: a state diagram of what one tick can do to a car. And a sequence diagram of a hall call, from button to arrival.
- Movie ticket booking: a seat’s life as a state diagram, and a slow payment as a sequence. It’s the same pair this page draws for an order.
Interview questions
Section titled “Interview questions”Interviewer asksWhat is the difference between aggregation and composition, and how would I see it in code?
Composition means each part has one owner and shares its lifetime. In code the owner creates the parts, keeps them private and never hands them out, so when the owner is gone nothing references them. Aggregation means the parts are referenced but live independently, which in code is the same as a plain association. I use composition when the lifetime rule matters and a plain arrow otherwise.
Interviewer asksA class gets its repository through the constructor. Which arrow do you draw?
A solid arrow from the class to the repository interface: an association, because it is kept in a field. Not a diamond, because the class did not create it and does not own its lifetime. Not a dashed dependency arrow, because it outlives a single call. The repository’s implementation hangs off the interface with a dashed triangle.
Interviewer asksWhen would you draw a sequence diagram instead of just writing the code?
When one use case crosses three or more objects, and the order or the failure branch is the point.
Examples are checkout, a booking with a hold and a confirm, and an ATM withdrawal. It shows in a
minute that the charge happens before the receipt. And the alt frame makes the declined path
visible. For a call inside one class, the code is clearer.
Interviewer asksYour diagram has an arrow from Order to Product and one from Product to Order. Do you need both?
Only if a use case walks both ways often. Two-way means two fields that must change together, and each class now changes when the other does. I’d keep Order to Product, through the line items, and answer “which orders contain this product?” with a query.
| Decision | We chose | We gave up |
|---|---|---|
| What goes in a box | Fields that hold state and methods that carry use cases | A complete member list |
| Owner and parts | Composition only when the owner creates the parts and nobody else holds them | Aggregation’s extra nuance, drawn as a plain arrow instead |
| Injected collaborators | Association: a solid arrow to an interface | The literal reading of “dependency injection” |
| Arrow direction | Toward the class that is known, used or inherited from | Nothing: drawn the other way, it says something false |
| Navigability | One way, the way a use case walks | Walking back without a lookup |
| Behavior over time | One sequence diagram for the flow with a failure branch, one state diagram for a lifecycle | Activity, deployment and the other ten diagram types |
Further reading
Section titled “Further reading”- How to approach an LLD interview, for when to draw each stage and how much time it gets
- Object modeling, for finding the classes that go in the boxes
- Composition vs inheritance, for choosing between the diamond and the triangle
- Dependency inversion, for why the arrows point at interfaces
- State pattern, for when a state diagram becomes classes
- Martin Fowler, UML Distilled, 3rd edition (2003), the short practical guide to the parts of UML worth using, including the advice to leave aggregation out
- Object Management Group, UML 2.5.1 specification, for the exact meaning of every symbol
- Mermaid class diagram syntax, the notation every diagram on this site is written in
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.