What DI actually is
Without it, a class builds its own dependencies:
class CardService {
private final CardRepository repository = new JpaCardRepository(); // welded in
}You can’t swap that repository, and you can’t test the service without a database.
With it, the class just asks:
class CardService {
private final CardRepository repository;
CardService(CardRepository repository) { // someone else decides which one
this.repository = repository;
}
}That’s the whole idea. The class declares what it needs; something outside decides what to hand it. Spring is that something.
What Spring does with it
On startup Spring builds an application context: a container full of beans (objects it manages). It scans your packages, creates one instance of each component it finds, and passes them into each other’s constructors.
@SpringBootApplication
↓ scans this package and below
finds @Component / @Service / @Repository / @Controller
↓
creates one of each, wires them together
↓
application contextTwo consequences worth remembering: you never call new on a bean yourself, and anything you do new yourself is not managed — so @Transactional, @Cacheable and friends silently do nothing on it.
Registering a bean
| Annotation | Use for | Note |
|---|---|---|
@Component | anything generic | the base one |
@Service | business logic | semantics only |
@Repository | data access | + exception translation |
@RestController | HTTP endpoints | + @ResponseBody |
@Configuration | declares @Bean | types you don’t own |
@Service
class CardService { }
@Repository
class JpaCardRepository implements CardRepository { }
@RestController
class CardController { }For a class you don’t own — a library type, a client, a Clock — you can’t put @Component on it, so declare it:
@Configuration
class HttpConfig {
@Bean
RestClient cardApiClient() {
return RestClient.create("https://db.ygoprodeck.com");
}
}@Service, @Repository and @Controller are all @Component underneath. Picking the right one is mostly about telling the reader which layer they’re in — except @Repository, which really does add exception translation.
Injecting it: three ways
Constructor — use this one.
@Service
class CardService {
private final CardRepository repository;
CardService(CardRepository repository) {
this.repository = repository;
}
}Fields can be final, the object is impossible to build half-initialised, and a test can just call the constructor.
Setter — for genuinely optional dependencies.
@Autowired(required = false)
void setMetrics(MeterRegistry metrics) {
this.metrics = metrics;
}Field — avoid.
@Service
class CardService {
@Autowired private CardRepository repository; // don't
}It can’t be final, it hides how many dependencies the class really has (nothing stops it growing to twelve), and you can’t construct the class in a test without Spring or reflection.
So what does @Autowired actually do?
It tells Spring “fill this in from the container.” The differences in one table:
| Where | Needed? |
|---|---|
| Constructor | No — since Spring 4.3, a single constructor is used automatically |
| Two constructors | Yes — on the one Spring should use |
| Setter | Yes |
| Field | Yes — but use the constructor instead |
So in modern Spring you mostly don’t write @Autowired at all:
@Service
class CardService {
private final CardRepository repository;
CardService(CardRepository repository) { // no annotation needed
this.repository = repository;
}
}By default injection is by type. If exactly one bean matches, you get it. If none matches, startup fails — which is good: you find out immediately, not at 3am.
What can you actually inject?
Only beans. Spring never creates a class for you just because you asked for it in a constructor.
class PriceCalculator { } // no @Component, no @Bean method
@Service
class CardService {
CardService(PriceCalculator calculator) { … } // fails at startup
}NoSuchBeanDefinitionException: No qualifying bean of type
'com.example.PriceCalculator' availableA type only becomes injectable by being registered as a bean, one of four ways:
@Component/@Service/@Repository/@Controller, picked up by component scan- a
@Beanmethod in a@Configurationclass @Import(PriceCalculator.class), which registers a plain class and wires its constructor- Spring Boot auto-configuration — which is just
@Beanmethods someone else wrote. That is whereObjectMapper,DataSourceandMeterRegistrycome from.
Beyond a single bean
| You ask for | You get |
|---|---|
CardRepository | the one bean of that type |
List<DeckRule> | every bean of that type, ordered by @Order |
Map<String, DeckRule> | the same, keyed by bean name |
Optional<CardCache> | the bean if it exists, empty if not |
ObjectProvider<CardCache> | a lazy handle — resolve 0, 1 or many when you ask |
@Value("${app.max-size}") | a config value, not a bean at all |
@Service
class DeckService {
private final Map<String, DeckRule> rulesByName; // keyed by bean name
private final Optional<CardCache> cache; // may not be configured
private final int maxDeckSize;
DeckService(Map<String, DeckRule> rulesByName,
Optional<CardCache> cache,
@Value("${app.max-deck-size:60}") int maxDeckSize) {
this.rulesByName = rulesByName;
this.cache = cache;
this.maxDeckSize = maxDeckSize;
}
}What you don’t inject: data
This is the line that blurs. You inject collaborators — the things that do work, usually stateless, usually one instance for the life of the app. Anything carrying per-request or per-user data is just an object you create:
Card card = new Card("Kuriboh", 300); // data — new it
service.save(card); // and pass it as an argumentEntities, DTOs, records, requests, responses: never beans.
If you find yourself wanting “a fresh instance per call, from the container”, you want new or a factory — not injection. @Scope("prototype") looks like the answer and usually isn’t: a prototype injected into a singleton is still resolved once, so every call reuses the same instance. ObjectProvider<T> is the fix when you genuinely need a new one each time.
When more than one bean fits
Two implementations of the same interface, and Spring doesn’t know which you meant.
@Primary — the default choice:
@Service
@Primary
class YgoProDeckSource implements CardSource { }
@Service
class LocalCacheSource implements CardSource { }@Qualifier — name the one you want at the injection point:
CardService(@Qualifier("localCacheSource") CardSource source) {
this.source = source;
}Inject them all — often nicer than an if-chain:
@Service
class DeckValidator {
private final List<DeckRule> rules; // every DeckRule bean
DeckValidator(List<DeckRule> rules) {
this.rules = rules;
}
void validate(Deck deck) {
rules.forEach(rule -> rule.check(deck));
}
}Adding a new rule is now just adding a class.
Scopes
@Service // singleton: one instance, the default
@Scope("prototype") // a new instance per lookup — see the caveat above
@Scope("request") // one per HTTP request (web apps)Singleton beans are shared by every thread, so don’t put mutable state in them.
In tests
First question: does this test need Spring at all?
That’s the dividing line, and it decides every annotation below. There are two families, and mixing them up is the usual source of confusion:
| Annotation | Needs a context? | Gives you |
|---|---|---|
@Mock | no — Mockito | a standalone mock object |
@InjectMocks | no — Mockito | your class, built with those mocks |
@Autowired | yes — Spring | a real bean from the context |
@MockitoBean | yes — Spring | a mock that replaces a bean |
@MockitoSpyBean | yes — Spring | the real bean, wrapped to verify |
@Mock and @MockitoBean look interchangeable and aren’t. @Mock just creates an object. @MockitoBean reaches into a running context and swaps a bean out. Put @Mock in a @WebMvcTest and you get a mock the controller never sees — the real bean is still wired in, and the test fails in a way that makes no sense.
1. Logic in one class — no Spring
class CardServiceTest {
private final CardRepository repository = mock(CardRepository.class);
private final CardService service = new CardService(repository);
@Test
void findsTheStrongestCard() {
when(repository.findAll()).thenReturn(List.of(blueEyes, kuriboh));
assertThat(service.strongest().name()).isEqualTo("Blue-Eyes White Dragon");
}
}No context, milliseconds to run. Most of your tests belong here. This is exactly what constructor injection buys you — you can just call the constructor.
Same thing with Mockito’s annotations, if you prefer them:
@ExtendWith(MockitoExtension.class)
class CardServiceTest {
@Mock CardRepository repository;
@InjectMocks CardService service; // Mockito calls the constructor for you
}2. The web layer — @WebMvcTest
Starts controllers, JSON serialisation, validation and error handling. No services, no database. So every collaborator the controller needs has to be supplied — that’s what @MockitoBean is for.
@WebMvcTest(CardController.class)
class CardControllerTest {
@Autowired MockMvc mvc;
@MockitoBean CardService service; // replaces the real bean in the context
@Test
void returnsTheCard() throws Exception {
when(service.byName("Kuriboh")).thenReturn(kuriboh);
mvc.perform(get("/cards/Kuriboh"))
.andExpect(status().isOk());
}
}Use it for routing, status codes, request validation and the JSON shape — not for business rules.
3. Persistence — @DataJpaTest
Starts JPA, your entities and repositories against an in-memory database. No web layer, no services. Each test rolls back afterwards.
@DataJpaTest
class CardRepositoryTest {
@Autowired CardRepository repository;
@Test
void findsByAttribute() {
assertThat(repository.findByAttribute(DARK)).hasSize(5);
}
}Use it for custom queries, entity mappings and cascades — the things a mock can’t tell you anything about.
4. The whole application — @SpringBootTest
Starts everything, the way the app actually runs.
@SpringBootTest
class CardServiceIT {
@Autowired CardService service;
}Use it for wiring, config binding and genuine end-to-end paths. It’s the slowest by a wide margin, so keep these few.
Which annotation for which test
| Test of | Annotation |
|---|---|
| one class’s logic | none + Mockito |
| controller, JSON | @WebMvcTest |
| queries, mappings | @DataJpaTest |
| wiring, end-to-end | @SpringBootTest |
Mock the collaborators with @Mock on the first row and @MockitoBean on the other three — anything with a Spring context needs the bean replaced, not just a mock created.
Work down that list, not up. If a mock can answer the question, you don’t need a context.
@MockBeanand@SpyBeanare deprecated as of Spring Boot 3.4 and removed in 4.0. Use@MockitoBeanand@MockitoSpyBean— note they come fromorg.springframework.test.context.bean.override.mockito, not fromspring-boot-test.
So why is @Autowired on a field fine here?
The note says avoid field injection, then uses it in every Spring test. Worth being precise about why that isn’t a contradiction — and the reason is not “there’s no constructor”, because there is one and it works:
@SpringBootTest
class CardServiceIT {
private final CardService service;
@Autowired // Spring resolves the constructor's parameters
CardServiceIT(CardService service) {
this.service = service;
}
}SpringExtension implements JUnit Jupiter’s ParameterResolver, so test constructors and test-method parameters can both be injected.
The actual reason field injection is acceptable in a test class:
- The decisive objection doesn’t apply. In production the killer argument is “you can’t construct this class in a test without Spring”. There is no test of a test — JUnit constructs the class and nothing else ever will.
- The other two barely apply. A test class isn’t reused anywhere, and if it has collected so many dependencies that hiding them is a problem, the test is doing too much.
So field @Autowired in a test is conventional and fine. Constructor injection works too, and is worth it if you want final fields.
Overriding one bean for a test
@TestConfiguration
class FixedClockConfig {
@Bean
Clock clock() {
return Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC);
}
}Gotchas
- Don’t
newa bean. The instance you made isn’t managed, so@Transactional,@Asyncand@Cacheabledo nothing on it. - Circular dependencies fail at startup with constructor injection — that’s a feature. Field injection hides them until something breaks later. The real fix is usually a third class.
- Self-invocation skips the proxy. Calling
this.otherMethod()inside the same bean bypasses@Transactionalon that method, because the proxy isn’t involved. - No mutable state in singletons. Every request shares the same instance.
@Autowiredon a field is the one to grep for in an old codebase — it’s the usual reason a class is untestable.
Key points
DI means a class declares what it needs; something else supplies it.
Spring’s container creates the beans and wires them by type.
Constructor injection: final fields, visible dependencies, testable without Spring.
You rarely need to write
@Autowiredany more — a single constructor is used automatically.
@Primarypicks a default;@Qualifierpicks at the injection point;List<T>injects them all.
Spring injects beans, never plain classes — if it isn’t registered, startup fails.
Inject collaborators;
newyour data.
In tests, reach for a plain constructor call first and
@SpringBootTestlast.
@Mockmakes an object;@MockitoBeanreplaces a bean in a running context.
@MockBeanis out;@MockitoBeanis in.