Why this is the most important communication skill in LLD
You can have the cleanest design in your head and still fail an LLD round if you can’t show it. The interviewer needs to see your classes, their responsibilities, and how they connect — fast, on a whiteboard or a shared doc, often in ASCII. The UML class diagram is the agreed-upon notation for exactly this.
You do not need formal, tool-perfect UML. Interviewers want practical UML: enough rigor that any engineer reads your boxes and arrows the same way you meant them. This lesson teaches the subset that actually shows up: the class box, visibility, static/abstract notation, and the relationship arrows — plus the bidirectional skill that matters most: turning a diagram into code and code back into a diagram.
In an interview, say: “Let me sketch the class diagram before I write code.” This signals seniority. You’re designing structure first, not coding into a corner.
The class box: name, attributes, methods
A class is a rectangle split into three compartments, top to bottom:
+---------------------------+
| Account | <- 1. Class name (centered, bold)
+---------------------------+
| - balance: double | <- 2. Attributes (fields)
| - owner: String |
+---------------------------+
| + deposit(amount): void | <- 3. Operations (methods)
| + withdraw(amount): bool |
| - validate(amt): bool |
+---------------------------+
- Name compartment: just the class name. Abstract class names are italic (in ASCII, mark them
{abstract}since you can’t italicize). - Attributes:
visibility name: Type. You may add= defaultfor initial values and[*]/[0..1]for multiplicity. - Operations:
visibility name(param: Type): ReturnType.
The signature format is precise but you don’t have to be exhaustive. Show the attributes and methods that matter to the design conversation. Omitting a getter is fine; omitting the field that makes the whole pattern work is not.
Don’t draw every getter/setter, constructor, or toString. Diagrams are for communication, not codegen. Show the fields and methods that reveal responsibilities and collaborations — the rest is noise that wastes your limited whiteboard time.
Visibility: the four symbols
Each member is prefixed with a visibility marker. These map directly to access modifiers in code:
| Symbol | Name | Java/C++/TS meaning | Python convention |
|---|---|---|---|
+ | public | public | plain name (balance) |
- | private | private | __balance (name-mangled) |
# | protected | protected | _balance (single underscore) |
~ | package | package-private (Java default) | (module scope — rarely shown) |
The ~ (package) is rare in interviews; +, -, and # cover almost everything. The mental rule: fields are usually - (encapsulation), most behavior is +, and # shows up only when a subclass genuinely needs access.
A common slip is marking fields +. If you draw + balance: double, you’ve signaled that any code can mutate the balance directly — which contradicts the whole point of an Account. Default fields to - and expose behavior through + methods. An interviewer will notice public mutable state.
Static and abstract notation
Two modifiers change how a member or class is rendered:
- Static members are underlined. In ASCII you can’t underline, so write
{static}after the member, or note it inline. Static = belongs to the class, not an instance. - Abstract classes and methods are italic. In ASCII, append
{abstract}. An abstract class can’t be instantiated; an abstract method has no body and must be overridden. - Interfaces get a
«interface»stereotype above the name (use<<interface>>in ASCII).
+--------------------------------+
| <<interface>> |
| PaymentGateway |
+--------------------------------+
| + charge(amount): Receipt | (all methods implicitly public + abstract)
+--------------------------------+
+--------------------------------+
| Shape {abstract} |
+--------------------------------+
| # name: String |
+--------------------------------+
| + area(): double {abstract} | <- subclasses MUST implement
| + describe(): String | <- concrete, inherited as-is
| + instances(): int {static} | <- class-level counter accessor
+--------------------------------+
In an interview, when you draw
{abstract}on a method, say it out loud: “area is abstract, so every concrete Shape implements its own.” This pre-empts the “what does the base class do?” question.
The relationship arrows (the part people get wrong)
Boxes are easy; the lines between them carry the design intent. Get these six right and you can express almost any LLD. Note the deliberate visual distinction between inheritance (solid line) and realization (dashed line) — they share a triangle head but the line style is the whole point:
Association A -------> B A holds a reference to B (uses/knows)
Aggregation A <>------ B A has-a B, but B can outlive A (shared)
Composition A *------- B A owns B; B dies with A (exclusive)
Inheritance A ------|> B A is-a B (extends) -- SOLID line, triangle
Realization A - - -|> B A implements interface B -- DASHED line, triangle
Dependency A - - - -> B A transiently uses B (param/local/return)
The single difference between inheritance and realization in UML is the line texture: a solid line for
extends, a dashed line forimplements. In ASCII, draw realization with dashes (- - -|>) so a reader can never confuse the two.
The two that get confused most are aggregation vs composition — both are “has-a”, but the diamond’s fill encodes lifecycle ownership:
| Relationship | Symbol | Lifecycle | Example |
|---|---|---|---|
| Aggregation (hollow diamond) | <>---- | Part exists independently | Team o— Player (a player survives the team disbanding) |
| Composition (filled diamond) | *---- | Part dies with whole | House ◆— Room (a room has no meaning without its house) |
Multiplicity is written at the line ends: 1, 0..1, 1..*, *. Read Order "1" *---- "1..*" LineItem as “one Order is composed of one-or-more LineItems.”
+---------+ 1 1..* +-------------+
| Order | *----------------| LineItem |
+---------+ +-------------+
composition: deleting the Order deletes its LineItems
The diamond sits on the whole (the owner), pointing away from the part. Filled diamond = composition (exclusive ownership, cascade delete). Hollow diamond = aggregation (shared, independent lifecycle). Inheritance (solid line) and realization (dashed line) triangles point to the parent/interface.
Translating a diagram into code
This is the skill that gets tested implicitly: you draw, then you implement, and the code must match the picture. Here’s a small diagram and its faithful translation.
<<interface>>
Notifier
+ send(msg: String): void
/\
: (realization, DASHED triangle)
+------+------+
: :
+--------------+ +---------------+
| EmailNotifier| | SmsNotifier |
+--------------+ +---------------+
| + send(msg) | | + send(msg) |
+--------------+ +---------------+
+----------------------------+ 1 1 +------------+
| AlertService |<>----------- | Notifier |
+----------------------------+ aggregation +------------+
| - notifier: Notifier | (injected, swappable)
+----------------------------+
| + raise(msg: String): void |
+----------------------------+
Reading rules applied:
Notifieris an interface → no fields, abstractsend.EmailNotifier/SmsNotifierrealize it (dashed triangle) → theyimplementand overridesend.AlertServicehas a- notifier: Notifierfield with a hollow diamond → it holds an injected, swappableNotifier, programmed to the interface, not a concrete type. Because the concrete notifier is passed in via the constructor (dependency injection), it can be created elsewhere, shared, or substituted in a test — so this is aggregation, not composition.
This is the exact judgment interviewers probe: a constructor-injected dependency is aggregation (hollow diamond), even if the holder later owns it. Composition (filled diamond) is reserved for objects the class constructs internally and never exposes. If AlertService did this.notifier = new EmailNotifier() inside itself, then it would be composition. Injection is what makes it swappable — and swappable is the whole point of programming to an interface.
interface Notifier {
send(msg: string): void;
}
class EmailNotifier implements Notifier {
send(msg: string): void { /* send email */ }
}
class SmsNotifier implements Notifier {
send(msg: string): void { /* send sms */ }
}
class AlertService {
// '-' visibility -> private; typed to the INTERFACE (the arrow).
// Injected -> aggregation (hollow diamond): the notifier can be
// shared/substituted, so AlertService doesn't exclusively own it.
private notifier: Notifier;
constructor(notifier: Notifier) { this.notifier = notifier; }
raise(msg: string): void {
this.notifier.send(msg);
}
}Notice how each notation maps to a language construct: <<interface>> → interface/ABC/pure-virtual struct; the realization triangle → implements/inheritance/override; the - field → private; the hollow diamond → an injected, swappable member.
Reconciling ownership with injection. The C++ tab uses std::unique_ptr<Notifier>, which encodes exclusive ownership — destroying the AlertService destroys the notifier. So is it composition after all? This is the nuance: the diamond’s fill reflects lifecycle ownership (does the part die with the whole?), while the hollow-vs-filled heuristic most people use (“injected → hollow, internally-constructed → filled”) is a proxy for it. A unique_ptr moved in through the constructor is injected ownership — both true at once. In TS/Python the reference is freely shareable, so aggregation is the clean reading; in C++ a unique_ptr makes a composition reading defensible. The honest interview answer: “It’s injected, so I drew aggregation — but because C++ moves a unique_ptr in, the lot owns it exclusively, which leans toward composition. The point of injection here is substitutability, so I’m treating it as aggregation.” Saying this out loud shows you understand both axes.
Translating code back into a diagram
The reverse direction is just as common — you’ve written some classes and the interviewer asks “draw the relationships.” The recipe:
- One box per class/interface. Mark interfaces and abstract classes with the stereotype/
{abstract}. - Fields → attributes, mapping access modifier to
+ - #. - Methods → operations, same visibility mapping; mark
static/abstract. - Field whose type is another class → draw an association/aggregation/composition line to it. Use a filled diamond if this class constructs internally and owns that object (cascade delete, not exposed); hollow if it’s injected/shared.
extends→ solid inheritance triangle to the parent.implements→ dashed realization triangle to the interface.- A type used only as a parameter, local, or return value (not stored) → dashed dependency arrow.
The hardest judgment is step 4: is a field association, aggregation, or composition? Ask “if this object is destroyed, should the field’s object be destroyed too — and did I build it myself or receive it?”
- Constructed internally and not exposed → composition (
*----). - Passed in via constructor / setter, shared elsewhere → aggregation (
<>----). - Just a reference you read but don’t own → plain association.
Don’t over-annotate. In most interviews a plain association arrow (A ----> B with a label and multiplicity) communicates enough. Reach for the diamonds only when lifecycle ownership is part of the design point — e.g. justifying a cascade delete, or explaining why a part can’t be shared. Spending three minutes debating hollow vs filled diamonds on a trivial field is a yellow flag.
A worked end-to-end example
Here is a richer diagram pulling together every notation — a parking-lot fragment you might draw in the first two minutes of that classic problem. Note how each relationship line is drawn to a distinct target, with the diamond seated on the ParkingLot (the whole/owner) side.
<<interface>>
FeeStrategy
+ fee(ticket: Ticket): double
/\
: (realization, DASHED)
+------+--------+
: :
+------------------+ +-----------------+
| FlatRateFee | | HourlyFee |
+------------------+ +-----------------+
| + fee(ticket) | | - rate: double |
+------------------+ | + fee(ticket) |
+-----------------+
^
: (hollow diamond seated on ParkingLot)
:
+-----------------------------+ : 1..* +-------------+
| ParkingLot |*----------------- | Spot | (composition,
+-----------------------------+ filled diamond +-------------+ filled on lot)
| - spots: List<Spot> | on lot side | # id: int |
| - strategy: FeeStrategy |<>--- aggregation -+ - taken: bool|
+-----------------------------+ (to FeeStrategy)+-------------+
| + park(v: Vehicle): Ticket |
| + count(): int {static} |
+-----------------------------+
:
: dependency (Vehicle is a param of park(), never stored)
v - - - - - - - - - - - +
v
+----------------+
| Vehicle |
| # plate: String|
+----------------+
To be unambiguous about which line is which (ASCII diamonds are cramped), here are the three ParkingLot relationships drawn one per line, with the diamond always on the ParkingLot (whole) side:
ParkingLot *----------------> Spot composition (filled diamond on ParkingLot)
ParkingLot <>---------------> FeeStrategy aggregation (hollow diamond on ParkingLot)
ParkingLot - - - - - - - - -> Vehicle dependency (param of park(), not stored)
What a reader extracts in seconds:
FeeStrategyis an interface with two implementations (Strategy pattern — the design intent is visible from the dashed realization triangles alone).ParkingLotcomposes itsSpots (filled diamond,1..*): the lot constructs and owns them, so destroy the lot and the spots go too.ParkingLotaggregates aFeeStrategy(hollow diamond): the strategy is injected, swappable, and can be shared across lots — never constructed insideParkingLot.ParkingLot.parkdepends onVehicle(dashed arrow) — it’s only a parameter, never stored.count()is{static}— a lot-wide tally.
In an interview, narrate the arrows as you draw: “The lot builds and owns its spots — composition, filled diamond. But the fee strategy is injected so we can swap pricing without touching the lot — hollow diamond, aggregation. And Vehicle is just a parameter to park(), so it’s a dashed dependency, not a stored field.” You’re demonstrating that every glyph reflects a deliberate decision.
Quick reference cheat sheet
| You want to express | Notation | In code |
|---|---|---|
| public method | + m() | public |
| private field | - f | private / __f |
| protected member | # m | protected / _m |
| static member | underline / {static} | static / @staticmethod |
| abstract class/method | italic / {abstract} | abstract / ABC / pure virtual |
| interface | <<interface>> | interface / ABC |
| is-a (extends) | `---- | >` solid line, triangle |
| implements | `- - - | >` dashed line, triangle |
| has-a, owned (built internally) | *---- filled diamond | constructed + owned field |
| has-a, shared (injected) | <>---- hollow diamond | injected field |
| uses (knows) | ----> arrow | stored reference |
| transient use | - - -> dashed arrow | param/local/return only |
Memorize this table. The inheritance-vs-realization distinction (solid vs dashed line) and the aggregation-vs-composition distinction (hollow vs filled diamond, injected vs internally-constructed) are the two most-tested notation facts. Being able to draw a correct, readable class diagram in under five minutes — and defend each arrow — is a concrete, repeatable senior signal.