Spring IoC and Dependency Injection

Every Spring Boot application is built on one idea: the framework creates and wires your objects, not you. This article explains how that works and how to use it effectively.

Inversion of Control

In traditional Java, you create objects yourself:

// You control the dependencies
public class OrderService {
    private final OrderRepository repository = new JpaOrderRepository();
    private final EmailService email = new SmtpEmailService("smtp.gmail.com");
}

Problems:

  • OrderService is tightly coupled to specific implementations
  • You can’t swap JpaOrderRepository for a mock in tests without editing OrderService
  • If SmtpEmailService needs its own dependencies, you must construct those too

Inversion of Control (IoC) flips this: instead of creating your dependencies, you declare what you need and let a container provide them.

// Container provides the dependencies
public class OrderService {
    private final OrderRepository repository;
    private final EmailService email;

    public OrderService(OrderRepository repository, EmailService email) {
        this.repository = repository;
        this.email = email;
    }
}

The Spring IoC container (ApplicationContext) creates OrderService, finds a OrderRepository implementation and an EmailService implementation, and passes them in.

The ApplicationContext

The ApplicationContext is the Spring IoC container. It:

  • Creates beans (objects it manages)
  • Resolves and injects dependencies
  • Manages bean lifecycles (init, destroy)
  • Provides access to configuration

SpringApplication.run() creates the ApplicationContext:

@SpringBootApplication
public class OrderServiceApplication {
    public static void main(String[] args) {
        ApplicationContext ctx = SpringApplication.run(OrderServiceApplication.class, args);

        // You can retrieve beans directly (rarely needed)
        OrderService service = ctx.getBean(OrderService.class);
    }
}

In practice you never call ctx.getBean() in application code — Spring injects everything automatically.

Declaring Beans

Stereotype Annotations (most common)

Mark your class with a stereotype and Spring will register it as a bean:

@Component        // generic bean
@Service          // business logic layer (semantically meaningful, same behavior)
@Repository       // data access layer (adds exception translation)
@Controller       // web layer (Spring MVC)
@RestController   // web layer (= @Controller + @ResponseBody)
@Service
public class OrderService {
    // Spring creates one instance of this and manages it
}

@Repository
public class JpaOrderRepository implements OrderRepository {
    // Spring creates one instance of this
}

@ComponentScan (included in @SpringBootApplication) finds all these classes in your package and sub-packages.

@Bean Methods (for third-party classes)

When you don’t own the class (it’s from a library) or need fine-grained construction control, declare a bean method in a @Configuration class:

@Configuration
public class AppConfig {

    @Bean
    public RestClient orderApiClient() {
        return RestClient.builder()
            .baseUrl("https://api.example.com")
            .defaultHeader("Accept", "application/json")
            .build();
    }

    @Bean
    public ObjectMapper objectMapper() {
        return JsonMapper.builder()
            .findAndAddModules()
            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
            .build();
    }
}

The method name is the bean name. The return type is the bean type.

Three Ways to Inject Dependencies

@Service
public class OrderService {

    private final OrderRepository repository;
    private final EmailService emailService;
    private final OrderProperties props;

    // Spring 4.3+: @Autowired is optional on single-constructor classes
    public OrderService(OrderRepository repository,
                        EmailService emailService,
                        OrderProperties props) {
        this.repository = repository;
        this.emailService = emailService;
        this.props = props;
    }
}

Why constructor injection is best:

  • Dependencies are final — immutable after construction
  • Class is testable without Spring (just pass mocks to the constructor)
  • Missing dependencies cause a startup failure — not a NullPointerException at runtime
  • Makes circular dependencies visible at compile time

With Lombok, the boilerplate disappears:

@Service
@RequiredArgsConstructor  // generates constructor for all final fields
public class OrderService {

    private final OrderRepository repository;
    private final EmailService emailService;
    private final OrderProperties props;
}

2. Field Injection (avoid)

@Service
public class OrderService {

    @Autowired
    private OrderRepository repository;  // NOT recommended

    @Autowired
    private EmailService emailService;
}

Problems:

  • Can’t be final — the field is set after construction via reflection
  • Can’t test without a Spring context (or messy reflection tricks)
  • Hides dependencies — you can’t see what the class needs from its constructor
  • Masks circular dependencies until runtime

3. Setter Injection (for optional dependencies)

@Service
public class OrderService {

    private final OrderRepository repository;
    private MetricsCollector metrics; // optional

    public OrderService(OrderRepository repository) {
        this.repository = repository;
    }

    @Autowired(required = false)
    public void setMetrics(MetricsCollector metrics) {
        this.metrics = metrics;
    }
}

Use setter injection only for optional dependencies (where the bean may not exist). Required dependencies should always use constructor injection.

Resolving Ambiguity: Multiple Implementations

When Spring finds multiple beans of the same type, it needs help choosing.

@Primary

Mark one implementation as the default:

@Service
@Primary
public class JpaOrderRepository implements OrderRepository { ... }

@Service
public class InMemoryOrderRepository implements OrderRepository { ... }

OrderService gets JpaOrderRepository unless told otherwise.

@Qualifier

Be explicit about which bean you want:

@Service
public class OrderService {

    private final OrderRepository repository;

    public OrderService(@Qualifier("jpaOrderRepository") OrderRepository repository) {
        this.repository = repository;
    }
}

The qualifier value defaults to the bean name (class name with lowercase first letter).

Inject All Implementations

Sometimes you want all of them:

@Service
public class NotificationService {

    private final List<NotificationChannel> channels;

    public NotificationService(List<NotificationChannel> channels) {
        this.channels = channels;
    }

    public void notifyAll(Order order) {
        channels.forEach(ch -> ch.send(order));
    }
}

Spring injects a list of all NotificationChannel beans — email, SMS, push, whatever you register.

@Autowired vs No Annotation

In modern Spring Boot you rarely write @Autowired. Spring automatically injects:

  • Constructor parameters (always, on any @Component bean)
  • @Bean method parameters in @Configuration classes
  • @Value fields (still needs the annotation)
@Configuration
public class AppConfig {

    @Bean
    public OrderService orderService(OrderRepository repository, EmailService email) {
        // repository and email are injected automatically — no @Autowired needed
        return new OrderService(repository, email);
    }
}

The Complete Wiring Picture

Here’s how the order-service component graph looks:

ApplicationContext
│
├── OrderController         (@RestController)
│   └── needs: OrderService
│
├── OrderService            (@Service)
│   ├── needs: OrderRepository
│   ├── needs: EmailService
│   └── needs: OrderProperties
│
├── JpaOrderRepository      (@Repository)
│   └── needs: EntityManager (auto-configured)
│
├── SesEmailService         (@Service, @Profile("prod"))
│   └── needs: SesClient (declared as @Bean)
│
└── OrderProperties         (@ConfigurationProperties)
    └── bound from application.yml

Spring resolves this graph at startup. If anything is missing or ambiguous, it fails immediately with a clear error.

@Value: Injecting Individual Properties

For a single property value, @Value is simpler than a full @ConfigurationProperties:

@Service
public class CurrencyService {

    @Value("${app.order.currency:USD}")  // default is USD if property missing
    private String defaultCurrency;

    @Value("${server.port}")
    private int port;

    @Value("#{systemProperties['java.version']}")  // SpEL expression
    private String javaVersion;
}

Use @Value sparingly. For groups of related properties, @ConfigurationProperties is better (typed, validated, IDE support).

Practical: Wiring the order-service

Let’s look at the complete service layer with proper DI:

// OrderRepository interface
public interface OrderRepository {
    Order save(Order order);
    Optional<Order> findById(UUID id);
    List<Order> findByCustomerId(UUID customerId);
}

// JPA implementation
@Repository
public class JpaOrderRepository implements OrderRepository {

    private final EntityManager em;

    public JpaOrderRepository(EntityManager em) {
        this.em = em;
    }

    @Override
    public Order save(Order order) {
        return em.merge(order);
    }

    @Override
    public Optional<Order> findById(UUID id) {
        return Optional.ofNullable(em.find(Order.class, id));
    }

    @Override
    public List<Order> findByCustomerId(UUID customerId) {
        return em.createQuery(
            "SELECT o FROM Order o WHERE o.customerId = :cid", Order.class)
            .setParameter("cid", customerId)
            .getResultList();
    }
}

// Service
@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository repository;
    private final EmailService emailService;
    private final OrderProperties props;

    public Order createOrder(CreateOrderRequest request) {
        if (request.items().size() > props.getMaxItemsPerOrder()) {
            throw new OrderTooLargeException("Too many items");
        }
        Order order = Order.from(request);
        Order saved = repository.save(order);
        emailService.send(confirmationEmail(saved));
        return saved;
    }
}

// Controller
@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {

    private final OrderService orderService;

    @PostMapping
    public ResponseEntity<Order> create(@RequestBody CreateOrderRequest request) {
        Order order = orderService.createOrder(request);
        return ResponseEntity.created(
            URI.create("/api/orders/" + order.id())
        ).body(order);
    }
}

Each layer depends on the abstraction below it, not the implementation. Spring wires the concrete classes at startup based on what’s available on the classpath and which profile is active.

Testing with DI

Constructor injection makes unit testing trivial — no Spring context needed:

class OrderServiceTest {

    OrderRepository repository = mock(OrderRepository.class);
    EmailService emailService = mock(EmailService.class);
    OrderProperties props = new OrderProperties();

    OrderService service = new OrderService(repository, emailService, props);

    @Test
    void shouldRejectOversizedOrders() {
        props.setMaxItemsPerOrder(2);
        var request = CreateOrderRequest.with(3, items());

        assertThrows(OrderTooLargeException.class,
            () -> service.createOrder(request));

        verifyNoInteractions(repository, emailService);
    }
}

No @SpringBootTest, no application context startup, no database. Fast and focused.

What You’ve Learned

  • IoC means the container creates and wires your objects — you declare what you need
  • The ApplicationContext is the Spring IoC container
  • @Component, @Service, @Repository, @Controller register beans for component scanning
  • @Bean methods in @Configuration classes register beans for third-party types
  • Constructor injection is the right choice for required dependencies — use it by default
  • @Primary and @Qualifier resolve ambiguity when multiple beans match a type
  • Constructor injection makes classes fully testable without Spring

Next: Article 7 — Bean Scopes and Lifecycle — controlling when beans are created, how many there are, and what happens when they’re destroyed.