Engineering Atlas › Low-Level Design

Drawing UML class diagrams

13 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

What the diagram is for

A class diagram drops method bodies, imports and error handling, and keeps what reviewers argue about: which classes exist, who owns whom, and which way dependencies point. Martin Fowler distinguishes UML as sketch, as blueprint and as programming language. Nearly all useful work today is sketching: draw to explore or explain, and stop once the picture has answered the question.

The class box

The top compartment holds the name, plus an optional stereotype such as <<interface>>, <<abstract>> or <<enumeration>>. The middle holds attributes in the form visibility name: Type [multiplicity] = default, for example - lines: OrderLine [1..*]. The bottom holds operations as visibility name(param: Type): ReturnType, for example + addLine(item: MenuItem, qty: int): void.

Static members are underlined and abstract ones italicised; Mermaid writes them with a trailing $ and *. Compartments may be left empty. A diagram that lists every getter is a diagram nobody reads.

Visibility

+ public, - private, # protected and ~ package map to Java's keywords directly. Python only has the _name convention, and Go's nearest fit is a capitalised (exported) versus lowercase name. The marks state the interface you intend, not what the language enforces, so the diagram is often the clearest record of that intent.

Multiplicity

A multiplicity is a range lower..upper. 1 is shorthand for 1..1, and for 0... It sits at the far end of an association from the object you are reading from: Customer "1" --> "*" Order reads "one customer has any number of orders; each order has exactly one customer".

Each bound means something in code. Lower bound 1: the constructor receives the other object. Lower bound 0: every reader handles absence. Upper bound 1: a single field. Upper bound *: a collection; a bound like 5 adds a check on every insert. The Association lesson enforces these when a link is made; this lesson's practice checks them over a whole object graph.

The six relationships

These are the arrows from the earlier lessons in this course, collected in one place with their notation:

RelationshipLine and headMermaidReads asTypical code
Associationsolid line, open arrowhead for navigabilityA --> BA knows B over timefield holding a reference or id
Dependencydashed line, open arrowheadA ..> BA uses B brieflyparameter, local variable, return type
Aggregationsolid line, hollow diamond at the wholeA o-- BA groups Bs that live on their owncollection of references to shared objects
Compositionsolid line, filled diamond at the wholeA *-- BA owns Bs; they die with itobjects created and held only by A
Inheritancesolid line, hollow triangle at the parent`A <\-- B`B is a kind of Aextends, subclassing, embedding as a crude substitute
Realizationdashed line, hollow triangle at the interface`A <\.. B`B fulfils the contract Aimplements, structural interface satisfaction

Dependency and association differ in time: one call versus a stored reference. Aggregation and composition differ in lifetime: deleting a playlist keeps its tracks, deleting an order deletes its lines. Realization and inheritance differ in what is shared: a contract only, versus contract plus implementation and state. The UML specification itself leaves the semantics of shared aggregation to the application domain, so use the hollow diamond only when the team agrees what it means.

Using it in an interview

In a low-level design round the interviewer reads your diagram before your code. A sequence that works:

  1. Restate requirements as a few use cases and underline the nouns. Nouns with state or rules become classes; pure values (a price, a seat number) become attributes.
  2. Draw named boxes, then relationships and multiplicities before attributes, because that is where the decisions are.
  3. Ask per arrow: if this object is deleted, what else goes? That separates composition, aggregation and association.
  4. Add interfaces only where you expect variation, each with a reason you can say out loud.
  5. Walk one use case through the diagram. If you cannot trace it, a relationship is missing.

Five to eight classes is enough. Twenty-five boxes in 45 minutes signals you modelled the nouns, not the problem.

Diagrams in the age of generated code

Code assistants over-produce class models: an interface per class, a manager per noun, links in both directions. Asking for a Mermaid class diagram of generated code is a cheap review step, since arrows in every direction reveal coupling faster than reading files. Diagram-as-code also lets the picture change in the same pull request as the code.

The same shape elsewhere

A class diagram is a schema for an object graph. An entity-relationship diagram says the same about tables (1 on the one side becomes a NOT NULL foreign key, to a join table), and JSON Schema says it about messages. The shape is "trust but verify": a declared structure plus a validator that checks real data against it.

Down the stack

Layer 1The contract

A class diagram promises structural facts that hold whenever a caller can observe the system: an Order reaches its lines, every OrderLine belongs to exactly one Order, CardPayment satisfies PaymentMethod. Each is testable: multiplicities as invariants after every public operation, realization as "compiles where the interface is expected", composition as "after deleting the whole, no part is reachable". It promises nothing about ordering or timing; that belongs in sequence and state diagrams.

Layer 2The mechanism

In the UML 2.5 metamodel an Association element has two member ends, each a Property with lower and upper bounds, navigability, and an aggregation kind of none, shared or composite. Generalization and InterfaceRealization are separate elements. Tools exchange the model as XMI, an XML format.

Mermaid is lighter: it parses classDiagram text into classes, members and relations with optional cardinality labels, and a layout engine (dagre by default) renders SVG. It checks neither multiplicities nor interface methods; the text is only a drawing. Enforcement lives in code: constructors for lower bounds of 1, guarded add methods for upper bounds, database constraints, and graph validators like the practice below.

Layer 3The machine

Each arrow has a memory shape. On a 64-bit machine an upper bound of 1 is one 8-byte pointer (4 with JVM compressed oops), and 0..1 adds a null check at every read. An upper bound of * in Go is a 24-byte slice header plus a backing array; an empty CPython list is 56 bytes plus 8 per slot.

Composition can be stored by value, which aggregation cannot. A Go struct holding Address rather than *Address puts the part inside the whole: one allocation, often the same cache line, and the part cannot outlive the whole. Aggregation must be a pointer, because the part is shared.

Inheritance and realization show up in dispatch. A C++ object with virtual methods carries an 8-byte vtable pointer, and a virtual call is two dependent loads plus an indirect branch. A Go interface value is two words: an itab pointer (cached per type and interface pair) and a data pointer. JavaScript walks the prototype chain, made cheap by V8's inline caches. A dependency costs nothing in object layout because it lives on the stack for one call, which is the physical reason it is the loosest relationship.

Why it's built this way

  1. Code is too detailed to discuss structure, so teams need a smaller picture that keeps types and links and drops the rest.
  2. The links that matter differ in lifetime and coupling, so the notation needs distinct arrows for "uses briefly", "knows", "groups", "owns", "is a" and "fulfils".
  3. Counts drive data structures and constraints (field or collection, nullable or required), so each end carries a multiplicity.
  4. Pictures drift from code, so modern practice keeps diagrams small and stores them as text next to the code, where review catches drift.

Diagram

A food-delivery order model that uses all six relationships, visibility marks and multiplicities.

classDiagram
    class Customer {
        +String id
        -String email
        +placeOrder(cart) Order
    }
    class Order {
        +String id
        -OrderStatus status
        +addLine(item, qty) void
        +total() int
    }
    class OrderLine {
        -int qty
        +subtotal() int
    }
    class MenuItem {
        +String name
        #int priceCents
    }
    class DeliveryZone {
        +String name
    }
    class Courier {
        +String id
    }
    class PaymentMethod {
        <<interface>>
        +charge(amountCents) Receipt
    }
    class CardPayment {
        -String token
        +charge(amountCents) Receipt
    }
    class Discount {
        <<abstract>>
        +apply(cents) int
    }
    class PercentOff {
        -int percent
        +apply(cents) int
    }
    class PricingService {
        +quote(order) int
    }
    Customer "1" --> "*" Order : places
    Order "1" *-- "1..*" OrderLine : contains
    OrderLine "*" --> "1" MenuItem : refers to
    DeliveryZone "1" o-- "*" Courier : covers
    Order "*" --> "0..1" Courier : assigned
    Order "*" --> "1" PaymentMethod : paid by
    PaymentMethod <|.. CardPayment
    Discount <|-- PercentOff
    PricingService ..> Order : prices
    PricingService ..> Discount : applies

Worked example

An interviewer asks for the model behind a food-delivery checkout. The requirements are: customers place orders of one or more menu items; an order is paid by card or wallet; a courier from the order's zone is assigned after payment; discounts may apply.

Of the nouns, quantity and price are values and become attributes, and receipt is only returned, so it stays a return type. OrderLine appears because "one or more menu items, with quantities" is a many-to-many association that carries data.

The ownership questions settle the diamonds. Delete an order and its lines go: composition, 1 to 1..*. Delete a zone and its couriers still work elsewhere: aggregation. Delete an order and the customer remains: plain association. PricingService only reads an order during quote, so it is a dependency, not a field.

The multiplicities turn into code directly:

class Order:
    def __init__(self, order_id, customer, payment, first_line):
        self.id = order_id
        self.customer = customer        # "1": required in the constructor
        self.payment = payment          # "1": required
        self.courier = None             # "0..1": readers must handle None
        self._lines = [first_line]      # "1..*": cannot start empty

    def remove_line(self, line):
        if len(self._lines) == 1:
            raise ValueError("order needs at least one line")
        self._lines.remove(line)

Now the cost. With 200,000 open orders of 3 lines each in Go, []OrderLine (32-byte lines by value) is one backing array per order. []*OrderLine adds 600,000 separate heap objects for the garbage collector to trace. Both are under 10 MB, so choose by semantics; at 50 million lines in a pricing batch, value composition is one cache-friendly scan instead of 50 million pointer chases.

In the interview, spend about 5 minutes on requirements, 10 on the diagram, 20 coding Order, OrderLine and PaymentMethod, and the rest walking through "place order, pay, assign courier".

Trade-offs

OptionStrengthsWeaknessesChoose when
Whiteboard sketchFast; invites discussion; no toolingLost after the meeting; no review historyInterviews, early design sessions
Diagram-as-code (Mermaid, PlantUML)Lives in the repo; diffable; reviewed with codeLayout control is limited; still drifts if nobody updates itDesign docs and READMEs for long-lived modules
Diagram generated from codeAlways matches the codeShows everything, so it hides the important partsOnboarding to an unfamiliar codebase, auditing coupling
Full UML model with tools and XMIPrecise; supports code generation and model checksHeavy; few teams keep it currentRegulated or safety-critical domains that require models
No diagram, code onlyNothing to maintainStructure is invisible until someone reads every fileSmall modules with two or three types

Pitfalls in production

Leadership lens

Check yourself

  1. A Playlist holds Track objects that appear in many playlists and survive playlist deletion. Should the diagram show composition?

    Show answer

    No. Shared parts that outlive the whole are aggregation (or a plain association). Composition requires that each part belongs to one whole at a time and dies with it.

  2. What changes in code when an association end changes from 1 to 0..1?

    Show answer

    The constructor no longer requires the object, the field becomes nullable or optional, and every reader must handle absence. In a database, the foreign key column drops its NOT NULL constraint.

  3. Why is a dependency weaker than an association, in terms of what is in memory?

    Show answer

    A dependency exists only for the duration of a call, as a parameter or local on the stack. An association is a stored field, so the two objects stay linked and a change to the target's structure affects the holder for its whole lifetime.

  4. Your Go code has type Order struct { lines []OrderLine }. Which diagram relationship does that most naturally express, and why?

    Show answer

    Composition. The lines are stored by value inside the order's backing array, so nothing else can hold the same line by reference and the lines become unreachable when the order does.

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →