SwadhyAI Notes

All Low-Level Design notes

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

1

Statics

FeeCalculator.now() is a static clock you cannot freeze in a unit test.

2

new in the method

new Postgres, Random, or FileWriter inside a method pins you to real I/O.

3

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.

Diagram text: the exact Mermaid definition below preserves the nodes, labels, and connections. Enable JavaScript to view the rendered diagram.
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 --> Repo

After 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 testGlued thingPort to inject
Fee depends on midnightWall clock via LocalDateTime.now()Clock
Cannot park without Postgresnew Repository() in the methodTicketRepository
SMS fires in CISmsClient constructed inlineNotificationChannel
Ticket ids never matchUUID.randomUUID() inside createIdGenerator
Twenty mocks to constructGod object with every verbSplit 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.

java
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.

java
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

  1. List every collaborator ParkingService needs. Which of them is currently a static or a new?
  2. A test needs twenty mocks. Name two classes you would extract first.
  3. 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.