SOLID · D
Dependency Inversion
Dependency Inversion means high-level policy depends on abstractions you own, not on low-level details such as database drivers or vendor SDKs. Both the policy and the details depend on that shared abstraction.
For example, fee calculation imported java.sql.Connection and built INSERT statements. A unit test then needed a live database. The domain had married the storage. Depend on TicketRepository — an interface you own — not on the driver behind it.
Does the domain import the database?
In plain words: your business code should talk to a simple interface you own, not to a database driver or a vendor SDK. High-level policy (parking rules) must not depend on low-level details (JDBC, Twilio, Razorpay). Both depend on the abstraction.
The arrow of source-code imports is the design. If Ticket.pay() imports StripeClient, the domain cannot move without Stripe. If Checkout talks to a PaymentGateway interface, a fake gateway can sit in a test and a Stripe adapter can sit at the edge.
You own the interface. That is the inversion. Postgres does not define TicketRepository. Your domain does. The Postgres class implements it. Details plug into policy, not the other way around. Dependency injection is how you pass the implementer in — a constructor argument. Dependency Inversion is why that argument is an interface you designed. Do not confuse the wiring with the habit.
The arrow that matters
Wrong direction versus inverted
Wrong arrow ParkingLot ──imports──► JdbcTicketStore (policy) (detail) Tests need Postgres. A vendor bump recompiles the lot. Inverted arrow ParkingLot ──depends on──► TicketRepository ◄──implements── JdbcTicketStore (policy) (interface you own) (detail) Tests pass InMemoryTicketRepository. Production passes JdbcTicketRepository. The lot file does not mention SQL.
Java: the domain owns the abstraction
ParkingService closes a stay. It loads a ticket, asks the ticket to close, and saves. It never mentions PreparedStatement. The in-memory map and the JDBC class both implement the same interface you own. That is also how you get testable design without a database in the unit tests.
interface TicketRepository {
void save(Ticket ticket);
Optional<Ticket> find(TicketId id);
}
class ParkingService {
private final TicketRepository tickets;
private final PricingStrategy pricing;
ParkingService(TicketRepository tickets, PricingStrategy pricing) {
this.tickets = tickets;
this.pricing = pricing;
}
Money close(TicketId id, Clock clock) {
Ticket ticket = tickets.find(id).orElseThrow();
ticket.close(clock.instant());
Money due = pricing.fee(ticket, clock);
tickets.save(ticket);
return due;
}
}
class InMemoryTicketRepository implements TicketRepository {
private final Map<TicketId, Ticket> store = new HashMap<>();
public void save(Ticket ticket) { store.put(ticket.id(), ticket); }
public Optional<Ticket> find(TicketId id) { return Optional.ofNullable(store.get(id)); }
}
class JdbcTicketRepository implements TicketRepository {
private final DataSource dataSource;
public void save(Ticket ticket) { /* SQL at the edge only */ }
public Optional<Ticket> find(TicketId id) { /* SQL at the edge only */ }
}Injection is not inversion
You can inject a concrete JdbcTicketRepository through the constructor and still have the domain depend on JDBC types if the constructor parameter is the concrete class, or if the interface lives in the jdbc package and exposes Connection.
Inversion is about which module owns the type. Put TicketRepository next to the use case, in the parking package. Put JdbcTicketRepository in persistence. The persistence module imports the domain. The domain does not import persistence. That is the inverted arrow.
Still not inverted
An interface in the jdbc package named TicketDao that returns ResultSet is a rename, not an inversion. The domain still thinks in storage ideas. An interface you own should speak tickets, not rows.
new JdbcTicketRepository() inside ParkingService also kills the habit. The interface is useless if the service constructs the concrete class. Pass it in.
Vendors get the same treatment
Twilio, Stripe, and a thermal printer are details. The stay should not import them. Notifier and PaymentGateway are interfaces you own. Adapters translate. When the vendor changes on Tuesday, you edit the adapter, not Ticket.
interface Notifier {
void ticketClosed(Ticket ticket, Money due);
}
class LoggingNotifier implements Notifier {
public void ticketClosed(Ticket ticket, Money due) {
System.out.println(ticket.id() + " due " + due);
}
}
class TwilioNotifier implements Notifier {
private final TwilioClient client;
public void ticketClosed(Ticket ticket, Money due) {
client.send(ticket.phone(), "Pay " + due + " for ticket " + ticket.id());
}
}
class ExitService {
private final TicketRepository tickets;
private final PricingStrategy pricing;
private final Notifier notifier;
Money handle(TicketId id, Clock clock) {
Ticket ticket = tickets.find(id).orElseThrow();
ticket.close(clock.instant());
Money due = pricing.fee(ticket, clock);
tickets.save(ticket);
notifier.ticketClosed(ticket, due);
return due;
}
}Where to put the interface
Next to the use case, not next to the driver. If TicketRepository lives in com.lot.persistence, every domain class that needs tickets must import persistence. That is the old arrow with extra steps. Put it in com.lot.tickets or beside ParkingService. The repository page is the persistence-shaped version of this habit. The dependency injection page is the wiring.
In a two-hour machine-coding round you will not build packages. You will still invert the arrow: interfaces at the top of the file next to the service, JDBC at the bottom or in a separate class the tests never import. Say that out loud so the interviewer sees the habit without a Maven module.
Clock is an abstraction too
Instant.now() inside Ticket couples tests to the wall clock. Pass a Clock. That is Dependency Inversion applied to time: policy depends on an abstraction Java already gave you. Fee tests freeze the clock, advance it two hours, and assert the money. No Thread.sleep. No flaky duration.
class Ticket {
private final Instant enteredAt;
Duration parked(Clock clock) {
return Duration.between(enteredAt, clock.instant());
}
}
// In tests:
Clock clock = Clock.fixed(Instant.parse("2026-08-13T10:00:00Z"), ZoneOffset.UTC);
Ticket ticket = lot.enter(car, spot, clock);
Clock later = Clock.offset(clock, Duration.ofHours(2));
Money due = pricing.fee(ticket, later);Composition root: the one place that may name JDBC
Main, or a small ParkingApp factory, constructs JdbcTicketRepository, TwilioNotifier, HourlyPricing, and ParkingService, then wires them. That file is allowed to import details. Domain files are not. If ParkingService still says new Jdbc..., you moved the constructor into the wrong class. The root is the seam tests replace with fakes.
class ParkingApp {
static ExitService exit(DataSource ds, TwilioClient twilio) {
TicketRepository tickets = new JdbcTicketRepository(ds);
PricingStrategy pricing = new HourlyPricing();
Notifier notifier = new TwilioNotifier(twilio);
return new ExitService(tickets, pricing, notifier);
}
}
class ExitServiceTest {
@Test void charges_two_hours() {
TicketRepository tickets = new InMemoryTicketRepository();
ExitService exit = new ExitService(tickets, new HourlyPricing(), new LoggingNotifier());
// no DataSource, no Twilio
}
}Money and time are details too if you hide them badly
java.time.Clock is an abstraction you already own in the platform sense: you did not invent it, but the domain depends on Clock rather than Instant.now(). Money should be your type, not a raw int of paise scattered through JDBC. The repository translates Money to a BIGINT at the edge. That translation is the adapter’s job, not Ticket’s.
If Ticket stores a String of SQL column names “for convenience,” the domain has imported persistence ideas without importing java.sql. Inversion is conceptual, not only a missing import. Speak tickets and money in the middle; speak rows only in JdbcTicketRepository.
Related
Tests are how you know the arrow flipped
If ExitServiceTest imports DataSource, the domain still depends on storage, or the test is not a unit test. Flip the arrow until the test file’s imports are Ticket, Money, Clock, and in-memory fakes. JDBC tests can exist, but they live next to JdbcTicketRepository and they are not the tests that grade fee math.
Package sketch you can say without Maven
In a round, draw three boxes: app (ParkVehicle, ExitVehicle), domain (Ticket, ParkingLot, PricingStrategy), and persistence (JdbcTicketRepository). Arrows point inward to domain and toward interfaces owned by app/domain. Persistence points at TicketRepository. Domain does not point at persistence. That drawing is Dependency Inversion even if every class is in one file for the coding window.
Stripe in Ticket.pay is the old arrow
Even a private StripeClient field on Ticket means the stay cannot be unit-tested without a vendor. Move charge to a gateway the controller holds. Ticket may markPaid(paymentId) after the gateway returns. The stay records an outcome; it does not dial the network. That is inversion applied to money movement, not only to SQL.
Fakes belong to tests, adapters belong to the edge
InMemoryTicketRepository is not a production store renamed for the interview. It is a test double that still honors save and find. JdbcTicketRepository is the production detail. Do not ship the map because inversion made it easy. Inversion gives you a choice of detail; it does not make every detail equally ready for traffic.
Takeaway
Policy depends on an interface you own; details implement it. Put TicketRepository next to the use case, pass it in, and keep SQL and vendor SDKs at the edge. Injection is the wiring. Inversion is the arrow of imports. If the domain still names Connection, the arrow never flipped.