DEV Community

Cover image for Designing Better Spring Boot Applications with Smart Dependency Injection
manju george
manju george

Posted on

Designing Better Spring Boot Applications with Smart Dependency Injection

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

Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Recommended:

private final InputValidator inputValidator;

public ProcessingService(InputValidator inputValidator) {
    this.inputValidator = inputValidator;
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

Spring automatically injects dependencies, even without using @Autowired.

Excluding Beans from Scanning

@ComponentScan(
    excludeFilters = @ComponentScan.Filter(
        type = FilterType.ASSIGNABLE_TYPE,
        classes = LegacyAuthHandler.class
    )
)
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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 {
}
Enter fullscreen mode Exit fullscreen mode

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";
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Use this pattern for dynamic, extensible systems (e.g., plugin architectures).

Optional Dependencies

public MessageHandler(Optional<NotificationSender> notificationSender) {
    this.notificationSender = notificationSender;
}
Enter fullscreen mode Exit fullscreen mode

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 @Primary to define a default implementation
  • Use component exclusion + custom @Bean for 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 @Qualifier when multiple implementations exist
  • Use @Primary for sensible defaults
  • Use custom @Bean for full control
  • Prefer composition over inheritance

Golden Rule:
Let Spring manage shared dependencies—but take control when customization and flexibility matter.

Top comments (0)