Hello. In the previous lesson, you made a domain object reliable by establishing all of its valid state in the constructor and preventing later mutation. We now extend that same idea to collaboration: a service should receive the collaborators it needs at construction time, store them as stable dependencies, and focus on its own business behavior.
This lesson builds a small Java service from interfaces and constructor-based composition, then shows how Spring Boot assembles the same design. The goal is not merely to say “dependency injection is good,” but to explain what changes in the code’s ownership, coupling, and testability—and where the approach should not be applied mechanically.
A service should coordinate behavior, not construct its world
A backend service commonly has to collaborate with other components. Consider a loan-application service. Its responsibility is to apply the business workflow:
- Ask a risk assessor for a decision.
- Reject unacceptable applications.
- Persist acceptable applications.
A tightly coupled version might look like this:
public final class LoanApplicationService {
private final RiskAssessor riskAssessor =
new RulesBasedRiskAssessor();
private final LoanApplicationRepository repository =
new JdbcLoanApplicationRepository();
public LoanDecision decide(LoanApplication application) {
RiskDecision risk = riskAssessor.assess(application);
if (risk == RiskDecision.REJECT) {
return LoanDecision.rejected("Risk checks failed");
}
LoanDecision decision = LoanDecision.approved();
repository.save(application, decision);
return decision;
}
}
This code may appear self-contained, but the service has taken on two unrelated jobs:
- It coordinates the loan decision workflow.
- It chooses, constructs, configures, and owns infrastructure collaborators.
The second job causes trouble. LoanApplicationService is now tied directly to RulesBasedRiskAssessor and JdbcLoanApplicationRepository. Changing persistence technology, using a remote risk provider, or testing the decision flow without a database all require working around decisions that the service made internally.
The important problem is not the new keyword itself. Java code should absolutely use new to create values that are truly internal to the object, such as a short-lived calculation helper or a result object. The issue is constructing an externally meaningful collaborator: a component that may have configuration, a different implementation, a lifecycle, or a reason to be substituted.
Dependency injection moves the assembly decision outside the service.
Dependency Injection Made Simple with Java Examples | Clean Code and Best Practices | Geekific
Watch “Dependency Injection Made Simple with Java Examples” from Geekific for a visual introduction to the coupling problem, the three injection styles, and the relationship between dependency injection and abstractions.
Start with the coupling example, where creating a dependency internally prevents variation. Then watch injection styles to distinguish constructor, setter, and field injection. Finish with the design rationale, focusing on the distinction between relying on an abstraction and relying on a concrete implementation.
DI and IoC: related, but not interchangeable
An object dependency is another object required for a class to do its job. In the example, a risk assessor and a repository are dependencies of the loan service.
Dependency injection (DI) means that the object declares its dependencies and receives them from outside rather than locating or constructing them internally. With constructor injection, the constructor is that declaration.
Inversion of Control (IoC) is broader. It means control over object creation, configuration, and lifecycle has moved from application objects to an external assembler or framework. Spring’s container is an IoC container; DI is its primary way of supplying collaborations.
The useful interview explanation is:
With direct construction, a service controls both its business workflow and the creation of its collaborators. With dependency injection, the service declares what it needs, while an external composition mechanism supplies suitable implementations. Spring is one such mechanism; the design remains ordinary Java even without Spring.
The Spring Framework reference expresses this particularly well: dependencies are supplied through constructor arguments, factory-method arguments, or properties after construction. The client class does not need to know where a dependency came from or which concrete class implements it.
Dependency Injection :: Spring Framework
Read the official Spring Framework reference for the precise definition of dependency injection and a plain-Java constructor-injection example. Notice that the core class does not need to implement a Spring interface or extend a Spring base class.
In “Dependency Injection,” read from the explanation that begins the definition of DI, through the discussion of why injected interface-based dependencies are easier to test. Then, in “Constructor-based Dependency Injection,” read the MovieLister example. Focus on the fact that Spring manages ordinary Java objects rather than requiring business logic to inherit from framework types.
Design the collaboration around behavior
An interface is a contract for behavior. It says what a collaborator can do without making the consuming service depend on how it does it.
For the loan example, the business service needs a risk decision. It does not need to know whether that decision comes from rules, a machine-learning service, a third-party API, or a deterministic test double.
public interface RiskAssessor {
RiskDecision assess(LoanApplication application);
}
Likewise, the service needs to save a decision. It does not need SQL, a JDBC connection, or the details of a persistence framework in its own code.
public interface LoanApplicationRepository {
void save(LoanApplication application, LoanDecision decision);
}
The interface should reflect what the caller actually needs. A good interface is not a duplicate of a class merely because “every class needs an interface.” It forms a useful boundary when:
- the consumer depends on a distinct role, such as risk assessment, persistence, payment authorization, or email delivery;
- more than one implementation is plausible now or later;
- the real implementation reaches external infrastructure;
- the contract is valuable to understand and test the service in isolation.
Conversely, a tiny helper used only inside one class is often an implementation detail. Making it public and injecting it may expose a boundary that does not exist in the domain. Constructor injection is valuable, but indiscriminate abstraction can turn a small codebase into a collection of unnecessary types.
Composition, not inheritance
The service does not inherit risk-assessment behavior. It is not a RiskAssessor. Instead, it has a risk assessor and delegates the relevant concern to it. That is composition.
This is an important distinction:
| Relationship | Meaning | Example |
|---|---|---|
| Inheritance | “is a” relationship; a subtype promises to behave as the parent type | RulesBasedRiskAssessor implements RiskAssessor |
| Composition | “has a” or “uses a” relationship; one object collaborates with another | LoanApplicationService has a RiskAssessor |
The implementation fulfills the role:
public final class RulesBasedRiskAssessor implements RiskAssessor {
@Override
public RiskDecision assess(LoanApplication application) {
if (application.requestedAmount().signum() <= 0) {
return RiskDecision.REJECT;
}
return RiskDecision.APPROVE;
}
}
The service composes that role with persistence:
public final class LoanApplicationService {
private final RiskAssessor riskAssessor;
private final LoanApplicationRepository repository;
public LoanApplicationService(
RiskAssessor riskAssessor,
LoanApplicationRepository repository) {
this.riskAssessor = Objects.requireNonNull(
riskAssessor, "riskAssessor must not be null");
this.repository = Objects.requireNonNull(
repository, "repository must not be null");
}
public LoanDecision decide(LoanApplication application) {
Objects.requireNonNull(application, "application must not be null");
RiskDecision risk = riskAssessor.assess(application);
if (risk == RiskDecision.REJECT) {
return LoanDecision.rejected("Risk checks failed");
}
LoanDecision decision = LoanDecision.approved();
repository.save(application, decision);
return decision;
}
}
This is a complete, useful Java design without Spring. It has no annotations and no container-specific API. That is an advantage: the business service can be created and used by any Java code.
The earlier immutability lesson applies directly here. The dependencies are private final, so a fully constructed service cannot be reconfigured into using a different repository halfway through its lifetime. final does not make the repository implementation immutable, but it ensures this service’s reference to its required collaborator cannot be reassigned.
Why the constructor is the clearest dependency declaration
Compare the constructor-based service with field injection:
@Service
public class FieldInjectedLoanApplicationService {
@Autowired
private RiskAssessor riskAssessor;
@Autowired
private LoanApplicationRepository repository;
// business methods...
}
Field injection can work at runtime, but its constructor does not reveal what the object needs. A caller can write:
new FieldInjectedLoanApplicationService();
and receive an object that is not ready to perform its job. Its dependencies appear only after Spring uses reflection to modify private fields. This makes direct unit tests more awkward and prevents the fields from being final.
Setter injection has a related issue:
public class SetterInjectedLoanApplicationService {
private RiskAssessor riskAssessor;
public void setRiskAssessor(RiskAssessor riskAssessor) {
this.riskAssessor = riskAssessor;
}
}
The following sequence is legal:
SetterInjectedLoanApplicationService service =
new SetterInjectedLoanApplicationService();
// Calling a business method here may fail because no assessor was set.
Constructor injection makes this invalid state unrepresentable through normal construction:
new LoanApplicationService(riskAssessor, repository);
At the moment a valid instance exists, all of its mandatory dependencies exist too.
This gives constructor injection four practical benefits:
- Explicitness. The constructor signature is an honest dependency list.
- Validity. Required collaborators can be null-checked once, during construction.
- Stable references. Dependencies can be held in
finalfields. - Direct testing. A test can instantiate the service with small test implementations without starting Spring.
Setter injection still has a legitimate role for a dependency that is truly optional and has a sensible default. For mandatory dependencies, constructors communicate the model more honestly. Field injection is generally avoided in production application code because it hides dependencies and relies on framework-driven field mutation.
A large constructor is useful feedback rather than a reason to return to field injection. If a service needs seven or eight unrelated collaborators, the likely issue is that it coordinates too many responsibilities. Refactoring the service or introducing a cohesive intermediate component is usually the better response.
Spring Constructor Injection: Why is it the recommended approach to Dependency Injection?
Watch Dan Vega’s “Spring Constructor Injection: Why is it the recommended approach to Dependency Injection?” for a Spring-specific comparison of constructor, setter, and field injection.
Watch constructor injection for the practical Spring convention: a single constructor is selected automatically and supports final dependencies. Continue with the comparison, concentrating on why field and setter injection make required dependencies less explicit.
Let Spring assemble the object graph
In a Spring Boot application, the container usually performs the wiring. The design of the service remains exactly the same; Spring simply becomes the external assembler.
A common component-scanning version might be:
import org.springframework.stereotype.Repository;
import org.springframework.stereotype.Service;
@Service
public final class LoanApplicationService {
private final RiskAssessor riskAssessor;
private final LoanApplicationRepository repository;
public LoanApplicationService(
RiskAssessor riskAssessor,
LoanApplicationRepository repository) {
this.riskAssessor = Objects.requireNonNull(riskAssessor);
this.repository = Objects.requireNonNull(repository);
}
public LoanDecision decide(LoanApplication application) {
RiskDecision risk = riskAssessor.assess(application);
if (risk == RiskDecision.REJECT) {
return LoanDecision.rejected("Risk checks failed");
}
LoanDecision decision = LoanDecision.approved();
repository.save(application, decision);
return decision;
}
}
@Service
public final class RulesBasedRiskAssessor implements RiskAssessor {
@Override
public RiskDecision assess(LoanApplication application) {
return RiskDecision.APPROVE;
}
}
@Repository
public final class JdbcLoanApplicationRepository
implements LoanApplicationRepository {
@Override
public void save(
LoanApplication application,
LoanDecision decision) {
// Persist application and decision.
}
}
With @SpringBootApplication in an appropriate top-level package, Spring Boot scans for stereotype annotations such as @Service and @Repository. It registers the classes as beans. When it creates LoanApplicationService, Spring recognizes that the constructor requires one RiskAssessor and one LoanApplicationRepository, then supplies the matching beans.

With a single constructor, modern Spring uses it for dependency injection even without @Autowired. If a bean class defines multiple constructors, mark the intended injection constructor with @Autowired; otherwise Spring cannot reliably infer which construction path represents the bean’s mandatory dependencies.
For this simplified example, assume that the application defines exactly one bean implementing each interface. If Spring sees no matching RiskAssessor, it cannot satisfy the service’s constructor. If it sees several, it needs an explicit way to choose. The exact candidate-selection rules are important and will be addressed in the Spring-container module; the design principle here is to make the required role explicit in the constructor.
Spring Beans and Dependency Injection :: Spring Boot
Read the Spring Boot reference to connect the plain Java composition shown above with the annotations and constructor-selection behavior Spring Boot uses in practice.
In “Spring Beans and Dependency Injection,” start at component registration. Then read the MyAccountService example and continue through multiple constructors. Focus on two operational facts: component scanning registers annotated application components, and one unambiguous constructor requires no @Autowired annotation.
Test the composition without starting Spring
Because LoanApplicationService is plain Java, a focused test does not need an ApplicationContext, component scan, database, or web server. Supply controlled implementations of its interfaces.
For example, a fake repository can retain what the service tries to save:
public final class InMemoryLoanApplicationRepository
implements LoanApplicationRepository {
private LoanApplication savedApplication;
private LoanDecision savedDecision;
@Override
public void save(
LoanApplication application,
LoanDecision decision) {
this.savedApplication = application;
this.savedDecision = decision;
}
public LoanDecision savedDecision() {
return savedDecision;
}
}
A test can then compose the service directly:
RiskAssessor approvingAssessor = application -> RiskDecision.APPROVE;
InMemoryLoanApplicationRepository repository =
new InMemoryLoanApplicationRepository();
LoanApplicationService service =
new LoanApplicationService(approvingAssessor, repository);
LoanDecision decision = service.decide(application);
assertEquals(LoanDecision.approved(), decision);
assertEquals(decision, repository.savedDecision());
The lambda works because RiskAssessor has one abstract method. More importantly, the test is not “mocking Spring.” It is testing the business service by supplying a collaborator that obeys the same contract in a controlled way.
A complementary test can supply a rejecting assessor and verify that the repository is not called. The point is that the service’s behavior depends on the contract of RiskAssessor, not on the internal machinery of a production implementation.
This does not eliminate the need for integration tests. A unit test can establish that the service handles an approval correctly; it cannot prove that a JDBC repository’s SQL works against a real database. Different layers need different tests. Constructor composition simply makes the business rule testable at the smallest useful scope.
A concise implementation routine
When building a small service, use this sequence:
- State the service’s one primary responsibility in a sentence.
- Identify the external collaborators needed to perform it.
- Define interfaces around the behaviors the service requires, not around framework APIs.
- Accept mandatory collaborators in the constructor and validate them.
- Store them in
private finalfields. - Keep the service responsible for workflow and business decisions, while delegating infrastructure-specific work.
- Write at least one direct test that constructs the service with controlled collaborators.
- Let Spring select and supply production implementations at the application boundary.
The “application boundary” is where wiring belongs. Application configuration, component scanning, and the Spring container decide which concrete implementations participate in a deployed system. The service should not make that deployment decision for itself.
Interview-quality explanation
For an interview prompt such as, “Why do you prefer constructor injection and interfaces?”, give a reasoned answer rather than a list of slogans:
I use constructor injection for required collaborators because the constructor becomes an explicit contract: a service cannot exist in a partially initialized state. I can validate dependencies once, store them in final fields, and instantiate the service directly in a unit test. I use interfaces at meaningful collaboration boundaries, for example persistence or an external risk-assessment capability, so the service depends on the behavior it needs rather than a specific implementation. Spring acts as the composition mechanism: it creates the concrete beans and passes them into the constructor. I would not create an interface for every small internal helper, because that can expose implementation details and add indirection without a real boundary.
For the loan example, the concrete application is the evidence behind the claim:
LoanApplicationServicecoordinates the decision workflow. It depends onRiskAssessorandLoanApplicationRepository, not on a rule engine or JDBC. In production, Spring supplies the chosen implementations. In a unit test, I can supply an approving or rejecting assessor and an in-memory repository without starting the container.
Key takeaways
A service is easier to understand and change when it owns its business workflow but does not construct externally meaningful collaborators.
- Dependency injection supplies declared collaborators from outside; IoC is the broader shift of assembly and lifecycle control to a framework or composition mechanism.
- Interfaces should express useful behavior boundaries, not be generated mechanically for every class.
- Constructor-based composition makes required dependencies explicit, enables
finalfields, and prevents normally constructed services from being incomplete. - Spring can assemble ordinary Java classes by discovering beans and satisfying their constructor parameters.
- A single constructor is injected implicitly; if multiple constructors exist, specify the injection constructor with
@Autowired. - Direct construction with controlled interface implementations makes focused service tests fast and clear.
- A long constructor parameter list is usually a design signal that responsibilities need reconsideration, not an argument for hidden field injection.
Next, you will choose Java collections deliberately by reasoning about ordering, uniqueness, and lookup requirements.
Can't find a good explanation? Sign up and we'll make it for you
Sign up