SwadhyAI Notes

All Low-Level Design notes

Production LLD

Designing around dependencies

Imagine opening ParkingLot and seeing import com.stripe.StripeClient as the first line. The parking domain now cannot compile without a payments SDK (vendor library), and a unit test cannot park a car without mocking Stripe.

That is the whole problem this page solves. A dependency boundary is a line: inside, your types; outside, someone else's. You cross it through a you own, not through a vendor class. The class that talks to Stripe or Postgres is an ; if the domain API takes StripeToken, that is already an .

What actually happens when the core imports a vendor

The Stripe story above is not a corner case. It is what happens whenever a domain class reaches for a library the moment it needs to charge, persist, mail, or read the time. The compile error and the untestable park method are the same symptom: the arrow of imports points the wrong way.

A is a type whose name you chose, such as PaymentGateway, MailSender, TicketRepository, or Clock. The domain depends on that port. An is the class that implements the port and is allowed to import the vendor. If Order needs StripeCharge, the arrow is already wrong.

Draw the line before you import the SDK. The domain may depend on the port. The adapter depends on the domain and the SDK. Never the other way. A Stripe client or a public static Clock hides the same line: it sneaks past the constructor, so every class can reach it, and every class will.

Simple rule of thumb

Once you can see the wrong arrow, the habit that prevents it is small enough to say in one breath.

Rule of thumb

When you add a library, ask: can a unit test run without it? If no, you glued the core to the vendor. Put a in between, keep vendor types inside the , and construct real adapters only in the assembler (the tiny Main or ParkingModule that wires production).

Concentric rule

Inside your types, outside theirs

The rule of thumb is the same picture drawn as layers. This is the hexagon (ports-and-adapters): your domain in the middle, you own around it, and at the edge. Vendor clients and the system clock never appear inside the green ring.

Adapters · Stripe, Postgres, Clock.systemUTC

Ports · PaymentGateway, TicketRepository, Clock

Domain · Order, Ticket, Money, CheckoutService

No vendor imports

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
  subgraph adapters [Adapters]
    Stripe[StripePaymentGateway]
    Pg[PostgresTicketRepository]
    SysClock[Clock.systemUTC]
  end
  subgraph ports [Ports you own]
    Pay[PaymentGateway]
    Repo[TicketRepository]
    Clk[Clock]
  end
  subgraph domain [Domain]
    Checkout[CheckoutService]
    Order[Order]
  end
  Stripe --> Pay
  Pg --> Repo
  SysClock --> Clk
  Checkout --> Pay
  Checkout --> Repo
  Checkout --> Clk
  Checkout --> Order

The domain names the port

Checkout talks only to types it owns. StripePaymentGateway implements PaymentGateway and is the only class that imports the Stripe SDK. PostgresTicketRepository implements TicketRepository, so the domain compiles without JDBC (the Java database API).

java
interface PaymentGateway {
  PaymentResult charge(OrderId id, Money amount);
}

interface TicketRepository {
  void save(Ticket ticket);
  Optional<Ticket> find(TicketId id);
}

class CheckoutService {
  private final PaymentGateway payments;
  private final Clock clock;
  private final TicketRepository tickets;

  CheckoutService(PaymentGateway payments, Clock clock, TicketRepository tickets) {
    this.payments = payments;
    this.clock = clock;
    this.tickets = tickets;
  }

  Receipt pay(Order order) {
    PaymentResult result = payments.charge(order.id(), order.total());
    Ticket ticket = order.toTicket(result, clock.instant());
    tickets.save(ticket);
    return new Receipt(order.id(), result, clock.instant());
  }
}

class StripePaymentGateway implements PaymentGateway {
  private final StripeClient client;
  public PaymentResult charge(OrderId id, Money amount) {
    Charge charge = client.charges().create(toStripe(id, amount));
    return PaymentResult.from(charge);
  }
}

Where vendors are allowed to meet the app

Those rings only hold if each outside tool has a you can fake, and exactly one place that constructs the real . The table is the library test; the Main listing is the wiring room.

OutsidePort you ownTest double
StripeClientPaymentGatewayFakeGateway that records charges
JavaMailSenderMailSenderRecordingMailbox
DataSource / JPATicketRepositoryInMemoryTicketRepository
LocalDateTime.now()ClockClock.fixed / MutableClock
HttpClient to SMSNotificationChannelRecordingChannel

Assembler is the only place vendors meet

Main (or a tiny ParkingModule) is allowed to import Stripe, JDBC, and Clock.systemUTC(). That is not a leak; that is the wiring room. The leak is when Ticket or CheckoutService does the same import. Keep the assembler boring: construct, inject, start.

java
class Main {
  public static void main(String[] args) {
    Clock clock = Clock.systemUTC();
    TicketRepository tickets = new PostgresTicketRepository(dataSource);
    PaymentGateway payments = new StripePaymentGateway(stripeClient);
    CheckoutService checkout = new CheckoutService(payments, clock, tickets);
    new HttpServer(checkout).start();
  }
}

Machine coding is still a hexagon

You will not build a full ports-and-adapters tree in forty-five minutes. You can still put interfaces next to the lot and HashMap fakes (in-memory maps standing in for a database) in the test source. That is a boundary. A single file that imports Stripe is a demo, not a design.

Where the line usually disappears

The assembler and the table above are the happy path. These three habits put vendor types back inside the domain even after you drew the rings.

Persistence is the same rule

A Ticket entity should not wear JPA annotations (Java Persistence API markers) if that forces the domain to know Hibernate. A persistence model such as TicketRecord can sit in the . Ticket stays a domain type. For machine coding, an interface plus a HashMap fake is the whole boundary; otherwise every test needs Hibernate or a mocking festival.

Static globals hide the line

A Stripe client or a public static Clock sneaks past the constructor. Every class can reach it, so every class will. Pass a repository. Tests pass a fake. Production passes the adapter.

SDK types on the domain API

An such as charge(StripeToken) couples every caller to the vendor. Your port should take your types. The adapter translates.

Wrap-up

You have the line, the rings, and the wiring room. Use these checks to see whether your package still imports the outside world, then follow the related notes.

Check yourself

  1. List the imports in your domain package. Which ones are vendor SDKs?
  2. Can CheckoutService compile if you delete the Stripe dependency from the module?
  3. Where is Clock.systemUTC() constructed: the assembler, or a domain class?

Key takeaway

Draw the line before you import the SDK. Domain depends on ports. Adapters own vendors and JDBC (the clock is wired in Main above). A unit test should run without the outside world.