Õppetund 21/159
Kirjalik õppetund · 17 min

Dependency Injection

Receiving collaborators through constructors instead of constructing them.

Eelvaade

Vaatad tasuta eelvaate õppetundi.

Our Order Management Backend has been bootstrapped, and we understand its project structure. Soon, real application components will need to collaborate. Order creation and payment will eventually look like this:

text
OrderHandler
├── CreateOrderUseCase
│   ├── ProductRepository
│   ├── InventoryRepository
│   └── OrderRepository
└── PayOrderUseCase
    ├── OrderRepository
    └── PaymentService

This raises an important question:

How does an object receive the collaborators it needs?

That problem leads us to Dependency Injection.

What Is a Dependency?

A class has a dependency when it needs another collaborator to complete its responsibility. CreateOrderUseCase needs an OrderRepository to persist and retrieve Order state, so OrderRepository is a dependency of CreateOrderUseCase.

The persistence module will design what each repository can do. For the examples in this module, the repositories are only named boundaries, so empty interfaces are enough:

java
interface ProductRepository {}
interface InventoryRepository {}
interface OrderRepository {}

Dependencies are normal. OrderHandler depends on CreateOrderUseCase, which depends on ProductRepository, InventoryRepository and OrderRepository; objects in real systems collaborate. Having dependencies is not itself a design problem. The important questions are:

Who creates the dependency, and how does the object obtain it?

The Naive Approach

Suppose we write:

<!-- fragment -->
java
public class CreateOrderUseCase {

    private final OrderRepository orderRepository =
            new PostgresOrderRepository();

    public void execute() {
        // ...
    }
}

Now CreateOrderUseCase constructs its own persistence implementation. The use case no longer knows only how to coordinate Order creation; it also knows which repository implementation exists, how that implementation is constructed and which persistence technology is used. Responsibilities are becoming mixed.

The job of CreateOrderUseCase is to coordinate Order creation, not to construct database infrastructure. A better direction is for application composition to construct the collaborators and provide them to the use cases. Then the use case can focus on the workflow it owns.

Dependency Injection in One Sentence

Dependency Injection means an object receives the collaborators it needs instead of constructing them internally.

java
public class CreateOrderUseCase {

    private final OrderRepository orderRepository;

    public CreateOrderUseCase(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }
}

CreateOrderUseCase no longer creates the repository; the dependency is supplied from outside. When dependencies are supplied through a constructor, we call it constructor injection. The constructor immediately communicates that CreateOrderUseCase requires an OrderRepository: the dependency is part of the object's construction contract. With the hidden new PostgresOrderRepository() version, a caller could not tell from the construction contract what infrastructure the class creates internally. Explicit dependencies improve clarity, testability, replaceability, configuration and architecture visibility.

Dependency Injection Is Not Spring-Specific

Dependency Injection is a general software-design technique, and plain Java can do it. Given a use case with a constructor dependency:

java
public class CreateProductUseCase {

    private final ProductRepository productRepository;

    public CreateProductUseCase(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }
}

manual composition creates the repository, creates the use case and supplies the repository to it:

<!-- fragment -->
java
ProductRepository productRepository = new PostgresProductRepository();
CreateProductUseCase createProductUseCase =
        new CreateProductUseCase(productRepository);

That is Dependency Injection, and there is nothing wrong with it. Spring becomes especially useful as the application's dependency graph grows, because it automates this composition.

Our Application Dependency Flow

Our internal application flow is handler → UseCase → Repository, with the vocabulary from 2.04: a Handler receives input at a boundary and invokes a use case, a UseCase coordinates one application operation, a Repository hides persistence mechanics, and a Service is an external capability such as PaymentService (PayOrderUseCase depends on both OrderRepository and PaymentService). Constructor injection makes every one of these collaborations explicit:

java
public class OrderHandler {

    private final CreateOrderUseCase createOrderUseCase;

    public OrderHandler(CreateOrderUseCase createOrderUseCase) {
        this.createOrderUseCase = createOrderUseCase;
    }
}
java
public class CreateOrderUseCase {

    private final ProductRepository productRepository;
    private final InventoryRepository inventoryRepository;
    private final OrderRepository orderRepository;

    public CreateOrderUseCase(
            ProductRepository productRepository,
            InventoryRepository inventoryRepository,
            OrderRepository orderRepository) {
        this.productRepository = productRepository;
        this.inventoryRepository = inventoryRepository;
        this.orderRepository = orderRepository;
    }
}

The handler does not construct the use case, and the use case does not construct database implementations: each receives what it requires, and the application's dependency relationships are visible directly in the constructors.

Why Constructor Injection Is Our Default

Spring supports constructor injection, field injection and setter injection. For required collaborators, our default is constructor injection, for four reasons:

  • Dependencies are visible. An engineer opening PayOrderUseCase(OrderRepository orderRepository, PaymentService paymentService) immediately knows that it requires Order persistence and an external Payment capability.
  • Required collaborators stay required. If a use case cannot operate without an OrderRepository, it should be impossible to construct one without it. We do not want partially configured application components.
  • Dependencies can be final. A field such as private final OrderRepository orderRepository; is assigned during construction and never changes, which makes the component easier to reason about.
  • Tests stay simple. A test constructs the component directly, with no Spring Application Context needed to test application behaviour:
<!-- fragment -->
java
OrderRepository orders = new FakeOrderRepository();
InventoryRepository inventory = new FakeInventoryRepository();
CancelOrderUseCase useCase = new CancelOrderUseCase(orders, inventory);

Field and Setter Injection

Spring code sometimes uses field injection:

<!-- fragment -->
java
@Autowired
private OrderRepository orderRepository;

It is shorter, but it is not our style for required dependencies. Field injection hides construction requirements, makes plain Java construction harder, makes dependencies less obvious and encourages framework-dependent tests. Constructor injection keeps the dependency graph visible.

Setter injection supplies a dependency through a method such as setOrderRepository(OrderRepository orderRepository). It can make sense when a collaborator is genuinely optional or replaceable after construction. Our core repositories and the external Payment dependency are not optional in that sense, so constructor injection communicates their role more accurately.

When a Spring-managed class has a single constructor, such as OrderHandler(CreateOrderUseCase createOrderUseCase), Spring uses it automatically. We do not add @Autowired to a single constructor, which keeps the class readable as ordinary Java.

Interfaces Only for Real Boundaries

Using DI does not mean every dependency needs an interface. OrderHandler depends on the concrete class CreateOrderUseCase, and that is completely reasonable. We do not create CreateOrderUseCase plus CreateOrderUseCaseImpl, or a CreateOrderUseCaseInterface, simply because DI is being used: there is no alternative boundary to protect. An interface should represent a real boundary, not architectural decoration, and internal application components can depend directly on one another when that dependency matches the architecture.

An external system is a strong example of a real boundary. A payment provider brings network failure, a provider protocol, credentials, request and response mapping, timeouts and provider-specific change. Our application wants the capability "process a payment" without depending on one provider's protocol, so an application-facing boundary is valuable:

<!-- fragment -->
java
public interface PaymentService {

    PaymentResult pay(PaymentRequest request);
}

A provider implementation fulfils it, and the use case depends only on the boundary:

<!-- fragment -->
java
public class ProviderPaymentService implements PaymentService {

    @Override
    public PaymentResult pay(PaymentRequest request) {
        // provider-specific integration
        return null;
    }
}
<!-- fragment -->
java
public class PayOrderUseCase {

    private final OrderRepository orderRepository;
    private final PaymentService paymentService;

    public PayOrderUseCase(
            OrderRepository orderRepository, PaymentService paymentService) {
        this.orderRepository = orderRepository;
        this.paymentService = paymentService;
    }
}

PayOrderUseCase owns application workflow coordination; PaymentService owns communication with the external Payment capability. That is a meaningful relationship. A CreateOrderService for our internal Order-creation workflow, by contrast, would conflict with our vocabulary: that workflow is CreateOrderUseCase.

Repository boundaries are meaningful too: a use case expresses "find Order" or "consume Inventory if available" without knowing SQL, EntityManager or PostgreSQL driver behaviour.

Domain Objects Are Not Injected

Suppose we create an Order:

<!-- fragment -->
java
Order order = Order.create(orderId, customerId, items, createdAt);

Order represents request-specific business state. It is not a long-lived collaborator that Spring should inject into a use case, because each request creates or reconstructs a different Order. Injecting one would look like this:

<!-- fragment -->
java
public class CreateOrderUseCase {

    private final Order order;

    public CreateOrderUseCase(Order order) {
        this.order = order;
    }
}

Which Order? Every workflow invocation creates or loads different business state, so a use case operates on request-specific domain objects rather than holding one injected entity.

These objects have different roles. A UseCase constructor holds stable collaborators: repositories, external services, a Clock. Operation input carries request-specific information, such as the CustomerId and items, in one command record. Spring composes the application components, while Product, Inventory, Order and OrderItem instances remain normal business objects created or reconstituted during workflows.

Who Constructs the Collaborators?

Construction is a responsibility in its own right. A handler that writes new CreateOrderUseCase(new PostgresProductRepository(), ...) knows use case construction, repository construction and persistence composition. Third-party services show the problem most clearly:

<!-- fragment -->
java
public class PayOrderUseCase {

    private final ProviderPaymentService paymentService =
            new ProviderPaymentService(
                    "https://provider.example.com", "secret");
}

Now the use case knows the provider implementation, the provider URL, the credentials and the construction details. The better design is the PayOrderUseCase above, which receives OrderRepository and PaymentService and leaves infrastructure composition to supply the actual provider implementation.

The provider implementation needs a base URL, credentials and a timeout from application configuration. Configuration feeds the provider integration, which implements PaymentService, which PayOrderUseCase uses; the use case never reads environment variables or provider settings itself.

DI Does Not Mean "Never Use new"

This is an important misconception. We still use new or factory methods for business state, and creating an Order as part of a workflow is normal:

<!-- fragment -->
java
OrderItem orderItem = new OrderItem(productId, quantity, unitPriceCents);

What we avoid is a high-level application component hard-wiring infrastructure collaborators. Whenever you see new Something(...), ask:

Am I creating business state, or am I hard-wiring a stable collaborator?

new OrderItem(...) is business state and normal. new ProviderPaymentService(...) inside a use case is a warning sign.

DI and Testability

Because PayOrderUseCase receives its collaborators through its constructor, a test can supply controlled ones:

<!-- fragment -->
java
OrderRepository orderRepository = new FakeOrderRepository();
PaymentService paymentService = new SuccessfulPaymentService();
PayOrderUseCase useCase =
        new PayOrderUseCase(orderRepository, paymentService);

No real PostgreSQL and no real payment provider: the workflow is tested in isolation from those infrastructure systems. A test implementation might look like this:

<!-- fragment -->
java
public class SuccessfulPaymentService implements PaymentService {

    @Override
    public PaymentResult pay(PaymentRequest request) {
        return PaymentResult.success();
    }
}

Another test double could simulate failure or ambiguity; test doubles belong in tests, not among production provider implementations. Testing is one benefit of DI, not its reason: the deeper reason is clear composition and responsibility boundaries. And do not mock domain objects by default: Order, Inventory and Product contain the behaviour we want to exercise, so test doubles belong at boundaries with infrastructure or side effects.

Constructor Size Is Design Feedback

Constructor signatures act as lightweight architecture documentation. Without reading any implementation, CancelOrderUseCase(OrderRepository, InventoryRepository) tells us that cancellation coordinates Order and Inventory state, exactly as our technical design says, and PayOrderUseCase(OrderRepository, PaymentService) that payment combines local persistence with an external capability. Clear constructors make reviews easier. Now suppose we find:

<!-- fragment -->
java
public SomeOrderUseCase(
        OrderRepository orderRepository,
        ProductRepository productRepository,
        InventoryRepository inventoryRepository,
        PaymentService paymentService,
        NotificationService notificationService,
        IdentityService identityService,
        AnotherRepository anotherRepository) {
}

This may occasionally be correct, but it should prompt a question:

Does this one use case genuinely own all of these responsibilities?

DI makes coupling visible; do not hide the signal with field injection. A constructor with several cohesive dependencies is normal: ProductRepository, InventoryRepository, OrderRepository and a Clock all make sense for Order creation. There is no useful rule such as "at most three dependencies". The question is whether the dependencies are coherent with the component's responsibility.

A generic OrderUseCase that handles create, cancel, pay, history and admin operations tends to grow exactly that kind of constructor. Operation-specific use cases (CreateOrderUseCase, CancelOrderUseCase, PayOrderUseCase, GetOrderHistoryUseCase) give each workflow a clear responsibility and usually keep its dependency set focused.

Avoid Circular Dependencies

If OrderUseCase depends on InventoryUseCase and InventoryUseCase depends on OrderUseCase, the graph contains a cycle: Spring may fail to construct it, and the responsibility design is unclear. For cancellation, CancelOrderUseCase instead depends on OrderRepository and InventoryRepository and owns the complete workflow (3.07 returns to cycles).

Avoid Hidden Lookups

A component could ask Spring for dependencies manually, with applicationContext.getBean(OrderRepository.class) inside application code. But then the dependency disappears from the constructor, and the class no longer declares what it needs. Injecting the ApplicationContext into a use case and calling context.getBean(...) later turns Spring into a global dependency registry, the service locator style. We avoid it and keep the graph explicit:

<!-- fragment -->
java
public CreateOrderUseCase(
        ProductRepository productRepository,
        InventoryRepository inventoryRepository,
        OrderRepository orderRepository, Clock clock) {
}

Static global access, such as PaymentServiceHolder.get().pay(...) or GlobalRepositories.orderRepository(), has the same problem: it hides collaboration and introduces global state. Explicit constructor dependencies are easier to understand and test.

Request Data Is Not a Constructor Dependency

Constructor dependencies are stable collaborators; request-specific input belongs in the operation:

<!-- fragment -->
java
public Order execute(CreateOrderCommand command) {
    // request-specific local state
}

Keeping request state in fields is a real bug:

<!-- fragment -->
java
public class CreateOrderUseCase {

    private CustomerId currentCustomer;

    public void execute(CreateOrderCommand command) {
        this.currentCustomer = command.customerId();
    }
}

Spring-managed application components are typically shared and may serve concurrent requests, so mutable request-specific fields create concurrency bugs. CreateOrderUseCase may hold its repositories and Clock because they are stable collaborators. It should not hold the current customer, the current Order or the current request across executions. Stateless application components are easier to reason about under concurrent requests.

DI Does Not Remove Coupling

Suppose the constructor is PayOrderUseCase(ProviderPaymentService paymentService). The dependency is injected, but the use case still depends directly on one provider-specific implementation, and DI has not removed that coupling. If provider isolation matters, the better boundary is PayOrderUseCase(PaymentService paymentService). A more accurate statement is:

Dependency Injection does not eliminate coupling. It makes collaborators externally supplied, explicit, configurable, testable and replaceable.

We still need good architectural boundaries; DI cannot choose them for us. Nor does it answer other design questions: injecting three repositories does not make Order creation transactional, and injecting PaymentService does not say what happens when the provider times out. DI is also no reason to move business rules into injectable classes such as OrderCancellationService: order.cancel() stays on Order, which owns the validity of its lifecycle.

Spring's Role

Eventually, Spring will build the object graph for us. For payment, it creates the OrderRepository and PaymentService implementations first, then PayOrderUseCase with them, then OrderHandler. The request path through that graph is:

Diagrammi joonistamine…

If a required collaborator such as OrderRepository has no managed implementation, startup fails; the next lesson shows how Spring reports that and what it does when two implementations compete.

Annotations Do Not Define Architecture

Spring MVC talks about controllers and offers @Service, but our architecture uses Handler for the incoming boundary (one per capability, such as ProductHandler and OrderHandler) and keeps Service for external capabilities. A framework annotation does not change a class's architectural responsibility: @Component does not make a class well designed.

Practice Exercise 1: Compose and Test Without Spring

Solutions: lesson 3.09.

This is a throwaway experiment: the classes are not part of the course's application. In your project, create a package com.tarkev.ordermanagement.practice with an interface ReminderSender (one method, void send(String customerEmail, String text)) and a class SendReminderUseCase whose execute(String customerEmail) sends the text Your Order is waiting for payment. through it. The use case receives its ReminderSender through the constructor.

Then write SendReminderUseCaseTest in the same package under src/test/java, with a hand-written fake ReminderSender that records what it was asked to send. Construct the use case yourself, call execute(...) and assert on the recorded message. Use no Spring annotation anywhere, and run ./gradlew test. Keep the classes until lesson 3.04's first exercise, then delete the practice package in both source folders.

Practice Exercise 2: Review a Use Case Draft

Find at least four problems in this draft and write the constructor you would ask for instead:

<!-- fragment -->
java
public class CancelOrderUseCase {

    @Autowired
    private OrderRepository orderRepository;

    private final InventoryRepository inventoryRepository =
            new PostgresInventoryRepository();

    @Autowired
    private ApplicationContext context;

    private CustomerId customerId;

    public void execute(CancelOrderCommand command) {
        this.customerId = command.customerId();
        PaymentService payments = context.getBean(PaymentService.class);
        // ...
    }
}

Practice Exercise 3: Constructor, Parameter or new?

For each value, decide whether CreateOrderUseCase receives it through its constructor, receives it in the command, or creates it during the workflow: OrderRepository, a Clock, the CustomerId of the current request, the requested quantity of a Product, a new OrderItem, and the Order being created.

Knowledge Check

Question 1

A use case uses field injection. What does a plain JUnit test of it lose compared with constructor injection?

Question 2

OrderHandler depends on the concrete class CreateOrderUseCase, but PayOrderUseCase depends on the interface PaymentService. Why the difference?

Question 3

PayOrderUseCase(ProviderPaymentService paymentService) uses constructor injection. Why does it still couple the workflow to one provider?

Question 4

Why can a request-specific field in a use case pass every single-request test and still be a production bug?

Question 5

A use case constructor takes seven collaborators. What should a reviewer ask, and why is field injection not the answer?

Knowledge Check Answers

Answer 1

With field injection the test cannot simply call a constructor with fakes: the required collaborators stay null unless Spring or reflection fills them. Constructor injection makes the requirements part of the type, so new with test doubles is enough.

Answer 2

An interface is worth having only at a real boundary. There is no alternative to CreateOrderUseCase to protect, while the payment provider brings a protocol, failures and change that the workflow must not depend on.

Answer 3

DI only decides who supplies the object, not which type the use case depends on. The parameter type still names one provider's implementation, so a provider change reaches the use case; depending on PaymentService removes that.

Answer 4

Spring shares one instance of the use case between concurrent requests. With one request at a time the field looks correct, but two overlapping requests overwrite each other's value.

Answer 5

Whether the use case really owns all those responsibilities, or should be split by operation. Field injection would only hide the signal; the coupling stays.

Engineering Principle

A component should declare the collaborators it needs instead of constructing or globally discovering them itself.

Dependency Injection is a composition mechanism: good architecture still determines which dependencies are appropriate.

Summary

  • Dependency Injection means receiving collaborators instead of constructing them; plain Java can do it, and Spring automates it as the graph grows.
  • Constructor injection is our default for required dependencies: it makes them explicit and final and keeps components easy to construct in tests. Field injection hides requirements, setter injection suits genuinely optional dependencies, and a single constructor needs no @Autowired.
  • Our flow is handler → UseCase → Repository, with Service reserved for external capabilities such as PaymentService; there are no internal OrderService, ProductService or InventoryService classes.
  • Interfaces are for meaningful boundaries such as PaymentService, not for every class.
  • Domain objects such as Order, OrderItem, Product and Inventory are not injected; creating them with new or factory methods is normal, and domain behaviour stays in them.
  • Stable collaborators belong in constructors and request-specific values in method parameters, so shared use cases stay stateless.
  • Avoid constructing infrastructure in use cases, ApplicationContext lookups, static globals and circular use cases; constructor size is design feedback.
  • DI makes coupling explicit but does not remove it, and it does not decide transactions, concurrency, security or failure semantics.

In the next lesson, Beans and the Application Context, we examine what Spring actually creates and manages, and how it builds the handler → UseCase → Repository graph during startup.

Mõtle enne

Tulekul

Kontrollküsimused, mille puhul valid vastuse enne, kui õppetund selle näitab.