Spring Framework & the IoC Container

Inversion of Control (IoC)

18 min Lesson 2 of 13

Inversion of Control (IoC)

If you have written plain Java for any length of time you have written code like this: a class decides what it needs, creates it, and uses it. That arrangement is natural but it ties your code in knots the moment you need to swap an implementation, test in isolation, or reuse a component in a different context. Inversion of Control (IoC) is the design principle that untangles those knots by flipping who is responsible for creating dependencies.

The Classic Problem: Hard-Wired Dependencies

Consider a simple order-processing service:

public class OrderService { private final PaymentGateway gateway; private final NotificationService notifier; public OrderService() { this.gateway = new StripeGateway(); // hard-wired this.notifier = new EmailNotifier(); // hard-wired } public void placeOrder(Order order) { gateway.charge(order.total()); notifier.send(order.customerEmail(), "Order confirmed"); } }

What goes wrong here?

  • Untestable in isolation. Every time you instantiate OrderService in a test, you get a real StripeGateway — meaning real HTTP calls and real charges.
  • Change is painful. Switching from Stripe to PayPal requires editing OrderService, which should not care about payment-provider choice.
  • Hidden coupling. The class knows which concrete types it needs. That knowledge is scattered across your codebase and must be updated in every class that creates its own dependencies.

The IoC Flip

IoC says: do not create your dependencies — declare them and let someone else provide them. The simplest form is constructor injection:

public class OrderService { private final PaymentGateway gateway; private final NotificationService notifier; // Dependencies are GIVEN to us, not created by us public OrderService(PaymentGateway gateway, NotificationService notifier) { this.gateway = gateway; this.notifier = notifier; } public void placeOrder(Order order) { gateway.charge(order.total()); notifier.send(order.customerEmail(), "Order confirmed"); } }

OrderService now depends on interfaces, not concrete classes. It no longer knows — or cares — whether gateway is a Stripe adapter, a PayPal adapter, or a stub you wrote in a test. The caller (or the IoC container) decides.

Key insight: IoC is a principle, not a framework feature. You can practice it with plain Java by having a main method wire objects together. Spring just automates and scales that wiring.

Three Ways to Inject Dependencies

IoC is delivered through Dependency Injection (DI), which has three common styles:

  1. Constructor injection — dependencies are final, set once, always valid. This is the style Spring recommends and the one you should reach for by default.
    // Preferred public OrderService(PaymentGateway gw, NotificationService ns) { ... }
  2. Setter injection — useful for optional dependencies or when you need to swap a collaborator after construction. More flexible, but the object can exist in a half-configured state.
    public void setGateway(PaymentGateway gw) { this.gateway = gw; }
  3. Field injection — Spring injects directly into a private field via reflection. Concise to write, but makes the dependency invisible in the constructor, complicates testing, and is generally discouraged in production code.
    @Autowired private PaymentGateway gateway; // avoid in production classes
Prefer constructor injection. It makes all required dependencies explicit in the class API, allows fields to be final, and lets you instantiate the class in a plain JUnit test without a Spring context at all — just pass mock objects to the constructor.

Manual Wiring vs. Container Wiring

To see the full picture, wire the object graph by hand first:

// Without Spring — a "poor man's IoC container" in main() public class Application { public static void main(String[] args) { // 1. Build low-level components PaymentGateway gateway = new StripeGateway(System.getenv("STRIPE_KEY")); NotificationService ns = new EmailNotifier("smtp.example.com"); // 2. Inject into higher-level components OrderService orderService = new OrderService(gateway, ns); ReportService reports = new ReportService(orderService); // 3. Use the fully-wired graph reports.runDailyReport(); } }

This works. But as the graph grows to dozens of objects — each with its own dependencies — the wiring code becomes hundreds of lines. You also have to manage object lifetimes (singleton vs. prototype) and handle circular dependencies yourself. This is exactly the problem a Spring ApplicationContext solves.

What the Container Adds

Spring's IoC container reads your configuration (annotations, Java config, or XML), builds every object, injects every dependency, and manages each object's lifecycle. From that point on you ask the container for a fully-wired object rather than calling new:

// With Spring — the container owns the object graph ApplicationContext ctx = new AnnotationConfigApplicationContext(AppConfig.class); OrderService orderService = ctx.getBean(OrderService.class); orderService.placeOrder(order); // gateway and notifier already injected

The container does the same wiring you did in main(), but automatically, based on your declarations. It also enforces scope (one OrderService shared across the application, or a new one per HTTP request), and it calls lifecycle callbacks (@PostConstruct / @PreDestroy) so your components can initialise and clean up cleanly.

Why This Matters in Practice

  • Testability. Because OrderService accepts its dependencies through the constructor, a unit test can pass in a MockPaymentGateway — no Spring context, no network, no database, tests run in milliseconds.
  • Replaceability. Switching from EmailNotifier to SmsNotifier is a one-line change in configuration, not a search-and-replace across every class that creates a notifier.
  • Single Responsibility. Business logic classes are freed from the accidental complexity of object creation. They do one thing: their job.
  • Composition over inheritance. Behaviour is composed by combining injected collaborators rather than by deep class hierarchies — far easier to reason about and extend.
IoC does not mean no new ever. Value objects — a Money record, an OrderId wrapper, a DTO — have no collaborators and are fine to construct with new. Reserve IoC for services and infrastructure components that have dependencies and need to be swapped or tested in isolation.

IoC in the Real World: The Hollywood Principle

IoC is sometimes described by the Hollywood Principle: "Don't call us, we'll call you." In traditional code, your class calls a library. With IoC, the framework calls your code — it invokes your business methods at the right time after it has assembled the object graph. You supply the pieces; the framework orchestrates them. This inversion is what gives Spring its power and is why the principle is named what it is.

Summary

IoC reverses the responsibility for creating and connecting objects: instead of a class reaching out to instantiate its own dependencies, the dependencies are supplied from outside. Constructor injection is the preferred mechanism. Manual wiring proves the concept works; a Spring ApplicationContext automates it at scale. The payoff is code that is genuinely testable in isolation, straightforward to reconfigure, and aligned with the Single Responsibility Principle. The next lesson examines the Spring container itself — how it reads your declarations and manages the lifecycle of every object it controls.