Recommended resources
The freely available resources this lesson will direct you to.
Hello! Last lesson used composition to keep a class’s public API focused on the operations it actually supports. This lesson looks at another boundary: who decides which collaborator a service uses?
In about 40 minutes, you’ll refactor a Java service that constructs its own database adapter. You’ll give the service a domain-level interface, supply the adapter through its constructor, and verify the service without starting Spring or a database. That is a useful interview refactor because it lets you show the code change and explain precisely what dependency has been inverted.
Find the dependency that makes the service rigid
Imagine a service that turns shipment data into a customer-facing status. Its decision is simple: a missing shipment is UNKNOWN; an existing shipment is either DELIVERED or IN_TRANSIT. The database lookup is not part of that decision, but this version binds the two together:
public final class ShipmentStatusService {
private final JdbcShipmentLookup shipments;
public ShipmentStatusService(DataSource dataSource) {
this.shipments =
new JdbcShipmentLookup(new JdbcTemplate(dataSource));
}
public String status(String trackingId) {
if (trackingId == null || trackingId.isBlank()) {
throw new IllegalArgumentException("Tracking ID is required");
}
return shipments.findByTrackingId(trackingId)
.map(shipment ->
shipment.delivered() ? "DELIVERED" : "IN_TRANSIT")
.orElse("UNKNOWN");
}
}
Assume JdbcShipmentLookup queries a SQL database and returns an Optional<Shipment>. Even though this service receives a DataSource, it still chooses and constructs JdbcShipmentLookup itself. A unit test of the status rules must now accommodate that database-oriented construction. Switching to a different lookup implementation also requires changing the service.
Watch Geekific’s Dependency Injection Made Simple with Java Examples for a short illustration of the distinction between constructing a collaborator inside an object and supplying it from outside. Focus on that construction decision; our refactor will take the further step of choosing an interface that expresses what the service needs.
Dependency Injection Made Simple with Java Examples | Clean Code and Best Practices | Geekific
Geekific’s Dependency Injection Made Simple with Java Examples shows how direct construction couples one object to another, then introduces constructor injection.
Watch the coupled example, where the class chooses a concrete collaborator internally. Continue with constructor injection, where the collaborator is supplied when the class is created. Notice that passing an object through a constructor is a construction technique; the type of the constructor parameter still matters.
There is an important distinction behind that last point. Dependency injection (DI) means an object receives a collaborator rather than acquiring it itself. Dependency inversion (DIP) is the design principle that a high-level policy should not depend directly on a low-level implementation; both should work through a suitable abstraction. Constructor injection can support DIP, but changing the constructor to accept JdbcShipmentLookup directly would apply DI without removing the service’s dependency on the concrete database adapter.
Define the boundary in the service’s language
The service needs to find a shipment by tracking ID. It does not need to know about JDBC, SQL queries, or connection pools. Express that requirement as a small interface:
import java.util.Optional;
public interface ShipmentLookup {
Optional<Shipment> findByTrackingId(String trackingId);
}
For this example, the value returned by the lookup can be a small Java record:
public record Shipment(String trackingId, boolean delivered) {}
ShipmentLookup is a port: a contract for the capability the service needs. It could live alongside the service in an application package; a JDBC implementation can live in an infrastructure package and implement it. The key is not a particular package layout. The key is that the service’s source code names ShipmentLookup, while the database adapter conforms to that contract.
Baeldung’s The Dependency Inversion Principle in Java is useful here for separating the terminology and examining where the abstraction belongs. Its CustomerService/CustomerDao example is deliberately simpler than our service, so pay particular attention to the ownership and level of the interface rather than copying its package names.
The Dependency Inversion Principle in Java
Read Baeldung’s explanation to distinguish DI from DIP and to see how a service-owned interface changes the source-code dependency.
In Section 2, “Dependency Injection and Inversion of Control Are Not DIP Implementations,” read the DI distinction. In Section 3.1, “Design Choices and the DIP,” start at the paragraph beginning “In traditional software development” and read through its explanation of why the high-level component is hard to reuse. Then, in Section 3.3, “Choosing the Right Level of Abstraction,” read abstraction level: the interface should speak in terms useful to its consumer. Finally, in Section 4.1, “Direct DIP Implementation,” inspect the CustomerService, CustomerDao, and SimpleCustomerDao declarations, from “Let’s start by defining the high-level component” through the DAO implementation. Read dependency ownership immediately after the interface declaration; you can stop before Section 4.2. Skip the later Java module-system material.
An interface is not automatically a good abstraction. SqlShipmentTableAccessor would still describe an implementation detail, even if it were an interface. ShipmentLookup describes the operation the status service requires. Equally, not every private helper needs an interface: introduce one when it protects a meaningful boundary, not to wrap every new expression in the codebase.
Refactor without changing the status rules
Now replace the concrete field and internal construction with a required constructor parameter. Keep the status logic intact:
import java.util.Objects;
public final class ShipmentStatusService {
private final ShipmentLookup shipments;
public ShipmentStatusService(ShipmentLookup shipments) {
this.shipments = Objects.requireNonNull(shipments, "shipments");
}
public String status(String trackingId) {
if (trackingId == null || trackingId.isBlank()) {
throw new IllegalArgumentException("Tracking ID is required");
}
return shipments.findByTrackingId(trackingId)
.map(shipment ->
shipment.delivered() ? "DELIVERED" : "IN_TRANSIT")
.orElse("UNKNOWN");
}
}
This is the central refactor. final prevents the field from being reassigned after construction, and requireNonNull rejects a missing required collaborator immediately. Neither measure makes the collaborator’s internal state immutable; they make the service’s reference stable and its construction requirements explicit.
Outside Spring, the caller can assemble the objects directly:
ShipmentLookup lookup = new JdbcShipmentLookup(jdbcTemplate);
ShipmentStatusService service = new ShipmentStatusService(lookup);
The service no longer decides how its lookup is built. That responsibility has moved to the code that assembles the application. Notice what has not changed: the service still depends on shipment lookup behavior. Dependency inversion does not remove dependencies; it redirects this one from a database-specific class to a service-relevant contract.
Wire the same design in Spring
Spring can perform that assembly. The service remains understandable as an ordinary Java class; @Service marks it for component discovery, while its sole constructor declares its required collaborator:
import org.springframework.stereotype.Service;
@Service
public final class ShipmentStatusService {
private final ShipmentLookup shipments;
public ShipmentStatusService(ShipmentLookup shipments) {
this.shipments = Objects.requireNonNull(shipments, "shipments");
}
// The status(String trackingId) method is unchanged.
}
A database adapter can implement the interface:
import java.util.List;
import java.util.Optional;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
@Repository
final class JdbcShipmentLookup implements ShipmentLookup {
private final JdbcTemplate jdbc;
JdbcShipmentLookup(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Override
public Optional<Shipment> findByTrackingId(String trackingId) {
List<Shipment> matches = jdbc.query(
"select tracking_id, delivered "
+ "from shipments where tracking_id = ?",
(rs, rowNum) -> new Shipment(
rs.getString("tracking_id"),
rs.getBoolean("delivered")),
trackingId
);
return matches.stream().findFirst();
}
}
Assuming a JdbcTemplate bean and component scanning for these classes are configured, Spring constructs the adapter and supplies it to the service. With a single constructor, Spring does not require @Autowired on that constructor. If multiple ShipmentLookup beans exist, the application must explicitly resolve which implementation to inject; the interface alone does not make that choice.
The Spring Framework’s Dependency Injection reference explains the constructor mechanism and why it is generally preferred for required dependencies. Read it with the two constructors above in mind.
Dependency Injection :: Spring Framework
Read the Spring Framework reference to connect ordinary constructor injection to Spring’s bean creation, and to assess when constructor versus setter injection is appropriate.
Read the opening “Dependency Injection” section, from its definition through the paragraph ending with easier testing using stubs or mocks: the DI definition. In “Constructor-based Dependency Injection,” read the constructor example, stopping before “Constructor Argument Resolution.” Then find “Constructor-based or setter-based DI?” and read the recommendation. Focus on required dependencies and fully initialized objects; the later configuration details are not needed for this refactor.
The following comparison is a quick visual reminder. Its constructor example includes @Autowired, which is permitted but unnecessary for the single-constructor service shown above.

Setter injection has legitimate uses when a dependency is genuinely optional and the class has a sensible default. Field injection is less suitable here: a caller constructing ShipmentStatusService with new would have no constructor argument through which to supply its required lookup. Constructor injection makes that requirement visible both to Spring and to a plain Java test.
Verify the service independently of the adapter
A focused test can supply an implementation of the one-method ShipmentLookup interface directly. No Spring context or database is needed to check the service’s status decisions:
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.util.Optional;
import org.junit.jupiter.api.Test;
class ShipmentStatusServiceTest {
@Test
void translatesLookupResultsIntoStatuses() {
ShipmentLookup lookup = trackingId -> switch (trackingId) {
case "T-1" -> Optional.of(new Shipment(trackingId, true));
case "T-2" -> Optional.of(new Shipment(trackingId, false));
default -> Optional.empty();
};
ShipmentStatusService service = new ShipmentStatusService(lookup);
assertEquals("DELIVERED", service.status("T-1"));
assertEquals("IN_TRANSIT", service.status("T-2"));
assertEquals("UNKNOWN", service.status("missing"));
assertThrows(IllegalArgumentException.class,
() -> service.status(" "));
}
}
This test checks the service’s policy, not whether the SQL query or Spring wiring works. Those need separate integration coverage. That distinction is a useful interview answer: constructor injection makes the service easy to test in isolation, but an isolated test cannot prove the production adapter is configured correctly.
When presenting this refactor, a concise explanation is: “The service originally constructed a JDBC-specific collaborator. I extracted the lookup operation it actually needs, made that interface a required constructor parameter, and moved adapter selection to application wiring. The status rules stayed the same; I can now test them without a database.” That explains the motivation, the change in dependency direction, and the verification without claiming that interfaces or Spring solve every testing problem.
Key takeaways
Constructor injection makes a required collaborator explicit and moves its creation outside the service. Dependency inversion comes from making the service depend on a suitable abstraction rather than the concrete JDBC adapter; constructor injection alone does not guarantee that. Keep the interface close to the service’s needs, test the service’s decisions independently, and test the real adapter and wiring separately.
Next, you’ll use a related separation of responsibilities to replace branching business logic with the Strategy pattern.