Depend on a role, not a brand
Interfaces and contracts
An interface names a role — what a collaborator must do — not a brand of implementation. Example: ParkingLot constructs PostgresTicketRepository and calls it by name. Tests now need a database. Next week the interviewer wants an in-memory map. If the lot had depended on TicketRepository, both implementations would fit. The lot needed a , not a brand of storage.
SpotAllocationStrategy
choose returns a free spot that fits, or empty. It does not occupy.
PricingStrategy
feeFor is deterministic for the same duration and vehicle.
TicketRepository
save then findById returns that ticket. Same id replaces or rejects — pick one.
Clock
now() is the only time source the domain uses.
What role does the caller need?
An interface is a for a role. Allocation. Pricing. Persistence. Time. The caller depends on the methods and on what they promise. It does not depend on Postgres, on NearestSpot, or on System.now(). Keep interfaces small. SpotAllocationStrategy.choose. PricingStrategy.feeFor. TicketRepository.save and findById. Clock.instant. Do not make ParkingLotAPI with twenty methods so one class can implement “the system.” That is a . Nobody fakes it honestly.
You do not need an interface for every class. Floor can stay a concrete type if there is one kind of floor. Introduce a role when something will vary, or when a test must substitute it. That is the .
Depend on the role
interface SpotAllocationStrategy {
Optional<ParkingSpot> choose(List<ParkingSpot> free, Vehicle vehicle);
}
interface PricingStrategy {
Money feeFor(Duration parked, Vehicle vehicle);
}
interface TicketRepository {
void save(Ticket ticket);
Optional<Ticket> findById(TicketId id);
}
interface Clock {
Instant now();
}
class ParkingLot {
private final SpotAllocationStrategy allocation;
private final PricingStrategy pricing;
private final TicketRepository tickets;
private final Clock clock;
ParkingLot(
SpotAllocationStrategy allocation,
PricingStrategy pricing,
TicketRepository tickets,
Clock clock) {
this.allocation = allocation;
this.pricing = pricing;
this.tickets = tickets;
this.clock = clock;
}
}One huge interface for the whole lot
ParkingManager with park, price, persist, notify, and report forces every fake to implement jobs it does not have. Split roles. That is Interface Segregation without the slogan.
IParkingLot for a single class
An interface with one implementation and no test double is noise. Add a role when something varies or when you must substitute it. Prefixing I on every class is not a design.
The contract is more than the signature
choose must not occupy. If NearestSpot calls tryOccupy inside choose, the lot will occupy twice — or the strategy will hide failures. save of the same id must have a stated rule: replace, or reject. Clock must be the only now(). If exit also calls Instant.now(), tests that freeze the clock still flake. Write those sentences next to the interface. Implementations are guilty until they honor them.
A fake that is honest because the role is small
final class FixedClock implements Clock {
private Instant now;
FixedClock(Instant now) { this.now = now; }
void advance(Duration d) { now = now.plus(d); }
public Instant now() { return now; }
}
final class MapTicketRepository implements TicketRepository {
private final Map<TicketId, Ticket> byId = new HashMap<>();
public void save(Ticket ticket) { byId.put(ticket.id(), ticket); }
public Optional<Ticket> findById(TicketId id) {
return Optional.ofNullable(byId.get(id));
}
}Who owns the interface?
The caller’s package should own the role. ParkingLot needs a TicketRepository. The Postgres adapter implements that role from the outside. That is dependency inversion: high-level policy does not import a driver. The same move makes Strategy and Repository the same shape — a role with a promise — used for policy versus memory.
Protected variations is the older name for “put a seam where change is likely.” Allocation and pricing change in almost every follow-up. Time must be substitutable for tests. Persistence must be substitutable for machine coding without a database. Floor usually does not. Do not interface-wash the whole model.
When a concrete class is the right contract
Money, TicketId, Duration — these are values. Their constructors are the contract. An IMoney is ceremony. Ticket can stay a class if there is one kind of ticket. Introduce Ticket only as a role if you truly have seasonal tickets with a different life. Most rounds do not. Save the interface budget for things that vary or that you must fake.
Document the promise in the type, not in a comment pile
Method names do a lot: findById, tryOccupy, feeFor. Return types do more: Optional empty means not found, not “error.” Exceptions do the rest: IllegalStateException for a broken life, IllegalArgumentException for a bad value. If you need a paragraph to explain choose, the role is doing two jobs. Split it.
Then say the promise out loud in the interview: “Allocation chooses. It does not occupy. The lot occupies after choose returns. The repository replaces on the same id. Clock is injected.” That is a contract. IParkingLot is not.
The lot’s constructor is the design
If ParkingLot’s constructor takes allocation, pricing, tickets, and a clock, you have already named the seams. The rest of the round is implementing those roles. If the constructor takes nothing and news up Postgres inside park(), you will rewrite the class when the follow-up arrives.
Allocation must not occupy — write it in the type
Returning Optional<ParkingSpot> is not enough if the strategy mutates the spot. A test that checks choose then occupy will double-book. Keep choose pure. Let the lot occupy. If you want the strategy to see only free spots, pass a list the lot already filtered — or pass a query object that cannot mutate.
final class NearestSpot implements SpotAllocationStrategy {
public Optional<ParkingSpot> choose(List<ParkingSpot> free, Vehicle vehicle) {
return free.stream()
.filter(s -> s.fits(vehicle))
.findFirst();
// no tryOccupy here
}
}
class ParkingLot {
Ticket park(Vehicle vehicle) {
ParkingSpot spot = allocation.choose(freeSpots(), vehicle)
.orElseThrow(() -> new IllegalStateException("full"));
if (!spot.tryOccupy(vehicle)) {
throw new IllegalStateException("raced");
}
return Ticket.issue(spot.id(), vehicle, clock.now());
}
}Optional empty is a contract
findById returning Optional empty means “no such ticket,” not “database down.” Database down is an exception from the adapter. Mixing those two makes every caller write the same confused if. choose empty means “no fit,” not “list was null.” Pass an empty list, not null. The role’s types are part of the promise. Null is not a type.
Notify is a role too — and it is not the lot
If the follow-up is SMS on exit, add a Notifier role with one method. Do not put sendSms on TicketRepository or on ParkingLot. The lot can accept a Notifier in the constructor, or the application service can notify after exit returns. Either way the contract is “tell someone a receipt exists,” not “the database also texts.”
Clock is the role people skip
Fees depend on now. Tests that sleep are slow and flaky. A Clock port is three lines and saves the round. SystemClock in production. FixedClock in tests. Do not pass Instant now through every method as a habit that you then forget on one path. One port. Every duration reads it.
Random is the same idea for dice and load-balancing. Inject a Random or a Die. Then snake-and-ladder tests can force a six. That is a contract: nextInt is the only source of chance. Hidden new Random() inside roll() is an untestable brand, not a role.
Constructor arguments are the seam list
Read ParkingLot’s constructor out loud. Those types are the roles you chose. If a follow-up cannot be answered by swapping one of them, you missed a seam or you are looking at a true new entity. Either diagnosis is useful. A constructor that news up concretes has no diagnosis left — only a rewrite.