Best Practices for Spring Dependency Injection
Best practices for Spring dependency injection, starting with constructor injection.
Note: “Spring 4.1” is interpreted as Spring Boot 4.1.0 (June 2026, built on Spring Framework 7.0.8, with full JDK 25 support).
1. Make Constructor Injection the Default
Always use constructor injection for required dependencies. For classes with a single constructor, @Autowired isn’t even needed.
@Service
public class OrderService {
private final PaymentClient paymentClient;
public OrderService(PaymentClient paymentClient) {
this.paymentClient = paymentClient;
}
}
2. Use Optional<T> for a Single Optional Dependency
Instead of @Autowired(required=false), use Optional<T> as a constructor parameter; Spring injects Optional.empty() if no matching bean exists.
public NotificationService(Optional<SmsGateway> smsGateway) {
this.smsGateway = smsGateway;
}
3. Use ObjectProvider<T> for Lazy / Multi-Bean Scenarios
When you need the bean resolved only when actually used, or there may be multiple candidates:
public ReportGenerator(ObjectProvider<PdfExporter> provider) {
this.exporter = provider.getIfAvailable(DefaultPdfExporter::new);
}
4. Batch-Inject Multiple Implementations with List<T>
When implementing a Strategy pattern, collect all implementations as a list and control ordering with @Order.
@Order(1) @Component class CreditCardValidator implements PaymentValidator {}
@Order(2) @Component class FraudCheckValidator implements PaymentValidator {}
@Service
public class PaymentPipeline {
private final List<PaymentValidator> validators;
public PaymentPipeline(List<PaymentValidator> validators) {
this.validators = validators; // Spring injects them in @Order sequence
}
}
5. Use Map<String, T> for Named Bean Collections
When you need to distinguish beans by name (e.g. multiple CacheManager implementations):
public CacheRouter(Map<String, CacheManager> cacheManagers) {
this.redisCache = cacheManagers.get("redisCacheManager");
}
6. Resolve Ambiguity with @Qualifier or @Primary
When multiple beans of the same type exist, be explicit about which one gets injected.
@Service
public class ShippingService {
public ShippingService(@Qualifier("fedexClient") CourierClient client) { ... }
}
7. Avoid @Autowired(required = false) Entirely
Treat it as a deprecated practice - it relies on field/setter injection and breaks type-level clarity; migrate to Optional<T>.
// ❌ Avoid
@Autowired(required = false)
private MetricsCollector metricsCollector;
// ✅ Prefer
public MyService(Optional<MetricsCollector> metricsCollector) { ... }
8. Make Fields final for Immutability
Combine constructor injection with final fields to guarantee the object is immutable after construction.
private final InventoryClient inventoryClient; // final => thread-safe, testable
9. Resolve Circular Dependencies Through Design, Not @Lazy
@Lazy is an escape hatch; the root cause is usually poor separation of responsibilities. Try splitting services first.
// Workaround (not recommended, last resort only):
public ServiceA(@Lazy ServiceB serviceB) { this.serviceB = serviceB; }
// Proper fix: extract shared logic into a third service
public ServiceA(SharedLogic sharedLogic) { ... }
10. Use Records for @ConfigurationProperties (Spring Boot 4.x)
JDK 25 records are first-class supported for immutable configuration binding.
@ConfigurationProperties(prefix = "payment")
public record PaymentProperties(String apiKey, Duration timeout, boolean sandboxMode) {}
11. Use JSpecify @Nullable/@NonNull for Null-Safety Contracts
Use the standard null-safety annotations introduced with Spring Framework 7.x at API boundaries (return types, parameters); don’t conflate this with DI optionality.
public @Nullable Customer findByEmail(String email) { ... } // may return null
12. Use @ConditionalOnProperty for Conditional Bean Definitions
Determine whether a bean exists at the configuration level instead of doing manual null checks.
@Bean
@ConditionalOnProperty(name = "features.sms.enabled", havingValue = "true")
public SmsGateway smsGateway() { return new TwilioSmsGateway(); }
13. Use ObjectProvider.getObject() for Prototype-Scoped Beans
Inside a singleton service, use ObjectProvider to obtain a fresh prototype instance each time.
public BatchJobRunner(ObjectProvider<JobContext> jobContextProvider) {
this.jobContextProvider = jobContextProvider;
}
public void run() {
JobContext ctx = jobContextProvider.getObject(); // new instance every call
}
14. Use Sealed Interfaces + Pattern Matching for DI-Friendly Design (JDK 25)
For Strategy/Visitor patterns, use sealed interfaces for compile-time exhaustiveness guarantees; implementations are still wired via constructor injection.
public sealed interface DiscountPolicy permits PercentageDiscount, FlatDiscount {}
public record PercentageDiscount(double rate) implements DiscountPolicy {}
public record FlatDiscount(BigDecimal amount) implements DiscountPolicy {}
BigDecimal apply(DiscountPolicy policy, BigDecimal price) {
return switch (policy) {
case PercentageDiscount p -> price.multiply(BigDecimal.valueOf(1 - p.rate()));
case FlatDiscount f -> price.subtract(f.amount());
};
}
15. Inject Virtual-Thread-Based TaskExecutor Optionally
Consume Spring Framework 7.x’s native virtual thread support via Optional/ObjectProvider so the app doesn’t fail in environments without an executor bean.
@Bean
public AsyncTaskExecutor applicationTaskExecutor() {
return new VirtualThreadTaskExecutor("app-vt-");
}
public JobDispatcher(Optional<AsyncTaskExecutor> executor) {
this.executor = executor.orElseGet(SyncTaskExecutor::new);
}
16. Leverage Constructor Injection for Testability
Constructor injection lets you write unit tests with plain new, without bootstrapping a Spring context.
@Test
void discountAppliedCorrectly() {
OrderService service = new OrderService(new FakePaymentClient()); // no Spring needed
assertEquals(..., service.process(order));
}
17. Avoid Manually Instantiating Beans with new (Preserve Bean Lifecycle)
Creating another Spring-managed bean with new inside a service breaks AOP proxies, transaction management, and the DI graph.
// ❌ Avoid
PaymentClient client = new StripePaymentClient(); // @Transactional, proxies etc. won't work
// ✅ Let DI handle it
public OrderService(PaymentClient client) { this.client = client; }
18. Enforce No-Field-Injection with Static Analysis (ArchUnit / Checkstyle)
Automatically reject @Autowired field usage in your CI/CD pipeline to preserve team discipline.
@ArchTest
static final ArchRule noFieldInjection = noFields()
.that().areDeclaredInClassesThat().resideInAPackage("..service..")
.should().beAnnotatedWith(Autowired.class);
19. Clarify the Default Implementation with @Primary
When multiple beans exist and one should be the default at most injection points, use @Primary instead of scattering @Qualifier everywhere.
@Primary
@Service
public class DefaultPricingEngine implements PricingEngine { ... }
20. Call Injected Clients in Parallel with Structured Concurrency (JDK 25)
Use JDK 25’s structured concurrency API inside a service to call multiple injected clients in parallel; each client still arrives via constructor injection.
public class DashboardService {
private final OrdersClient ordersClient;
private final InventoryClient inventoryClient;
public DashboardService(OrdersClient ordersClient, InventoryClient inventoryClient) {
this.ordersClient = ordersClient;
this.inventoryClient = inventoryClient;
}
public Dashboard load() throws Exception {
try (var scope = StructuredTaskScope.open()) {
var orders = scope.fork(ordersClient::fetchAll);
var stock = scope.fork(inventoryClient::fetchAll);
scope.join();
return new Dashboard(orders.get(), stock.get());
}
}
}
21. Use Record DTOs as Value Objects in DI-Wired Services
Use records instead of mutable classes in the method signatures of injected services for side-effect-free, immutable data flow.
public record OrderSummary(String orderId, BigDecimal total, Instant createdAt) {}
public OrderSummary summarize(Order order) { // service is injected, return type is a record
return new OrderSummary(order.id(), order.total(), order.createdAt());
}
22. Use Setter Injection Only When a Framework Requires It
Some legacy integrations (e.g. JAX-WS, certain test frameworks) may require setter injection; otherwise don’t deviate from constructor injection.
// Only if the framework mandates it:
@Autowired
public void setLegacyHandler(LegacyHandler handler) { this.handler = handler; }
Summary Table
| # | Practice | When to Use |
|---|---|---|
| 1 | Constructor injection | Always, for required dependencies |
| 2 | Optional<T> | Single optional dependency |
| 3 | ObjectProvider<T> | Lazy / multi-bean / prototype scenarios |
| 4 | List<T> + @Order | Strategy pattern, multiple implementations |
| 5 | Map<String,T> | Named bean selection |
| 6 | @Qualifier/@Primary | Resolving ambiguity |
| 7 | Migrate to Optional<T> | Instead of @Autowired(required=false) |
| 8 | final fields | Immutability |
| 9 | Fix via design | Circular dependencies |
| 10 | Record + @ConfigurationProperties | Immutable configuration |
| 11 | JSpecify @Nullable | API null-safety contracts |
| 12 | @ConditionalOnProperty | Conditional bean presence |
| 13 | ObjectProvider.getObject() | Prototype scope |
| 14 | Sealed interface + switch | Compile-time exhaustiveness |
| 15 | Virtual thread executor | Performance/concurrency |
| 16 | Plain new in tests | Testability |
| 17 | Let DI handle it, avoid manual new | Bean lifecycle integrity |
| 18 | ArchUnit/Checkstyle rules | Team discipline |
| 19 | @Primary | Default implementation |
| 20 | Structured concurrency | Parallel dependency calls |
| 21 | Record DTO/Value Object | Immutable data transfer |
| 22 | Setter injection (exception) | Framework requirement |
Sources: Spring Framework 7.0 GA announcement (spring.io), official Spring Framework/Boot documentation and release notes (accurate as of June 2026), OpenJDK JEP notes (Records, Structured Concurrency, Sealed Classes).