LLD Dojo

Designing under pressure · chapter 31 of 33

Drawing a class diagram fast

Chapter 4.3 · Part 4, Designing under pressure · about 30 minutes

What you need before this chapter: chapters 4.1 and 4.2, plus chapter 1.5, Interfaces, and chapter 3.1, Strategy. You have three lists of nouns. This chapter turns them into one picture you and the interviewer can both point at.

When you finish this chapter you will be able to:


1. From nouns to one picture

After chapters 4.1 and 4.2 you have three lists. Actors, entities, and what you refuse to build.

A class diagram is where the entity list becomes boxes, and where you and whoever is listening agree — before any code exists — on how those boxes relate.

It does not need to be pretty. It needs to answer the questions a reviewer would ask you, and it needs to take you under a minute.

corpus/lru-cache's reference answer is small enough to draw in full. A cache, the policy that decides who gets pushed out, and whoever wants to hear about it when that happens.

Draw the boxes first. No lines at all.

+-----------+   +----------------+   +-------------------+
| LruCache  |   | EvictionPolicy |   | EvictionListener   |
+-----------+   +----------------+   +-------------------+

Three rectangles, one per class the constructor of LruCache actually mentions. That is all you draw first.

That costs you about fifteen seconds and answers nothing yet. It is still a defensible start, because every name on it is a real class from corpus/lru-cache/reference/src and corpus/lru-cache/contract rather than an invented one.

2. Association and realization: the seam that is meant to move

LruCache's constructor takes an EvictionPolicy:

LruCache(int capacity, EvictionPolicy policy) {
    this.capacity = capacity;
    this.policy = Objects.requireNonNull(policy, "policy");
}

EvictionPolicy is an interface with one implementation shipped today, LruEvictionPolicy.

Look at what the constructor does and does not claim. The field is injected. Nothing says the cache keeps it forever, and nothing says no other object could hold the same policy.

So you draw a plain arrow. Then you draw the implementation separately, with a dashed line and a hollow triangle.

LruCache --> EvictionPolicy
LruEvictionPolicy ..|> EvictionPolicy

The plain arrow is an association. One object holds a pointer to another, and the second can exist on its own.

The dashed arrow with the hollow triangle names a different fact: a class fulfils an interface's contract.

Draw both, because that second arrow is doing real work. It flags exactly the box a reviewer should watch for a second class arriving later.

And this corpus proves the prediction right. corpus/lru-cache/curveballs/01-least-frequently-used/reference-patch/LfuEvictionPolicy.java is a second EvictionPolicy, added later, and LruCache did not change by one line to accept it.

The plain arrow alone would have been true. It would also have hidden the one fact a later curveball depended on.

3. Aggregation: parts the whole never built

LruCache also keeps a list of listeners:

private final List<EvictionListener> listeners = new CopyOnWriteArrayList<>();

public void addEvictionListener(EvictionListener listener) {
    listeners.add(Objects.requireNonNull(listener, "listener"));
}

No listener is ever built inside LruCache. Every one arrives from outside through addEvictionListener, built by whoever wanted to watch this particular cache.

The same listener instance could just as easily be registered against a second cache. Nothing about a listener's own existence depends on any cache calling it.

That is the case aggregation exists for. A whole that holds parts it did not create, is not responsible for destroying, and may share with something else.

Draw it with a hollow diamond at the owner's end, and write the multiplicity where the count is not one.

LruCache o-- "0..*" EvictionListener

0..* is the honest count you should write. A cache may have no listeners at all, or several.

Now compare LruCache's single EvictionPolicy. Always exactly one, and never written on the diagram at all, because a count that is always one tells a reader nothing they could not assume.

A count earns its place only where it genuinely varies. And it is always a claim about the running program. 0..* here claims that notifyEviction loops over a group of listeners instead of calling one fixed target, and the code above does exactly that.

4. Composition, defined and pointed at

The fourth relationship is composition. A filled diamond, for a part whose lifetime is bound to the whole that holds it. Destroy the owner and nothing else in the program still needs the part.

corpus/lru-cache has no clean example of it at the level this problem's classes work at. corpus/parking-lot does, and lesson G1 works its ParkingLot-to-SpotGrid relationship as a full composition example, step by step, timed. Nothing here duplicates that.

The definition you need: built by the owner, for the owner alone, dying when the owner does.

Section 5 is what makes that definition sharper than it looks.

5. What a forty-five-minute round does not need

Skip all of this. None of it changes what a reviewer learns from your sketch, and every item costs seconds the clock does not give back.

Boxes, four arrows, and a count wherever it is not one. That is the whole subset worth carrying into a timed round.

Going deeper

Section 2 called the LruCache-to-EvictionPolicy arrow an association, because it looked like the usual Strategy shape. An interface, injected, presumably swappable and shareable.

Now test that reading against real code, because you are about to find it wrong. LruEvictionPolicy is not stateless. It carries its own bookkeeping.

final class LruEvictionPolicy implements EvictionPolicy {
    private final Map<String, Boolean> order = new LinkedHashMap<>();
    // onInsert, onAccess, onRemove, victim() all read and write "order"
}

Share one LruEvictionPolicy instance between two LruCache objects and both caches write into the same order map.

Here is the whole demonstration, using the package-private constructor both caches share.

EvictionPolicy shared = new LruEvictionPolicy();
LruCache cacheB = new LruCache(2, shared);
LruCache cacheA = new LruCache(2, shared);

cacheB.put("b1", "beta-1");
cacheA.put("a1", "alpha-1");
cacheA.put("a2", "alpha-2");
cacheA.put("a3", "alpha-3");

System.out.println("cacheA.size() reports:    " + cacheA.size());
System.out.println("cacheA.capacity() is:      " + cacheA.capacity());
System.out.println("cacheA has a1? " + cacheA.containsKey("a1"));
System.out.println("cacheA has a2? " + cacheA.containsKey("a2"));
System.out.println("cacheA has a3? " + cacheA.containsKey("a3"));

Run it. Here is the real output.

cacheA.size() reports:    3
cacheA.capacity() is:      2
cacheA has a1? true
cacheA has a2? true
cacheA has a3? true

Read those numbers carefully before you go on.

cacheA was built with a capacity of two and now holds three entries. LruCacheApi's own contract calls that out as never allowed: capacity must never be observably exceeded.

And nothing here is a threading bug. This happened on one thread, in order, every time.

Trace it through yourself. cacheB.put("b1", ...) registered b1 as the oldest key in the shared order map. When cacheA later needed to evict something, victim() correctly named the oldest key it was tracking — b1, a key cacheA had never heard of. Then cacheA.values.remove("b1") silently did nothing, because cacheA never held that key. The eviction that was supposed to make room never freed a single slot.

So what is the lesson? Not "always draw a filled diamond for anything stateful."

The question a filled diamond answers is not "was this built inside a constructor", and it is not "is this an interface". It is this:

Does the object hold state belonging to exactly one owner, such that handing it to a second owner corrupts both?

EvictionPolicy looks exactly like SpotAllocator from corpus/parking-lot. An interface, injected, seemingly interchangeable.

SpotAllocator's implementations hold no state of their own, so sharing one is free. LruEvictionPolicy holds a map keyed by the exact strings one cache's callers chose, so sharing it is a real, demonstrated bug.

Draw the arrow that follows that second fact.

Next: chapter 4.4, Two threads, one object. The question of which state belongs to exactly one owner stops being a question about a diagram there, and becomes a question about a running program.

← 4.2 Saying what you will not build · All chapters · 4.4 Two threads, one object →