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 context

Two 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

AnnotationUse forNote
@Componentanything genericthe base one
@Servicebusiness logicsemantics only
@Repositorydata access+ exception translation
@RestControllerHTTP endpoints+ @ResponseBody
@Configurationdeclares @Beantypes 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:

WhereNeeded?
ConstructorNo — since Spring 4.3, a single constructor is used automatically
Two constructorsYes — on the one Spring should use
SetterYes
FieldYes — 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' available

A type only becomes injectable by being registered as a bean, one of four ways:

  • @Component / @Service / @Repository / @Controller, picked up by component scan
  • a @Bean method in a @Configuration class
  • @Import(PriceCalculator.class), which registers a plain class and wires its constructor
  • Spring Boot auto-configuration — which is just @Bean methods someone else wrote. That is where ObjectMapper, DataSource and MeterRegistry come from.

Beyond a single bean

You ask forYou get
CardRepositorythe 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 argument

Entities, 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:

AnnotationNeeds a context?Gives you
@Mockno — Mockitoa standalone mock object
@InjectMocksno — Mockitoyour class, built with those mocks
@Autowiredyes — Springa real bean from the context
@MockitoBeanyes — Springa mock that replaces a bean
@MockitoSpyBeanyes — Springthe 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 ofAnnotation
one class’s logicnone + 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.

@MockBean and @SpyBean are deprecated as of Spring Boot 3.4 and removed in 4.0. Use @MockitoBean and @MockitoSpyBean — note they come from org.springframework.test.context.bean.override.mockito, not from spring-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 new a bean. The instance you made isn’t managed, so @Transactional, @Async and @Cacheable do 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 @Transactional on that method, because the proxy isn’t involved.
  • No mutable state in singletons. Every request shares the same instance.
  • @Autowired on 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 @Autowired any more — a single constructor is used automatically.

@Primary picks a default; @Qualifier picks at the injection point; List<T> injects them all.

Spring injects beans, never plain classes — if it isn’t registered, startup fails.

Inject collaborators; new your data.

In tests, reach for a plain constructor call first and @SpringBootTest last.

@Mock makes an object; @MockitoBean replaces a bean in a running context.

@MockBean is out; @MockitoBean is in.