Dependency Injection (concepts & Spring basics)

Reviewed & published by Brayan K

Dependency injection is the technique of giving an object the collaborators it needs from the outside — through its constructor — instead of having it create them itself, which decouples your code and makes it far easier to test.

Part of the free Java course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

Before You Start

You should be comfortable with interfaces (DI depends on programming to abstractions) and constructors. A look at lambdas helps for the functional-interface examples.

What You'll Learn

The Big Idea — Power Sockets, Not Soldered Wires

💡 Analogy: A class that news its own dependencies is like a lamp with its power cable soldered straight into the wall — it works, but you can never move it, test it on a bench, or swap the bulb's power source. Dependency injection gives the lamp a plug and the wall a socket: the lamp just declares "I need power" and whoever installs it decides what to plug in — mains power, a battery pack, or a test rig. The lamp doesn't change; only what you plug in does.

"Inversion of Control" is simply this idea: the lamp no longer controls where its power comes from — an outside installer does.

1️⃣ The Problem — Hard-Wired Dependencies

When a class builds its own collaborators with new, it is welded to that exact implementation. You can't swap EmailService for an SMS one, and you certainly can't test the class without triggering the real thing. That is tight coupling.

public class Main {
    // WITHOUT DI: the class creates its OWN dependency — tightly coupled.
    static class EmailService {
        public String send(String to, String msg) { return "EMAIL to " + to + ": " + msg; }
    }

    static class OrderServiceTight {
        // hard-wired: you can never swap this out (e.g. for a test fake)
        private final EmailService email = new EmailService();

        public String placeOrder(String customer) {
            return email.send(customer, "Your order is confirmed");
        }
    }

    public static void main(String[] args) {
        OrderServiceTight svc = new OrderServiceTight();
        System.out.println(svc.placeOrder("ana@site.com"));
        // No way to test placeOrder without sending a "real" email.
    }
}

2️⃣ The Fix — Constructor Injection

Depend on an interface and receive the implementation through the constructor. Now the same OrderService works with an email notifier, an SMS notifier, or anything else — and the caller (a manual "injector") decides which to plug in.

public OrderService(Notifier notifier) {   // dependency handed in
    this.notifier = notifier;              // store the abstraction
}
public class Main {
    // WITH DI: depend on an INTERFACE, receive it through the constructor.
    interface Notifier {
        String notify(String to, String msg);
    }

    static class EmailNotifier implements Notifier {
        public String notify(String to, String msg) { return "EMAIL to " + to + ": " + msg; }
    }

    static class SmsNotifier implements Notifier {
        public String notify(String to, String msg) { return "SMS to " + to + ": " + msg; }
    }

    static class OrderService {
        private final Notifier notifier;            // depends on the abstraction

        // constructor injection: the dependency is HANDED IN from outside
        public OrderService(Notifier notifier) { this.notifier = notifier; }

        public String placeOrder(String customer) {
            return notifier.notify(customer, "Your order is confirmed");
        }
    }

    public static void main(String[] args) {
        // The caller (a manual "injector") decides which implementation to use.
        OrderService viaEmail = new OrderService(new EmailNotifier());
        OrderService viaSms   = new OrderService(new SmsNotifier());

        System.out.println(viaEmail.placeOrder("ana@site.com"));
        System.out.println(viaSms.placeOrder("+15551234"));
    }
}

3️⃣ The Payoff — Testability

Because the dependency is an interface passed in, a test can inject a fake that records calls instead of doing real work. You can then assert exactly what the class did — with no email server, no network, no flakiness.

import java.util.*;

public class Main {
    interface Notifier { String notify(String to, String msg); }

    static class OrderService {
        private final Notifier notifier;
        public OrderService(Notifier notifier) { this.notifier = notifier; }
        public String placeOrder(String customer) {
            return notifier.notify(customer, "Your order is confirmed");
        }
    }

    // A TEST DOUBLE: because OrderService depends on the interface, we can
    // inject a fake that records calls instead of really sending anything.
    static class FakeNotifier implements Notifier {
        final List<String> sent = new ArrayList<>();
        public String notify(String to, String msg) {
            sent.add(to + "|" + msg);
            return "FAKE";
        }
    }

    public static void main(String[] args) {
        FakeNotifier fake = new FakeNotifier();
        OrderService svc = new OrderService(fake);   // inject the fake

        svc.placeOrder("ana@site.com");
        svc.placeOrder("ben@site.com");

        // Now we can ASSERT on what happened — no real email sent.
        System.out.println("notifications sent: " + fake.sent.size());
        System.out.println("first call        : " + fake.sent.get(0));
        System.out.println("testable without real I/O? " + (fake.sent.size() == 2));
    }
}

4️⃣ How Spring Automates the Wiring

In a small program you do the wiring in main. In a large app that quickly becomes a tangle, so frameworks like Spring provide a container that wires everything for you. You annotate classes as beans with @Component / @Service, and Spring constructs the object graph and injects dependencies — often just by spotting the constructor (so @Autowired is optional on a single constructor).

// How a DI CONTAINER like Spring does the wiring for you.
// (Conceptual — needs the Spring framework on the classpath to run.)

public interface Notifier {
    String notify(String to, String msg);
}

@Component                              // Spring registers this as a bean
public class EmailNotifier implements Notifier {
    public String notify(String to, String msg) {
        return "EMAIL to " + to + ": " + msg;
    }
}

@Service                                // a Spring-managed service bean
public class OrderService {
    private final Notifier notifier;

    // Constructor injection: Spring sees one constructor and supplies the
    // matching Notifier bean automatically. @Autowired is optional here.
    @Autowired
    public OrderService(Notifier notifier) {
        this.notifier = notifier;
    }

    public String placeOrder(String customer) {
        return notifier.notify(customer, "Your order is confirmed");
    }
}

// You never call 'new OrderService(...)'. The container builds the graph:
// it creates an EmailNotifier, then an OrderService, and injects the first
// into the second — that is Inversion of Control in action.

🧠 Quick Recall

Predict before you peek.

Answer: control over creating and wiring collaborators. Instead of the class deciding what to new, an outside party (your code or a container) decides and supplies it.

Answer: so any implementation — real or fake — can be injected. Depending on the concrete class re-couples you to it and blocks substitution.

Answer: because it's set once in the constructor and never reassigned. final documents that, prevents accidental rebinding, and guarantees the object is fully initialised.

🎯 YOUR TURN — Inject a Logger

Make Calculator depend on the Logger interface via constructor injection, store it final, and log each addition.

public class Main {
    // YOUR TURN: apply constructor injection.
    interface Logger { void log(String msg); }

    static class ConsoleLogger implements Logger {
        public void log(String msg) { System.out.println("LOG: " + msg); }
    }

    static class Calculator {
        // Make Calculator depend on the Logger INTERFACE and receive it via a
        // constructor (store it in a private final field). add(a,b) should log
        // "a + b = r" and return r.
        //
        // Expected output:
        //   LOG: 2 + 3 = 5
        //   Result: 5

        // private final Logger logger;
        // constructor + add(int a, int b)
    }

    public static void main(String[] args) {
        Calculator calc = new Calculator(new ConsoleLogger());
        int sum = calc.add(2, 3);
        System.out.println("Result: " + sum);
    }
}

🧩 MINI-CHALLENGE — Two injected collaborators

Build a CheckoutService that takes a PaymentGateway and a Receipts recorder, wired with lambdas in main.

import java.util.*;

public class Main {
    // MINI-CHALLENGE: a CheckoutService that depends on TWO injected
    // collaborators — a PaymentGateway and a Receipts recorder — both passed
    // through the constructor. checkout() charges via the gateway; if it
    // succeeds it records a receipt line and returns "OK", else "DECLINED".
    // Wire it up in main with lambda implementations (interfaces with one
    // method are functional interfaces!).
    //
    // Expected output:
    //   checkout: OK
    //   receipts: [CHARGED acct-1 1999]

    interface PaymentGateway { boolean charge(String account, int cents); }
    interface Receipts { void record(String line); }

    public static void main(String[] args) {
        // PaymentGateway alwaysOk = (acct, cents) -> true;
        // Receipts recorder = log::add;  (back a list to record into)
        // build the service, call checkout, print result and the receipts list
    }
}

Common Errors

Pro Tips

📋 Quick Reference

ConceptSyntax / ideaNote
tight couplingnew EmailService()Avoid inside business classes
abstractioninterface NotifierDepend on this
constructor injectionSvc(Notifier n)Preferred form
final fieldprivate final Notifier n;Set once, never null
manual injectornew Svc(new Email())Wiring in main
test doublenew Svc(fake)No real I/O
IoCcontrol moves outwardDI implements it
Spring bean@Component / @ServiceContainer-managed
Spring inject@AutowiredOptional on 1 ctor
benefitdecoupling + testabilityThe whole point
avoidfield injection, singletonsHide dependencies

📚 Resources / Keep Learning

🎉 Lesson Complete!

You now understand Inversion of Control, why hard-wired new dependencies hurt, how to apply constructor injection against an interface in plain Java, how that unlocks easy testing with fakes, and how Spring's @Component/@Autowired automate the same idea at scale.

Practice quiz

What is dependency injection?

  • Creating dependencies inside a class with new
  • Injecting code at runtime via reflection only
  • Supplying a class's dependencies from the outside
  • A type of inheritance

Answer: Supplying a class's dependencies from the outside. DI means a class receives the objects it needs (its dependencies) from outside rather than constructing them itself.

What is Inversion of Control (IoC)?

  • Control of object creation/wiring moves out of the class to a caller or container
  • The class controls everything
  • Reversing a list
  • Running code backwards

Answer: Control of object creation/wiring moves out of the class to a caller or container. IoC flips who is in charge: instead of a class creating its collaborators, an external party provides them. DI is one way to achieve IoC.

Which is the preferred form of injection?

  • Field injection
  • Static injection
  • No injection
  • Constructor injection

Answer: Constructor injection. Constructor injection makes dependencies explicit and lets you mark them final, ensuring the object is fully and validly built.

Why does DI improve testability?

  • It makes code faster
  • You can inject fakes/mocks instead of real collaborators
  • It removes all bugs
  • It hides dependencies

Answer: You can inject fakes/mocks instead of real collaborators. Because a class depends on an abstraction passed in, tests can supply a fake implementation and assert on interactions — no real I/O.

DI works best when classes depend on...

  • interfaces / abstractions
  • concrete classes
  • static methods
  • global variables

Answer: interfaces / abstractions. Depending on an interface lets any implementation (real or fake) be injected, which is the core of decoupling.

In plain Java, who plays the role of the 'injector'?

  • The JVM
  • The garbage collector
  • The code that constructs and wires the objects (e.g. main or a factory)
  • Nobody

Answer: The code that constructs and wires the objects (e.g. main or a factory). Without a framework, your own wiring code (in main or a factory) creates implementations and passes them into constructors.

What does Spring's @Component annotation do?

  • Deletes a class
  • Marks a class as a bean for Spring to manage and inject
  • Makes a class final
  • Starts a thread

Answer: Marks a class as a bean for Spring to manage and inject. @Component (and @Service, @Repository) registers the class in Spring's container so it can be created and injected automatically.

What does @Autowired tell Spring to do?

  • Run the method twice
  • Log the call
  • Cache the result
  • Inject a matching bean into the field/constructor

Answer: Inject a matching bean into the field/constructor. @Autowired asks Spring to supply a managed bean of the required type; on a constructor it's often optional in modern Spring.

A benefit of decoupling via DI is...

  • Faster compilation only
  • You can swap implementations without changing the dependent class
  • Smaller JARs
  • No need for interfaces

Answer: You can swap implementations without changing the dependent class. Because the class only knows the interface, you can swap Email for SMS, or real for fake, without touching it.

Constructor injection lets you mark dependencies as...

  • static
  • volatile
  • final
  • transient

Answer: final. Dependencies assigned once in the constructor can be final, signalling they never change after construction.

Continue this course

Frequently asked questions

What exactly is dependency injection?

Dependency injection (DI) is a technique where an object receives the other objects it depends on — its dependencies — from an outside source, instead of creating them itself with new. Rather than OrderService building its own EmailService, OrderService declares 'I need a Notifier' and someone else supplies one. This small shift decouples the class from any specific implementation: it only knows the abstraction, not how the concrete collaborator is built or configured.

What is Inversion of Control and how does it relate to DI?

Inversion of Control (IoC) is the broader principle: control over creating and wiring objects is taken away from the classes that use them and given to an external party — your main method, a factory, or a framework's container. Dependency injection is the most common way to implement IoC. With IoC, your business classes focus on behaviour while the 'assembly' of the object graph is handled elsewhere, which is exactly what a DI container like Spring automates.

Why is constructor injection preferred over field injection?

Constructor injection passes dependencies as constructor parameters, which has several advantages: the dependencies are explicit (you can see everything a class needs by reading its constructor), they can be marked final (guaranteeing they're set once and never null), the object is always created in a fully valid state, and it's trivial to test by just calling new with fakes. Field injection (annotating a field with @Autowired) hides dependencies, allows partially-constructed objects, and makes plain-Java testing harder.

How does DI make code more testable?

Because a class depends on an interface that is handed in, a unit test can pass a fake or mock implementation instead of the real one. If OrderService takes a Notifier, the test injects a FakeNotifier that records calls in a list rather than sending real emails, then asserts on that list. No network, no database, no flakiness — the test is fast, deterministic, and focused purely on the class's own logic. Tight coupling (new-ing dependencies internally) makes this impossible.

Do I need Spring to use dependency injection?

No. DI is a design technique, not a framework feature — the plain-Java examples here use it with nothing but constructors and interfaces, and your main method does the wiring. Frameworks like Spring or Guice simply automate that wiring at scale: you annotate classes with @Component/@Service and the container builds the whole object graph for you, which is invaluable in large applications. But the principle, and its benefits, exist with zero dependencies.

Related lessons