Production LLD
Designing code that is easy to test
You want to assert a two-hour parking fee. The fee method asks the wall clock (real system time) and constructs new PostgresTicketRepository(). The test either waits two hours or stands up a real database. That is not a testing-tool problem; a collaborator is glued down inside the method.
Testable design means each use case depends on injected ports (interfaces you own: Clock, repositories, notifiers) so a test can freeze time and pass fakes (working in-memory stand-ins). A or a makes that impossible. The rest of this page shows how to spot the glue and invert it.
Three ways a test gets stuck
Statics
FeeCalculator.now() is a static clock you cannot freeze in a unit test.
new in the method
new Postgres, Random, or FileWriter inside a method pins you to real I/O.
Twenty mocks
Twenty mocks for one constructor means a God object wearing a test hat.
What actually happens when a dependency is glued
The two-hour fee story above is the default whenever production I/O lives inside the method you want to test. LocalDateTime.now() reads the real clock, so a "parked for two hours" assertion cannot run in milliseconds. new PostgresTicketRepository() opens a real store, so every unit test needs a database. An inline SmsClient fires a real text from CI (the automated build), and UUID.randomUUID() makes ticket ids you can never match.
The way out is not more stubbing. A port is an interface the use case owns (Clock, TicketRepository, NotificationChannel). An adapter is the production class that implements it (system clock, Postgres, SMS). Tests skip production wiring and pass fakes: a FixedClock, a map-backed store, a quiet notifier. That is in parking-lot clothes.
An (Main or a ParkingModule) is the only place that constructs Clock.systemUTC(), PostgresTicketRepository, and HourlyPricing, then injects them. The domain never calls new on those adapters. Keep logic out of constructors and statics; if construction opens a socket, you no longer have a unit test.
Simple rule of thumb
Once you can name the glued collaborator, the design move is always the same. Do not reach for another mocking library.
If the test is painful, invert the dependency
A painful test means a concrete is glued down. Invert it: inject Clock, ids, repositories, and strategies through the constructor. Keep logic out of constructors and statics. Pain in the test is a smell in the types, not a Mockito (Java mocking library) shortage.
Invert the arrows, then name the glue
The rule above says invert. is the picture: ParkingService depends on ports, not on Postgres or the wall clock. An in Main chooses the adapters. Tests skip Main and pass fakes.
Read the diagram definition
flowchart TB
Main[Main assembler]
Svc[ParkingService]
Clock[Clock]
Ids[IdGenerator]
Alloc[SpotAllocationStrategy]
Price[PricingStrategy]
Repo[TicketRepository]
Main --> Svc
Main --> Clock
Main --> Ids
Main --> Alloc
Main --> Price
Main --> Repo
Svc --> Clock
Svc --> Ids
Svc --> Alloc
Svc --> Price
Svc --> RepoAfter the arrows point at ports, use the table to diagnose which dependency is still glued. Each row is a test pain, the concrete that caused it, and the port to inject instead.
| Pain in the test | Glued thing | Port to inject |
|---|---|---|
| Fee depends on midnight | Wall clock via LocalDateTime.now() | Clock |
| Cannot park without Postgres | new Repository() in the method | TicketRepository |
| SMS fires in CI | SmsClient constructed inline | NotificationChannel |
| Ticket ids never match | UUID.randomUUID() inside create | IdGenerator |
| Twenty mocks to construct | God object with every verb | Split by reason to change |
Worked example: exit() glued, then injected
The table names the ports. Here is the same exit use case before and after injection, so you can see every leave the method body.
Glued
new and now() inside exit
A hidden dependency in every line. Primitive money and a live SMS client ride along.
class ParkingService {
Receipt exit(TicketId id) {
Instant now = LocalDateTime.now().toInstant(ZoneOffset.UTC); // wall clock
Ticket ticket = new PostgresTicketRepository().find(id).orElseThrow();
Money fee = hours * 10; // primitive obsession hiding in the same method
new SmsClient().send(ticket.email, "fee " + fee);
return new Receipt(ticket.id(), fee, now);
}
}Injected
No wall clock, no new Postgres
Constructor lists every collaborator. Tests control Clock, ids, allocation, pricing, store, and channel.
class ParkingService {
private final Clock clock;
private final IdGenerator ids;
private final SpotAllocationStrategy allocation;
private final PricingStrategy pricing;
private final TicketRepository tickets;
private final NotificationChannel notify;
ParkingService(
Clock clock,
IdGenerator ids,
SpotAllocationStrategy allocation,
PricingStrategy pricing,
TicketRepository tickets,
NotificationChannel notify) {
this.clock = clock;
this.ids = ids;
this.allocation = allocation;
this.pricing = pricing;
this.tickets = tickets;
this.notify = notify;
}
Receipt exit(TicketId id) {
Instant now = clock.instant();
Ticket ticket = tickets.require(id);
Money fee = pricing.fee(ticket, now);
ticket.close(now);
tickets.save(ticket);
notify.send(ReceiptIssued.of(ticket, fee));
return new Receipt(ticket.id(), fee, now);
}
}Constructors, statics, and how many fakes is too many
Injection is not enough if construction does I/O, a static hides a clock, or one class owns every verb. The injected exit above stays testable only when these three habits hold.
Keep logic out of constructors
A constructor that opens a socket cannot be unit-tested. Construct values. Open I/O in adapters, not in Ticket.
Statics are globals
A static FeeCalculator.now() is a Clock you cannot fake. A public static repository is a boundary nobody sees.
God object
A is untestable because you cannot set up "just allocation." After you extract strategies, pricing tests need ten lines, not a theatre of mocks.
Numbers to know
- A handful (Clock, ids, one fake repository, one strategy) is one use case.
- Three to six ports should match reasons to change: time, identity, allocation, price, persistence, notify.
- Twenty mocks means the class has too many friends; shrink it (see the hero preview).
Healthy constructor
Ports stay in that three-to-six band (see Numbers to know). Testable design and good LLD are the same skill with different evidence.
Unhealthy constructor
Stripe, JavaMail, a thread pool, a GUI frame, and a static logger. Split until each use case is small.
What still goes wrong after you inject
Putting the pieces together, test pain is a design dashboard. Read the symptom, then invert; do not add more stubbing. These are the leftovers that still show up after the constructor lists every port.
Common leftovers
- :
new Random(),new FileWriter(), or now() still pin you to the real world. - A theatre of mocks is the from Numbers to know above.
- Sockets, files, or listeners in
new ParkingLot()make every test an integration test.
How to read the dashboard
- Flaky fees mean Clock is missing, so freeze time through the port.
- A database in a unit test means the repository is not a port.
- A test that cannot construct ParkingLot without Scanner means I/O leaked into the domain.
Wrap-up
You have seen the glued exit, the inverted arrows, and the dashboard. Use these checks to see whether the ideas stuck, then follow the related notes.
How to explain this in an interview
Walk the diagnosis table, then the inverted-arrow diagram. Name the as the only place that constructs production adapters, and as why ParkingService never calls new on Postgres. Testable design and good LLD are the same skill with different evidence.
Check yourself
- List every collaborator ParkingService needs. Which of them is currently a static or a new?
- A test needs twenty mocks. Name two classes you would extract first.
- Where should Clock.systemUTC() be constructed: the domain, or the assembler?
Key takeaway
If it is hard to test, a concrete is glued down (see the rule of thumb). A painful test is a design smell, not a Mockito shortage.