Most Spring Boot developers rely heavily on @Autowired—and it works—until it doesn’t.
As applications grow, this convenience can quietly introduce hidden dependencies, tight coupling, and code that becomes difficult to test and maintain.
This article explores smarter approaches to building scalable, maintainable Spring Boot applications by going beyond basic dependency injection.
Introduction
One of the biggest strengths of Spring Boot is how easy dependency injection feels. Add @Autowired, and everything just works.
However, as your application evolves, this simplicity can become a liability.
You may start encountering issues such as:
Multiple implementations and conflicts between beans
Hidden dependencies that are difficult to test
Coupling due to improper dependency injection
The need to override or customize parts of existing functionality
At this stage, it’s no longer enough to know how to use @Autowired. You need to understand when to use it—and when to avoid it.
In this article, we’ll explore practical, real-world patterns to design cleaner and more maintainable Spring Boot applications.
Why Dependency Injection Matters
Dependency Injection is more than just wiring objects—it defines how responsibilities flow across your application.
Key benefits include:
- Loose coupling
- Testability
- Maintainability
- Separation of concerns
- Flexibility in replacing implementations
Spring eliminates the need to manually create objects using new, and instead manages object creation and wiring for you.
Typical Spring Boot Architecture
Controller → Service → Repository → Database
↓
Helper / Validator
Dependencies across these layers are seamlessly managed by Spring.
When to Use @Autowired
Use Spring injection when working with Spring-managed beans.
Example: Service Injection
@Service
public class ReportService {
private final DataExportService dataExportService;
public ReportService(DataExportService dataExportService) {
this.dataExportService = dataExportService;
}
public void generateReport(String type) {
dataExportService.export(type);
}
}
Common Use Cases
- Service → Service communication
- Repository injection
- Helper/utility components
- Strategy pattern implementations
When You DON’T Need @Autowired
1. Single Constructor Injection
@Service
public class ReportService {
private final DataProcessor dataProcessor;
public ReportService(DataProcessor dataProcessor) {
this.dataProcessor = dataProcessor;
}
}
If a class has only one constructor, Spring automatically injects dependencies—no @Autowired needed.
2. Utility Classes
public class DateUtil {
public static String format(LocalDate date) {
return date.toString();
}
}
Use this approach for stateless helpers (e.g., formatters, mappers) where dependency injection is unnecessary.
3. Runtime Objects
public class ReportCriteria {
private final String reportTypeKey;
public ReportCriteria(String reportTypeKey) {
this.reportTypeKey = reportTypeKey;
}
}
Use this when objects are created dynamically at runtime (e.g., request-based data, DTOs, user inputs) instead of being managed by the Spring container.
Avoid Field Injection
Not Recommended:
@Autowired
private AddressValidator validator;
Recommended:
private final InputValidator inputValidator;
public ProcessingService(InputValidator inputValidator) {
this.inputValidator = inputValidator;
}
Why this is recommended:
- Explicit dependencies
- Immutable fields
- Easier unit testing
- Better design
Multiple Beans Causing Confusion? Use @Qualifier
@Service
public class ProcessingService {
private final Handler handler;
public ProcessingService(@Qualifier("primaryHandler") Handler handler) {
this.handler = handler;
}
}
Use this when more than one implementation is defined for the same dependency.
Implicit Injection (The Clean Way)
@Service
public class ProfileService {
private final ProfileRepository profileRepository;
public ProfileService(ProfileRepository profileRepository) {
this.profileRepository = profileRepository;
}
}
Spring automatically injects dependencies, even without using @Autowired.
Excluding Beans from Scanning
@ComponentScan(
excludeFilters = @ComponentScan.Filter(
type = FilterType.ASSIGNABLE_TYPE,
classes = LegacyAuthHandler.class
)
)
Use this to prevent unwanted beans from being loaded into the application context.
Manual Bean Registration
@Configuration
public class SystemConfig {
@Bean
public ObsoleteProcessor processor() {
return new ObsoleteProcessor();
}
}
Use this when Spring cannot auto-detect a class and you need manual control.
Multiple Beans? Let @Primary Pick the Default
@Component
@Primary
public class PrimaryHandler implements Handler {
}
Use this when you want Spring to automatically select a default implementation.
Partial Override (Inheritance)
public class LogFormatter extends StandardFormatter {
@Override
public String getHeader() {
return "Log Header";
}
}
Use this when you need to modify a small part of an existing class.
Better Approach: Composition
@Component
public class CustomBillingCalculator {
private final BaseBillingCalculator baseBillingCalculator;
public CustomBillingCalculator(BaseBillingCalculator baseBillingCalculator) {
this.baseBillingCalculator = baseBillingCalculator;
}
public double calculate(double amount) {
if (amount > 1000) return amount * 0.08;
return baseBillingCalculator.calculate(amount);
}
}
Prefer composition over inheritance for flexibility.
Composition allows you to build behavior by combining smaller components, while inheritance creates rigid structures that are harder to change.
Injecting Multiple Implementations
public PromotionEngine(List<PromotionStrategy> promotionStrategies) {
this.promotionStrategies = promotionStrategies;
}
Use this pattern for dynamic, extensible systems (e.g., plugin architectures).
Optional Dependencies
public MessageHandler(Optional<NotificationSender> notificationSender) {
this.notificationSender = notificationSender;
}
Use this when certain features are optional and should not break the application if absent.
Spring DI: What to Use and When
| Situation | Recommended Approach | Avoid |
|---|---|---|
| Shared dependency across layers | Use Spring Injection | Creating manually with new
|
| Multiple implementations exist | Use @Qualifier
|
Relying on ambiguous injection |
| One default implementation needed | Use @Primary
|
Adding @Qualifier everywhere |
| Need fine-grained control | Use @Bean + exclusion |
Blind component scanning |
| Customize behavior partially | Use composition / override | Deep inheritance chains |
| Optional feature (SMS, Email, etc.) | Use Optional injection |
Forcing bean presence |
Spring DI: When NOT to Use Spring Injection
| Scenario | What to Do Instead |
|---|---|
| Runtime-created objects (DTOs, request data) | Create using new
|
| Utility/helper classes | Use static methods or simple classes |
| No lifecycle management needed | Keep it outside Spring |
Real World pattern
- Use @Qualifier when multiple beans exist
- Use
@Primaryto define a default implementation - Use component exclusion + custom
@Beanfor fine control - Use composition or partial overrides for flexible customization This combination enables a clean, scalable, and enterprise-ready architecture.
Conclusion: Smarter Spring DI
@Autowired is powerful—but blindly using it everywhere can lead to poor design.
The real strength lies in choosing the right tool for the right situation:
- Prefer constructor injection for clarity and immutability
- Use
@Qualifierwhen multiple implementations exist - Use
@Primaryfor sensible defaults - Use custom
@Beanfor full control - Prefer composition over inheritance
Golden Rule:
Let Spring manage shared dependencies—but take control when customization and flexibility matter.
Top comments (0)