Spring Boot Dependency Injection Without Losing Your Architecture

Spring’s dependency injection container is one of the most mature and capable DI frameworks available. It’s also capable of enabling architectures that are deeply coupled to Spring internals, impossible to test without a full Spring context, and opaque about their dependencies.

The patterns that produce good Spring Boot architectures are not complicated. They require deliberate choices about when to use Spring and when to step back from it.

Constructor Injection Is Non-Negotiable

Spring supports three injection mechanisms: constructor, setter, and field injection. Field injection (@Autowired on a field) is the most common and the worst.

// Field injection — don't do this
@Service
public class OrderService {
    @Autowired
    private OrderRepository repository;
    @Autowired
    private InventoryClient inventory;
    @Autowired
    private PaymentGateway payments;
}

// Constructor injection — the right approach
@Service
public class OrderService {
    private final OrderRepository repository;
    private final InventoryClient inventory;
    private final PaymentGateway payments;

    public OrderService(OrderRepository repository,
                        InventoryClient inventory,
                        PaymentGateway payments) {
        this.repository = repository;
        this.inventory = inventory;
        this.payments = payments;
    }
}

Why field injection is a problem:

Not testable without Spring: you can’t instantiate OrderService in a unit test without reflection hacks or Spring context. Constructor injection lets you pass test doubles directly.

Hides the dependency graph: reading the class header doesn’t reveal its dependencies — you have to scan the body for @Autowired. Constructor injection makes dependencies explicit in the constructor signature.

Allows null fields: field injection via reflection bypasses final constraints. Constructor injection enables final fields, which prevents accidental reassignment and makes the state clear.

Enables circular dependencies: Spring handles field injection circular dependencies with proxy tricks. Constructor injection fails fast with an error — which is the right behavior. Circular dependencies are usually a design problem, not something to route around.

With Spring Boot 2.6+ and Lombok, constructor injection requires no boilerplate:

@Service
@RequiredArgsConstructor  // Generates constructor for all final fields
public class OrderService {
    private final OrderRepository repository;
    private final InventoryClient inventory;
    private final PaymentGateway payments;
}

Component Scanning: Scope Matters

@SpringBootApplication includes @ComponentScan, which scans the entire package tree by default. Every @Component, @Service, @Repository, and @Controller in the package hierarchy is registered as a bean.

This is convenient. It’s also the reason Spring context startup time grows unchecked and why adding a new dependency can silently pull in dozens of beans you didn’t expect.

Limit component scanning scope:

@SpringBootApplication(scanBasePackages = "com.myapp") // Default is fine

For multi-module projects, scan only the relevant packages per module:

// Each module registers its own beans explicitly
@Configuration
public class OrdersModuleConfiguration {
    
    @Bean
    public OrderService orderService(OrderRepository repo, InventoryClient inventory) {
        return new OrderService(repo, inventory);
    }
    
    @Bean
    public OrderController orderController(OrderService service) {
        return new OrderController(service);
    }
}

Explicit @Bean methods in @Configuration classes make the bean graph visible. You can read the configuration and understand exactly what’s registered without scanning the entire classpath.

Configuration Classes Over Annotation Spreading

Annotations like @Service, @Repository, @Component are convenient but they couple your domain classes to Spring. A domain service annotated with @Service cannot be used outside a Spring context without Spring being on the classpath.

The alternative: Spring annotations on configuration classes, domain classes with no Spring annotations.

// Domain class — no Spring dependency
public class OrderService {
    private final OrderRepository repository;
    private final InventoryPort inventory;
    
    public OrderService(OrderRepository repository, InventoryPort inventory) {
        this.repository = repository;
        this.inventory = inventory;
    }
}

// Spring wiring in configuration
@Configuration
public class OrdersConfiguration {
    
    @Bean
    public OrderService orderService(
            OrderJpaRepository jpaRepo,
            InventoryClient inventoryClient) {
        return new OrderService(
            new JpaOrderRepository(jpaRepo),
            inventoryClient
        );
    }
}

The domain class is testable without Spring. The configuration class is where Spring lives. This separation is the foundation of hexagonal architecture in Spring Boot.

The Bean Lifecycle: What You Actually Need to Know

Spring beans go through a lifecycle: construction → dependency injection → initialization → use → destruction.

Hooks that matter:

@PostConstruct: runs after all dependencies are injected. Use for initialization that requires injected dependencies.

@Service
public class CacheWarmingService {
    private final UserRepository userRepository;
    private final Cache<UserId, User> cache;

    @PostConstruct
    void warmCache() {
        // Safe to use userRepository here — it's been injected
        userRepository.findFrequentUsers().forEach(u -> cache.put(u.id(), u));
    }
}

@PreDestroy: runs before the bean is destroyed (application shutdown). Use for cleanup.

SmartLifecycle/ApplicationListener<ContextRefreshedEvent>: for more control over startup ordering across beans.

One important rule: don’t do significant work in constructors. The constructor may run before all dependencies are injected (in some proxy scenarios). Complex initialization belongs in @PostConstruct.

Conditional Beans and Profiles

Spring’s conditional mechanisms allow beans to be registered based on configuration:

@Configuration
public class PaymentConfiguration {

    @Bean
    @ConditionalOnProperty(name = "payment.provider", havingValue = "stripe")
    public PaymentGateway stripePaymentGateway(StripeProperties props) {
        return new StripePaymentGateway(props);
    }

    @Bean
    @ConditionalOnProperty(name = "payment.provider", havingValue = "paypal")
    public PaymentGateway paypalPaymentGateway(PayPalProperties props) {
        return new PayPalPaymentGateway(props);
    }
    
    @Bean
    @Profile("test")
    public PaymentGateway fakePaymentGateway() {
        return new FakePaymentGateway();
    }
}

@ConditionalOnProperty enables different implementations based on configuration. @Profile("test") registers beans only in test contexts.

This is appropriate for selecting between implementations. Avoid using profiles for environment differences that should be in configuration files — @Profile("production") and @Profile("staging") usually indicate that environment-specific behavior belongs in properties, not bean registration.

Testability as a Design Signal

If your Spring beans are hard to test, that’s architectural feedback — not a testing problem.

Hard to test:

@Service
public class OrderService {
    @Autowired ApplicationContext ctx;  // Hidden dependency on container
    @Autowired OrderRepository repo;    // Can't pass test double without Spring
    
    @Transactional
    public void processOrder(String orderId) {
        // Requires full Spring context with DB
    }
}

Easy to test:

public class OrderService {
    private final OrderRepository repo;     // Explicit, injectable
    private final TransactionTemplate tx;   // Or wrap in use-case class
    
    // Instantiable in tests with mock OrderRepository
}

The test for good DI design: can you write a meaningful unit test for this class without @SpringBootTest? If yes, the dependencies are explicit and controllable. If no, something is hidden.

Circular Dependencies

Spring handles constructor injection circular dependencies with an error at startup:

The dependencies of some of the beans form a cycle:
  orderService → paymentService → orderService

This is the correct behavior. A circular dependency is a design problem:

  • Extract a shared dependency that both services use
  • Move one dependency direction through an event (publish/subscribe)
  • Merge the two classes if they’re too tightly coupled to be separate

The Spring workaround (@Lazy on one dependency, or setterInjection) postpones the problem rather than solving it. When you see a circular dependency, redesign.

The Minimal Spring Principle

Use Spring where it adds value:

  • DI wiring (configuration, bean graph)
  • Web layer (request mapping, serialization)
  • Data access (transactions, JPA integration)
  • Observability (Actuator, Micrometer)
  • Configuration management (properties, profiles)

Don’t use Spring where it adds coupling:

  • Domain logic that benefits from being Spring-free
  • Utility classes with no infrastructure concerns
  • Pure computation with no external dependencies

The goal is a codebase where Spring handles infrastructure concerns and your domain logic is independent of the framework. You’ll know you’re there when most of your domain tests don’t start a Spring context.