Best Practices for Spring Dependency Injection

Best practices for Spring dependency injection, starting with constructor injection.

🌱 Seedling·created: ·category:Java

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

#PracticeWhen to Use
1Constructor injectionAlways, for required dependencies
2Optional<T>Single optional dependency
3ObjectProvider<T>Lazy / multi-bean / prototype scenarios
4List<T> + @OrderStrategy pattern, multiple implementations
5Map<String,T>Named bean selection
6@Qualifier/@PrimaryResolving ambiguity
7Migrate to Optional<T>Instead of @Autowired(required=false)
8final fieldsImmutability
9Fix via designCircular dependencies
10Record + @ConfigurationPropertiesImmutable configuration
11JSpecify @NullableAPI null-safety contracts
12@ConditionalOnPropertyConditional bean presence
13ObjectProvider.getObject()Prototype scope
14Sealed interface + switchCompile-time exhaustiveness
15Virtual thread executorPerformance/concurrency
16Plain new in testsTestability
17Let DI handle it, avoid manual newBean lifecycle integrity
18ArchUnit/Checkstyle rulesTeam discipline
19@PrimaryDefault implementation
20Structured concurrencyParallel dependency calls
21Record DTO/Value ObjectImmutable data transfer
22Setter 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).

This note is part of the Digital Garden — a collection of connected, evolving thoughts.