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 turns into 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 about the design, and it needs to take 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. This costs about fifteen seconds and answers nothing yet, but it is a real, defensible start: every name on it is a class from corpus/lru-cache/reference/src and corpus/lru-cache/contract, not 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. The field is injected, and nothing about the constructor says the cache has to keep it forever or that no other object could hold the same policy. Draw that as a plain arrow, and 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 one 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, and the 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, with no dashed one beside it, would have been true and would 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, and nothing about a listener's own existence depends on any cache calling it. This 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 here: a cache may have no listeners registered at all, or several. Compare that to LruCache's single EvictionPolicy, always exactly one, never written on the diagram at all, because a count that is always one says nothing a reader could not already assume. A count on the page 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 rather than 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 to exist. corpus/lru-cache does not have a clean example of it at the box level this problem's own classes work at. corpus/parking-lot does: chapter 4.1 and 4.2 already used that corpus, and lesson G1 works its ParkingLot-to-SpotGrid relationship as a full composition example, step by step, timed. Nothing here duplicates that walkthrough. The definition is: built by the owner, for the owner alone, dying when the owner does. Section 5 below is what makes that definition sharper than it looks.

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

Skip all of the following. None of it changes what a reviewer learns from the sketch, and every item on this list costs seconds a 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 association, because it looked like the usual Strategy shape: an interface, injected, presumably swappable and shareable. Test that reading against real code. 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 now 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, and 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

cacheA was built with a capacity of two and now holds three entries, which LruCacheApi's own contract calls out as never allowed: capacity must never be observably exceeded. Nothing here is a threading bug. It happened on one thread, in order, every time. 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, which was b1, a key cacheA had never heard of. cacheA.values.remove("b1") silently did nothing, because cacheA never held that key, and the eviction that was supposed to make room never freed a single slot in cacheA's own map.

The lesson is not "always draw a filled diamond for anything stateful." The question a filled diamond actually 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 that belongs to exactly one owner, so 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. The arrow you draw should follow that second fact, not the first.

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 →