Spring Boot
Primary Annotation
The @Primary annotation marks one bean as the default candidate when multiple beans of the same type exist in the Spring container. When Spring encounters ambiguity during injection, it selects the @Primary bean automatically — no @Qualifier needed at the injection point. Understanding @Primary, when to use it, and how it interacts with @Qualifier and auto-configuration is essential for managing multiple implementations cleanly.
What Is @Primary?
When the Spring container finds multiple beans of the same type and needs to inject one, it throws NoUniqueBeanDefinitionException — it cannot decide which bean to use. @Primary resolves this by marking one bean as the preferred candidate. Any injection point that doesn't specify a @Qualifier receives the @Primary bean automatically.
@Primary expresses a default — not an exclusive choice. Injection points that need a specific non-primary bean can still use @Qualifier to override the default. The combination of @Primary for the common case and @Qualifier for the exception case is the standard disambiguation pattern in Spring.
@Primary is most appropriate when:
- One implementation is genuinely the standard choice used in the vast majority of injection points
- You want to provide a sensible default while still allowing specific overrides
- You're overriding a Spring Boot auto-configured bean with your own implementation
- You're writing a library or starter that provides a default implementation others can override
Basic @Primary Usage
Apply @Primary to one bean definition when multiple beans of the same type exist. The annotated bean becomes the default and is injected wherever the type is requested without a @Qualifier.
Java
// Two implementations of PaymentService:
@Component
@Primary // this is the default — injected when no @Qualifier is specified
public class StripePaymentService implements PaymentService {
private final String apiKey;
public StripePaymentService(@Value("${stripe.api.key}") String apiKey) {
this.apiKey = apiKey;
}
@Override
public PaymentResult charge(String customerId, BigDecimal amount) {
// real Stripe implementation
return new PaymentResult("stripe-txn-" + UUID.randomUUID(), PaymentStatus.SUCCESS);
}
@Override
public String getProviderName() { return "stripe"; }
}
@Component
public class PaypalPaymentService implements PaymentService {
@Override
public PaymentResult charge(String customerId, BigDecimal amount) {
// real PayPal implementation
return new PaymentResult("paypal-txn-" + UUID.randomUUID(), PaymentStatus.SUCCESS);
}
@Override
public String getProviderName() { return "paypal"; }
}
// Injection without @Qualifier — gets @Primary bean (Stripe):
@Service
public class StandardCheckoutService {
private final PaymentService paymentService; // receives StripePaymentService
public StandardCheckoutService(PaymentService paymentService) {
this.paymentService = paymentService;
}
public Order checkout(Cart cart) {
PaymentResult result = paymentService.charge(cart.getCustomerId(), cart.getTotal());
return new Order(cart, result);
}
}
// Injection with @Qualifier — overrides @Primary:
@Service
public class PaypalCheckoutService {
private final PaymentService paymentService; // receives PaypalPaymentService
public PaypalCheckoutService(
@Qualifier("paypalPaymentService") PaymentService paymentService) {
this.paymentService = paymentService;
}
}
// The rule: @Qualifier always wins over @Primary
// @Primary is the default, @Qualifier is the explicit override@Primary in @Bean Methods
@Primary works identically on @Bean methods in @Configuration classes. This is the most common placement when configuring third-party infrastructure beans like DataSource, ObjectMapper, or RestTemplate.
Java
@Configuration
public class DataSourceConfig {
// Primary — used by Spring Boot's auto-configured JdbcTemplate, JPA, etc.:
@Bean
@Primary
public DataSource primaryDataSource(DataSourceProperties props) {
HikariConfig config = new HikariConfig();
config.setJdbcUrl(props.getUrl());
config.setUsername(props.getUsername());
config.setPassword(props.getPassword());
config.setMaximumPoolSize(20);
config.setPoolName("primary-pool");
return new HikariDataSource(config);
}
// Read replica — injected only when explicitly qualified:
@Bean("readOnlyDataSource")
public DataSource readOnlyDataSource(DataSourceProperties props) {
HikariConfig config = new HikariConfig();
config.setJdbcUrl(props.getUrl().replace("primary", "replica"));
config.setUsername(props.getUsername());
config.setPassword(props.getPassword());
config.setMaximumPoolSize(50); // more connections — read-heavy
config.setReadOnly(true);
config.setPoolName("readonly-pool");
return new HikariDataSource(config);
}
// Reporting database — separate schema entirely:
@Bean("reportingDataSource")
public DataSource reportingDataSource() {
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:mysql://reporting-db:3306/reports");
config.setMaximumPoolSize(5);
config.setPoolName("reporting-pool");
return new HikariDataSource(config);
}
}
// JdbcTemplate and JPA use primaryDataSource automatically:
@Repository
public class UserRepository {
private final JdbcTemplate jdbcTemplate; // auto-configured with primaryDataSource
public UserRepository(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
}
// Read-heavy service uses the replica explicitly:
@Repository
public class ProductSearchRepository {
private final JdbcTemplate readJdbc;
public ProductSearchRepository(
@Qualifier("readOnlyDataSource") DataSource dataSource) {
this.readJdbc = new JdbcTemplate(dataSource);
}
public List<Product> search(String query) {
return readJdbc.query(
"SELECT * FROM products WHERE name LIKE ?",
productRowMapper,
"%" + query + "%"
);
}
}
// Reporting service uses the reporting database:
@Service
public class ReportGenerationService {
private final JdbcTemplate reportJdbc;
public ReportGenerationService(
@Qualifier("reportingDataSource") DataSource dataSource) {
this.reportJdbc = new JdbcTemplate(dataSource);
}
}Overriding Spring Boot Auto-Configuration with @Primary
The most powerful use of @Primary in Spring Boot applications is overriding auto-configured beans. Spring Boot auto-configures beans conditionally — if you define your own bean of the same type, the auto-configuration backs off. @Primary ensures your custom bean is used everywhere, including by other auto-configured beans that depend on it.
Java
// Override Spring Boot's auto-configured ObjectMapper:
@Configuration
public class JacksonConfig {
@Bean
@Primary // replaces Spring Boot's auto-configured ObjectMapper everywhere
public ObjectMapper objectMapper() {
return JsonMapper.builder()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)
.configure(MapperFeature.DEFAULT_VIEW_INCLUSION, true)
.addModule(new JavaTimeModule())
.addModule(new Jdk8Module())
.serializationInclusion(JsonInclude.Include.NON_NULL)
.build();
}
}
// Spring Boot's MVC message converters, WebFlux, and everything else
// now uses your customized ObjectMapper automatically
// Override Spring Boot's auto-configured TaskExecutor:
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean
@Primary
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(50);
executor.setQueueCapacity(500);
executor.setThreadNamePrefix("app-async-");
executor.setRejectedExecutionHandler(
new ThreadPoolExecutor.CallerRunsPolicy()
);
executor.initialize();
return executor;
}
}
// All @Async methods now use this executor instead of Spring Boot's default
// Override Spring Boot's auto-configured PasswordEncoder:
@Configuration
public class SecurityConfig {
@Bean
@Primary
public PasswordEncoder passwordEncoder() {
// Use Argon2 instead of BCrypt for stronger security:
return new Argon2PasswordEncoder(16, 32, 1, 65536, 10);
}
}
// Check what Spring Boot auto-configures and what your bean overrides:
// application.properties:
// debug=true
// Look for "UserDefinedBeanCondition" in the conditions report —
// Spring Boot reports when your bean caused an auto-config to back off@Primary in Testing
@Primary is frequently used in tests to replace production beans with test doubles. @TestConfiguration classes with @Primary beans override the real beans for the duration of the test.
Java
// Production service:
@Service
public class OrderService {
private final PaymentService paymentService; // injected — could be any PaymentService
private final EmailService emailService;
public OrderService(PaymentService paymentService, EmailService emailService) {
this.paymentService = paymentService;
this.emailService = emailService;
}
public Order createOrder(CreateOrderRequest request) {
PaymentResult payment = paymentService.charge(
request.customerId(), request.totalAmount()
);
Order order = orderRepository.save(new Order(request, payment));
emailService.sendConfirmation(order);
return order;
}
}
// Test with @Primary to override production beans:
@SpringBootTest
class OrderServiceIntegrationTest {
@TestConfiguration
static class TestOverrides {
// @Primary overrides the real StripePaymentService:
@Bean
@Primary
public PaymentService mockPaymentService() {
PaymentService mock = Mockito.mock(PaymentService.class);
when(mock.charge(anyString(), any(BigDecimal.class)))
.thenReturn(new PaymentResult("test-txn-001", PaymentStatus.SUCCESS));
return mock;
}
// @Primary overrides the real SmtpEmailService:
@Bean
@Primary
public EmailService noOpEmailService() {
return (email, subject, body) -> {
System.out.println("Test email suppressed: " + subject);
};
}
}
@Autowired
private OrderService orderService;
@Autowired
private PaymentService paymentService; // gets the mock from TestOverrides
@Test
void createOrder_chargesPaymentAndReturnsOrder() {
CreateOrderRequest request = new CreateOrderRequest(
"customer-1", new BigDecimal("99.99"), List.of("PROD-1")
);
Order order = orderService.createOrder(request);
assertThat(order).isNotNull();
assertThat(order.getPaymentTransactionId()).isEqualTo("test-txn-001");
verify(paymentService).charge("customer-1", new BigDecimal("99.99"));
}
}
// @MockBean — simpler alternative that replaces beans in the context:
@SpringBootTest
class OrderServiceMockBeanTest {
@MockBean // replaces the real PaymentService bean in the context
private PaymentService paymentService;
@MockBean
private EmailService emailService;
@Autowired
private OrderService orderService;
@Test
void createOrder_withMockBeans() {
when(paymentService.charge(any(), any()))
.thenReturn(new PaymentResult("mock-txn", PaymentStatus.SUCCESS));
Order order = orderService.createOrder(buildRequest());
assertThat(order).isNotNull();
}
}
// @MockBean automatically replaces the bean — no @Primary needed
// Use @TestConfiguration + @Primary when you need a real (non-mock) test double@Primary with Profiles
Combining @Primary with @Profile is a powerful pattern for environment-specific default beans. Different profiles activate different @Primary implementations, giving each environment a tailored default without injection point changes.
Java
// Interface:
public interface StorageService {
String upload(String path, byte[] data);
byte[] download(String path);
void delete(String path);
}
// Development — local file system, marked @Primary for dev profile:
@Component
@Profile("dev")
@Primary
public class LocalFileStorageService implements StorageService {
private final Path basePath = Path.of("/tmp/dev-uploads");
@PostConstruct
public void init() throws IOException {
Files.createDirectories(basePath);
}
@Override
public String upload(String path, byte[] data) throws IOException {
Path target = basePath.resolve(path);
Files.createDirectories(target.getParent());
Files.write(target, data);
return "file://" + target.toAbsolutePath();
}
@Override
public byte[] download(String path) throws IOException {
return Files.readAllBytes(basePath.resolve(path));
}
@Override
public void delete(String path) throws IOException {
Files.deleteIfExists(basePath.resolve(path));
}
}
// Test — in-memory, @Primary for test profile:
@Component
@Profile("test")
@Primary
public class InMemoryStorageService implements StorageService {
private final Map<String, byte[]> store = new ConcurrentHashMap<>();
@Override
public String upload(String path, byte[] data) {
store.put(path, data);
return "memory://" + path;
}
@Override
public byte[] download(String path) {
byte[] data = store.get(path);
if (data == null) throw new FileNotFoundException("Not found: " + path);
return data;
}
@Override
public void delete(String path) {
store.remove(path);
}
}
// Production — AWS S3, @Primary for prod profile:
@Component
@Profile("prod")
@Primary
public class S3StorageService implements StorageService {
private final AmazonS3 s3Client;
private final String bucketName;
public S3StorageService(AmazonS3 s3Client,
@Value("${aws.s3.bucket}") String bucketName) {
this.s3Client = s3Client;
this.bucketName = bucketName;
}
@Override
public String upload(String path, byte[] data) {
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentLength(data.length);
s3Client.putObject(bucketName, path,
new ByteArrayInputStream(data), metadata);
return "s3://" + bucketName + "/" + path;
}
@Override
public byte[] download(String path) {
S3Object obj = s3Client.getObject(bucketName, path);
return obj.getObjectContent().readAllBytes();
}
@Override
public void delete(String path) {
s3Client.deleteObject(bucketName, path);
}
}
// All services inject StorageService without qualification:
@Service
@RequiredArgsConstructor
public class UserAvatarService {
private final StorageService storageService; // gets the right impl per profile
public String uploadAvatar(Long userId, byte[] imageData) {
return storageService.upload("avatars/" + userId + ".jpg", imageData);
}
}
// dev profile → LocalFileStorageService (fast, no cloud setup)
// test profile → InMemoryStorageService (fast, isolated, no disk)
// prod profile → S3StorageService (durable, scalable)
// Zero changes to UserAvatarService or any other consumerMultiple @Primary Beans — The Error
Only one bean of a given type can be @Primary. Declaring two @Primary beans of the same type causes NoUniqueBeanDefinitionException at startup — the same error you were trying to avoid.
Java
// WRONG — two @Primary beans of the same type:
@Component
@Primary // first @Primary
public class StripePaymentService implements PaymentService { }
@Component
@Primary // second @Primary — CONFLICT
public class PaypalPaymentService implements PaymentService { }
// Spring throws at startup:
// NoUniqueBeanDefinitionException: expected single matching bean but found 2:
// stripePaymentService, paypalPaymentService
// (both are @Primary — Spring still cannot decide)
// FIX — only one @Primary per type:
@Component
@Primary // Stripe is the default
public class StripePaymentService implements PaymentService { }
@Component // PayPal is the non-default
public class PaypalPaymentService implements PaymentService { }
// FIX — if genuinely no default exists, use @Qualifier everywhere:
// No @Primary on either bean
// All injection points use @Qualifier explicitly:
@Service
public class CheckoutService {
private final PaymentService stripeService;
private final PaymentService paypalService;
public CheckoutService(
@Qualifier("stripePaymentService") PaymentService stripeService,
@Qualifier("paypalPaymentService") PaymentService paypalService) {
this.stripeService = stripeService;
this.paypalService = paypalService;
}
}
// FIX — inject all implementations as a collection:
@Service
public class PaymentRouter {
private final Map<String, PaymentService> services;
public PaymentRouter(Map<String, PaymentService> services) {
this.services = services;
// {"stripePaymentService": ..., "paypalPaymentService": ...}
}
public PaymentResult route(String provider, Order order) {
PaymentService service = services.get(provider + "PaymentService");
if (service == null) throw new UnsupportedPaymentProviderException(provider);
return service.charge(order.getCustomerId(), order.getTotal());
}
}@Primary vs @Qualifier — Complete Decision Guide
Choosing between @Primary and @Qualifier comes down to understanding the relationship between the default case and the exception case in your application.
Java
// ── SCENARIO 1: One implementation is clearly the standard ──────────
// → Use @Primary on the standard, @Qualifier at the exception injection points
@Component @Primary
public class StripePaymentService implements PaymentService { } // standard
@Component
public class PaypalPaymentService implements PaymentService { } // exception
@Service
public class CheckoutService {
// 90% of services just need "the payment service" → gets Stripe automatically:
public CheckoutService(PaymentService paymentService) { }
}
@Service
public class PaypalCheckoutService {
// The one service that needs PayPal specifically:
public PaypalCheckoutService(@Qualifier("paypalPaymentService") PaymentService ps) { }
}
// ── SCENARIO 2: No clear default — all equal alternatives ──────────
// → No @Primary anywhere, use @Qualifier at every injection point
// → Or inject as a collection and dispatch at runtime
@Component public class StripePaymentService implements PaymentService { }
@Component public class PaypalPaymentService implements PaymentService { }
@Component public class BraintreePaymentService implements PaymentService { }
// Inject all and dispatch dynamically:
@Service
public class SmartPaymentRouter {
private final Map<String, PaymentService> services;
public SmartPaymentRouter(Map<String, PaymentService> services) {
this.services = services;
}
public PaymentResult pay(String provider, Order order) {
return services.get(provider + "PaymentService")
.charge(order.getCustomerId(), order.getTotal());
}
}
// ── SCENARIO 3: Overriding auto-configuration ───────────────────────
// → Use @Primary to ensure your bean takes precedence over auto-configured one
@Bean
@Primary // overrides Spring Boot's auto-configured ObjectMapper
public ObjectMapper objectMapper() {
return JsonMapper.builder()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.build();
}
// ── SCENARIO 4: Environment-specific defaults ───────────────────────
// → Use @Primary + @Profile on each implementation
@Component @Profile("dev") @Primary public class DevEmailService implements EmailService { }
@Component @Profile("test") @Primary public class TestEmailService implements EmailService { }
@Component @Profile("prod") @Primary public class SmtpEmailService implements EmailService { }
// ── SUMMARY TABLE ───────────────────────────────────────────────────
// Situation Solution
// ─────────────────────────────────────────────────────────────────────────
// One impl is the default (80%+ usage) @Primary on default, @Qualifier for exceptions
// No clear default @Qualifier everywhere, or inject as collection
// Override auto-configuration @Primary on your bean
// Environment-specific defaults @Primary + @Profile
// Multiple beans needed in one class Both @Qualifier on each parameter
// Type-safe disambiguation Custom @Qualifier annotations