Syllabus · C5
Interface segregation and minimal public surface
The idea
The one seam this lesson trades away, on purpose
LoggerApi in this corpus has six methods. Clock has one. Neither lets a caller enumerate, remove, or peek at internals. That is narrow on purpose: a caller who only wants to log a line has no use for a method that manages destinations. Look at what a caller actually calls, and give it nothing else.
Now look at corpus/rate-limiter's KeyBudget. It exposes lock() and unlock(), a wider surface than a segregationist would sign off on. Here is why that survives review. A request can answer to more than one budget: its own key, and a shared backstop cap every client draws on together. Checking that every budget in scope has room, then charging every one of them, has to happen under one uninterrupted hold. Otherwise a budget can report room during the check and lose it before the charge lands. A budget cannot order that acquisition on its own, since it does not know what else is in the request's scope. Only the caller does, so only the caller can hold a lock across the sequence, which means it needs a lock to hold, not a method that hides one.
Here is the tell that this is a real trade, not an excuse. lock()/unlock() are package-private, not part of the public contract, and the javadoc states the reason instead of apologizing for it. Widen a surface when narrowing it would move a correctness invariant somewhere that cannot see the whole picture; keep it to what the caller calls.
Worked walkthrough
What each line guarantees, and what breaks without it
Eight files, one deliberate exception to the item this lesson teaches. Run it:
.toolchain/jdk-21/bin/javac -Xlint:all -d out lessons/C5/worked/src/*.java && java -cp out Main
javac -Xlint:all prints nothing. This is a trimmed corpus/rate-limiter: one budget per key instead of several rules, and a plain unit counter instead of a fixed window. No clock either — enough surface to reproduce the one decision this lesson is about.
The default this item asks for, done correctly first
Scopes is one method, and it has two implementations that both get called. OwnScope is today's ordinary answer, and WithBackstop is the honest second case that shows up the moment a request has to satisfy more than one budget. ClientKey and Decision are records with nothing to segregate, one job each, stated in their own compact constructors. That is C5 working the way it is supposed to: a caller gets exactly the method it calls, nothing enumerable, nothing to peek at.
Visibility carries the same argument in a form javac checks for you. Every type in this package except Main and the two record contracts is package-private — no public on KeyBudget, Scopes, OwnScope, WithBackstop, or RateLimiter. Nothing outside this lesson's own package could reach any of them even if it wanted to, which is the narrowest a surface can get.
KeyBudget — the one class that trades the default away
void lock() {
lock.lock();
}
void unlock() {
lock.unlock();
}
Two methods that publish a ReentrantLock's existence to the world, where the narrower instinct says hasRoom() and charge() should each be synchronized and never mention a lock at all. Read the class javadoc before the method bodies — it is the argument, not decoration.
The reason bites only once a request needs more than one budget. Look at RateLimiter.locked:
for (Scoped s : scope) {
s.budget().lock();
}
try {
return body.get();
} finally {
for (int i = scope.size() - 1; i >= 0; i--) {
scope.get(i).budget().unlock();
}
}
This is the only place in the design that acquires more than one lock, and it is the only place that can. RateLimiter is the one object that knows the whole scope a request needs. KeyBudget cannot make this decision about itself. Asked to be self-synchronizing, it would hand back control at the end of hasRoom(), charge() and remaining() alike. A second thread then gets the gap between "own key had room" and "backstop had room" to spend the unit first.
Run block two of Main and lift the backstop afterward. acme's remaining reads 4, not 3: one unit spent on the one request that was actually allowed. That number is only trustworthy because the check and the charge happened under a single hold spanning two objects. That hold has to be a lock the caller can take, not one hidden inside either object.
requireLocked — a precondition stated, not assumed
private void requireLocked() {
if (!lock.isHeldByCurrentThread()) {
throw new AssertionError("this budget must be locked by the calling thread first");
}
}
hasRoom, charge, and remaining all call this first. It is not defending against a hostile caller — the caller is RateLimiter, in the same lesson, written by the same hand. It exists because a missed lock() call, three lines away in locked, is invisible in every single-threaded test and produces a lost update the day two threads actually collide. Delete this guard and faded/GapTest.java's first test stops throwing where it should. The guard turns that failure from silent to loud before a test suite has to go looking for it.
RateLimiter.decide — check everything, then charge everything
for (Scoped s : scope) {
if (!s.budget().hasRoom()) {
return Decision.deny(s.key().value());
}
}
for (Scoped s : scope) {
s.budget().charge();
}
Two loops, not one. The wrong answer here is not a straw man. It is the version a competent engineer writes first, because folding the check and the charge together looks like a saved pass:
for (Scoped s : scope) {
if (!s.budget().hasRoom()) {
return Decision.deny(s.key().value());
}
s.budget().charge();
}
This compiles, and it is correct for OwnScope, where a request only ever touches one budget. It breaks the moment WithBackstop is in play. Suppose acme's own budget has room and gets charged first, and the shared backstop then denies. acme has paid for a request that never went through. Run Main's second block against this version and acme's remaining reads one lower than it should, silently. faded/GapTest.java's second test is built specifically to catch it.
RateLimiter.resolve — a key nobody configured is unlimited, not a crash
for (ClientKey name : scopes.forRequest(key)) {
KeyBudget budget = budgets.get(name);
if (budget != null) {
found.add(new Scoped(name, budget));
}
}
Drop the if and an unconfigured key does not get denied — it gets a NullPointerException three calls later, from inside fewestRemaining, at a line that has nothing wrong with it. The bug is that a scope can name a budget that was never built. The fix is not a null check scattered at every call site; it is one filter, in the one place scope gets turned into budgets.
What stays deliberately thin
Scopes never grew a third method for "how many budgets a request has" or "which budget denied last" — nothing calls for either, and inventing them would be the failure when-not.md prices. ClientKey and Decision were left as records rather than given builders or setters: there is nothing partial about either one worth building up in steps.
When not to
The other direction this item penalises
STANDARD v1.0's D3 level 3 is explicit that over-abstraction costs as much as no abstraction at all. The named failure is "a speculative interface with a single implementation and no foreseeable second one," tagged over-engineered. Interface segregation is where that failure hides best, because splitting a class into more, smaller interfaces always looks like more discipline, never less.
Here is the concrete bad version, built from this lesson's own code. Split KeyBudget's three domain methods into role interfaces:
interface Chargeable { boolean hasRoom(); void charge(); }
interface Readable { int remaining(); }
interface Lockable { void lock(); void unlock(); }
final class KeyBudget implements Chargeable, Readable, Lockable { /* same body */ }
Three new files, zero behaviour changed, and RateLimiter still only ever holds a KeyBudget — nothing in this lesson accepts a Chargeable without also needing it to be Lockable in the same breath. There is no second implementation of any of the three, and none is named anywhere in the requirements. A reader who wants to know what a budget can do now opens four files instead of one.
The threshold that separates this from a real split: a second implementation exists, or two callers genuinely want different subsets of the same object's behaviour. Neither holds here. Two corpus decisions make the identical call. corpus/logger/reference/DECISION_LOG.md rejects a Formatters utility class with a built-in default. Every test already supplies its own formatter, so a default nobody must use would be "one more contract type existing only to be optional." lessons/C1/when-not.md catches the same shape one level down: PathSplitter, PathValidator, and PathJoiner, three files that each forward to PathSyntax and share its one row in the change table. A new path rule now has four candidate homes instead of one.
The honest version of this item is therefore two-sided. KeyBudget under-segregates one method pair on purpose, defended in writing. A Chargeable/Readable/Lockable split over-segregates three methods that were never asked to vary independently. Both are real ways to fail C5. Only one of them looks like effort.
Worked source
The 8 files of the worked design
Every file below is the one the app opens, verbatim. This is the part worth reading slowly: the prose above argues for a shape, and these are the lines that have it.
worked/src/ClientKey.java15 linesworked/src/Decision.java26 linesworked/src/KeyBudget.java75 linesworked/src/OwnScope.java10 linesworked/src/RateLimiter.java100 linesworked/src/Scopes.java13 linesworked/src/WithBackstop.java20 linesworked/src/Main.java33 lines
worked/src/ClientKey.java15 lines
import java.util.Objects;
/**
* The key everything here is budgeted by. A record, because a client key is a fact handed in at
* the boundary and never mutated afterward.
*/
public record ClientKey(String value) {
public ClientKey {
Objects.requireNonNull(value, "value");
if (value.isBlank()) {
throw new IllegalArgumentException("a client key cannot be blank");
}
}
}
worked/src/Decision.java26 lines
/**
* What one request got told: allowed, or denied and by which budget.
*
* The compact constructor is the only place this invariant has to be written down: an allowed
* decision names no denier, and a denial always names one. Nothing that reads a Decision later
* has to re-check which case it is looking at before trusting the field.
*/
public record Decision(boolean allowed, String deniedBy) {
public Decision {
if (allowed && deniedBy != null) {
throw new IllegalArgumentException("an allowed decision cannot also name a denier");
}
if (!allowed && deniedBy == null) {
throw new IllegalArgumentException("a denial has to name the budget that refused");
}
}
public static Decision allow() {
return new Decision(true, null);
}
public static Decision deny(String budget) {
return new Decision(false, budget);
}
}
worked/src/KeyBudget.java75 lines
import java.util.concurrent.locks.ReentrantLock;
/**
* One key's unit budget, and the lock that guards it.
*
* <h2>The interface this class does not have</h2>
* The narrow version of this class hides its lock and makes {@link #hasRoom()} and
* {@link #charge()} each {@code synchronized} on itself, so nothing outside ever sees a lock at
* all. That is the version interface segregation asks for by default, and it is wrong for this
* design specifically: {@link RateLimiter} may have to satisfy more than one budget for a single
* request — a client's own key, and a shared backstop cap every client draws on together — and
* checking that every budget in scope has room, then charging every one of them, has to happen
* under one uninterrupted hold. Self-synchronizing methods release the lock between the check and
* the charge, and between one budget and the next, which is exactly the gap a second thread needs.
*
* <p>A budget cannot close that gap itself, because it does not know what else is in the request's
* scope — only {@link RateLimiter} does. So the lock has to be something the caller can hold across
* several calls on several objects, which means it has to be exposed. {@link #lock()} and
* {@link #unlock()} are that exposure: a wider surface than a segregationist would sign off on,
* kept package-private and defended here rather than hidden. This is the identical trade
* {@code corpus/rate-limiter/reference/src/KeyBudget.java} makes, for the identical reason.
*/
final class KeyBudget {
private final int limit;
private final ReentrantLock lock = new ReentrantLock();
private int used;
KeyBudget(int limit) {
if (limit < 1) {
throw new IllegalArgumentException("a limit below 1 denies everything; say so directly");
}
this.limit = limit;
}
void lock() {
lock.lock();
}
void unlock() {
lock.unlock();
}
/** Whether one more unit fits. Charges nothing. */
boolean hasRoom() {
requireLocked();
return used < limit;
}
/** Spend one unit. Only ever called after {@link #hasRoom()} answered true under the same hold. */
void charge() {
requireLocked();
used++;
}
/** How many units are left. */
int remaining() {
requireLocked();
return limit - used;
}
/**
* The precondition of the three methods above, checked rather than trusted.
*
* This is not defensive programming against a hostile caller — it is the cheapest possible
* guard on the one mistake that would make this class silently wrong instead of loudly broken.
* A missed lock produces a lost update under load and passes every single-threaded test, which
* is exactly the failure this assertion turns from silent into loud.
*/
private void requireLocked() {
if (!lock.isHeldByCurrentThread()) {
throw new AssertionError("this budget must be locked by the calling thread first");
}
}
}
worked/src/OwnScope.java10 lines
import java.util.List;
/** Today's ordinary answer: a request answers to its own key, and nothing else. */
final class OwnScope implements Scopes {
@Override
public List<ClientKey> forRequest(ClientKey key) {
return List.of(key);
}
}
worked/src/RateLimiter.java100 lines
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Supplier;
/**
* Holds the budgets and performs the all-or-nothing commit. It contains no counting rule of its
* own and cannot reach into a budget's counters directly — see {@link KeyBudget}.
*/
final class RateLimiter {
/** One budget in scope, paired with the key it was resolved under, so a denial can name it. */
private record Scoped(ClientKey key, KeyBudget budget) {}
private final Scopes scopes;
private final Map<ClientKey, KeyBudget> budgets = new HashMap<>();
RateLimiter(Scopes scopes) {
this.scopes = Objects.requireNonNull(scopes, "scopes");
}
void configure(ClientKey key, int limit) {
budgets.put(key, new KeyBudget(limit));
}
Decision tryAcquire(ClientKey key) {
List<Scoped> scope = resolve(key);
return locked(scope, () -> decide(scope));
}
int remaining(ClientKey key) {
List<Scoped> scope = resolve(key);
return locked(scope, () -> fewestRemaining(scope));
}
/**
* Check every budget in scope before charging any of them.
*
* A single loop that checked and charged one budget at a time would leak: a client's own
* budget would be spent by a request the shared backstop went on to refuse, which under load
* empties it without a single request getting through.
*/
private Decision decide(List<Scoped> scope) {
for (Scoped s : scope) {
if (!s.budget().hasRoom()) {
return Decision.deny(s.key().value());
}
}
for (Scoped s : scope) {
s.budget().charge();
}
return Decision.allow();
}
private static int fewestRemaining(List<Scoped> scope) {
int fewest = Integer.MAX_VALUE;
for (Scoped s : scope) {
fewest = Math.min(fewest, s.budget().remaining());
}
return fewest;
}
/**
* The budgets a request from {@code key} must satisfy, in lock order.
*
* A key nobody has configured is skipped rather than blocking the request: unconfigured means
* unlimited, not denied, and it must never crash the request either.
*/
private List<Scoped> resolve(ClientKey key) {
List<Scoped> found = new ArrayList<>();
for (ClientKey name : scopes.forRequest(key)) {
KeyBudget budget = budgets.get(name);
if (budget != null) {
found.add(new Scoped(name, budget));
}
}
return found;
}
/**
* Acquire every budget in scope, in order; release in reverse, whatever {@code body} does.
*
* The only multi-lock acquisition in this design. One method means one order, and one order
* means no cycle.
*/
private <T> T locked(List<Scoped> scope, Supplier<T> body) {
for (Scoped s : scope) {
s.budget().lock();
}
try {
return body.get();
} finally {
for (int i = scope.size() - 1; i >= 0; i--) {
scope.get(i).budget().unlock();
}
}
}
}
worked/src/Scopes.java13 lines
import java.util.List;
/**
* The budgets one request must satisfy, in the order {@link RateLimiter} should lock them.
*
* One method, because deciding scope is the only thing a caller of this interface ever needs from
* it. This is the seam built the way C5 recommends everywhere in this lesson except
* {@link KeyBudget} — a single abstract method, two real implementations, and nothing offered that
* {@link RateLimiter} does not call.
*/
interface Scopes {
List<ClientKey> forRequest(ClientKey key);
}
worked/src/WithBackstop.java20 lines
import java.util.List;
/**
* A request answers to its own key AND a shared cap every client draws on together.
*
* The backstop is always second, and that is not a style choice. {@link RateLimiter} locks in the
* order this method returns, {@link #BACKSTOP} is the same key object for every client's request,
* and putting it in the same position every time is what keeps two different clients' requests
* from ever locking [own, backstop] against [backstop, own] — a cycle two threads could deadlock
* on. One fixed order removes the question rather than answering it correctly by luck.
*/
final class WithBackstop implements Scopes {
static final ClientKey BACKSTOP = new ClientKey("__backstop__");
@Override
public List<ClientKey> forRequest(ClientKey key) {
return List.of(key, BACKSTOP);
}
}
worked/src/Main.java33 lines
/**
* The seam only matters once a request spans more than one budget. This shows both cases.
*/
public final class Main {
public static void main(String[] args) {
ClientKey acme = new ClientKey("acme");
System.out.println("-- one budget, own scope, the ordinary case --");
RateLimiter solo = new RateLimiter(new OwnScope());
solo.configure(acme, 2);
knock(solo, acme, 3);
System.out.println();
System.out.println("-- two budgets: acme's own key, plus a shared backstop --");
RateLimiter guarded = new RateLimiter(new WithBackstop());
guarded.configure(acme, 5); // acme's own room is generous
guarded.configure(WithBackstop.BACKSTOP, 2); // the shared cap is the tight one
knock(guarded, acme, 3);
System.out.println("-- lift the backstop so it cannot be the bottleneck, and read acme alone --");
guarded.configure(WithBackstop.BACKSTOP, 100);
System.out.println(" acme's own remaining: " + guarded.remaining(acme)
+ " (2 charged, 1 denied and NOT charged, out of 5)");
}
private static void knock(RateLimiter limiter, ClientKey key, int times) {
for (int i = 0; i < times; i++) {
Decision d = limiter.tryAcquire(key);
System.out.println(" " + (d.allowed() ? "ALLOWED" : "DENIED by " + d.deniedBy()));
}
}
}
The faded stage is not here, on purpose
In the app, the third stage of a lesson hands you the worked design with a few lines
replaced by // GAP: markers, then compiles your completion and runs a JUnit suite
against it. That needs javac, and a static site has no compiler — so rather than show a
control that cannot work, this page stops at the worked source.
Run the app for the drill: it is the download in the header, and it works offline once unpacked.