← Series overview

Spring Boot from first principles · Module 01 of 05

Why Spring exists: separate use from creation.

Spring is easiest to understand after seeing the wiring problem it solves in plain Java. The central idea is that a class should use its dependencies without deciding which concrete implementations to create.

After this module, you should be able to:
  • identify tight coupling caused by constructing a concrete dependency
  • separate dependency inversion from dependency injection
  • use constructor injection and recognise a composition root
  • explain why business code normally does not call ApplicationContext.getBean()

Start with the problem

The dependent class is making two decisions.

This version of OrderService both places an order and chooses how notifications are sent:

Tightly coupled OrderService
public class OrderService {
    private final EmailNotificationSender notificationSender =
            new EmailNotificationSender();

    public void placeOrder(String orderId, String destination) {
        notificationSender.send(
                destination,
                "Order " + orderId + " placed successfully"
        );
    }
}

The service is coupled to EmailNotificationSender. Replacing email with SMS requires editing the service, and an isolated test cannot provide a harmless fake sender. The problem is not the new keyword by itself; it is where the concrete choice is made.

Read the code from the service's point of view. To place one order it now needs to know that email exists, how an email sender is constructed and that email is the policy currently chosen by the application. Those are wiring decisions, not order-placement decisions.

Business responsibility

What should happen?

After an order is accepted, send a confirmation to its destination.

Construction responsibility

Which object should do it?

Use email today, perhaps SMS tomorrow, and construct the chosen client.

Tight coupling appears because both responsibilities are inside the same class. The cost becomes visible when requirements change: adding SMS, recording sent messages in a test, or wrapping the sender with retry logic all require changes to OrderService.

Three related ideas

DIP changes the direction. DI supplies the object.

Dependency inversion principle

High-level policy such as OrderService depends on an abstraction such as NotificationSender, not directly on email or SMS.

Dependency injection

The dependency is supplied from outside, usually through the constructor. The service does not construct the object it depends on.

Inversion of control

Object creation and wiring move out of business classes. Plain Java can do this manually; Spring later acts as the container that performs the wiring.

The service depends on a contract
public interface NotificationSender {
    void send(String destination, String message);
}

public class OrderService {
    private final NotificationSender notificationSender;

    public OrderService(NotificationSender notificationSender) {
        this.notificationSender = notificationSender;
    }
}

The dependency arrow now points from the high-level service to a stable capability—NotificationSender. Email and SMS also depend on that same contract by implementing it. The interface belongs near the policy that needs the capability; it is not merely a shared folder for implementation classes.

A tempting half-fix

Declaring the field as an interface is not enough.

This code looks more abstract because the field type is an interface, but the service still names and creates the concrete email class:

An abstraction without injection
public class OrderService {
    private final NotificationSender notificationSender =
            new EmailNotificationSender();

    // ...
}

The compile-time dependency on EmailNotificationSender remains because of the constructor call. The service cannot receive SMS or a test fake. Dependency injection is missing: nobody outside the service gets a chance to supply the dependency.

The dependency is actually injected
public class OrderService {
    private final NotificationSender notificationSender;

    public OrderService(NotificationSender notificationSender) {
        this.notificationSender = notificationSender;
    }
}

Now construction cannot finish without a sender. That is useful: the class cannot exist in an invalid, partially initialised state, and every caller can see the requirement in the constructor signature.

Why constructor injection

The dependency becomes required, visible and stable.

Required

The constructor must receive a sender before the service can be used.

Visible

A caller or test can discover dependencies without reading private fields.

Stable

A final field prevents replacement after construction.

Setter injection can be useful for a genuinely optional, reconfigurable collaborator. Field injection hides the requirement from normal Java callers and makes isolated tests awkward. For required services, constructor injection is the default we will use throughout this series.

Two valid object graphs from the same service class
NotificationSender email = new EmailNotificationSender();
OrderService emailOrders = new OrderService(email);

NotificationSender sms = new SmsNotificationSender();
OrderService smsOrders = new OrderService(sms);

An object graph is simply the connected set of runtime objects. The two graphs above contain the same service type but different concrete dependencies. No condition or notification-specific branch was added to the service.

Where new belongs

The outer edge assembles the object graph.

In a plain Java program, Application can be the composition root: the one place that chooses concrete implementations and connects them.

Manual dependency injection
public class Application {
    public static void main(String[] args) {
        NotificationSender sender = new EmailNotificationSender();
        OrderService orderService = new OrderService(sender);

        orderService.placeOrder("ORD-101", "customer@example.com");
    }
}

Creating the concrete object here does not make OrderService tightly coupled. The composition root is supposed to know the implementations. If selection later becomes complex, configuration or a factory can centralise it without changing the service.

The composition root is intentionally coupled to concrete implementations. Its job is to know what the rest of the application should not need to know. A small command-line program may use main; a Spring application uses configuration and the container.

Configuration chooses; the service only uses
public class NotificationSenderFactory {
    public NotificationSender create(String channel) {
        return switch (channel) {
            case "email" -> new EmailNotificationSender();
            case "sms" -> new SmsNotificationSender();
            default -> throw new IllegalArgumentException(
                    "Unknown channel: " + channel
            );
        };
    }
}

public class Application {
    public static void main(String[] args) {
        String channel = System.getenv()
                .getOrDefault("NOTIFICATION_CHANNEL", "email");

        NotificationSender sender =
                new NotificationSenderFactory().create(channel);
        OrderService orderService = new OrderService(sender);
    }
}

The factory is not automatically superior to direct construction. It becomes useful when selection has real logic. The important boundary remains unchanged:OrderService receives a ready-to-use NotificationSender.

The testing payoff

A fake dependency can observe behaviour without sending anything.

Because the constructor accepts the contract, a unit test can provide a tiny implementation that records the call:

A plain Java test fake
public class RecordingNotificationSender
        implements NotificationSender {

    private String destination;
    private String message;

    @Override
    public void send(String destination, String message) {
        this.destination = destination;
        this.message = message;
    }

    public String destination() {
        return destination;
    }

    public String message() {
        return message;
    }
}
Testing OrderService without Spring
@Test
void placesOrderAndSendsConfirmation() {
    RecordingNotificationSender sender =
            new RecordingNotificationSender();
    OrderService service = new OrderService(sender);

    service.placeOrder("ORD-101", "customer@example.com");

    assertEquals(
            "customer@example.com",
            sender.destination()
    );
    assertEquals(
            "Order ORD-101 placed successfully",
            sender.message()
    );
}

This test does not start Spring and does not contact an email system. That is a direct benefit of the design boundary. Spring will automate production wiring, but ordinary Java construction remains available for focused unit tests.

Where ApplicationContext fits

Spring becomes the composition root, not a global object lookup.

A Spring ApplicationContext creates and connects managed objects. Controllers and services normally receive dependencies through constructors; they do not call applicationContext.getBean(...). Doing that makes dependencies hidden and turns the context into a service locator.

Direct context access is mostly for bootstrap code, framework integration, specialised tests, legacy boundaries or genuinely dynamic plugin lookup. Also, not every object belongs in the context: DTOs, JPA entities, value objects and exceptions are commonly created with new.

Normal application code

private final NotificationSender sender;

public OrderService(
        NotificationSender sender
) {
    this.sender = sender;
}

The dependency is explicit and easy to replace in a test.

Service-locator style

NotificationSender sender =
        context.getBean(
                NotificationSender.class
        );

The dependency is hidden inside a method and the class now knows Spring.

Framework bootstrap code may legitimately hold the context. A plugin host may need runtime lookup when the implementation is not knowable at construction time. Those are specialised boundaries. A regular controller-to-service flow should use injection.

Not every new is a design problem

Manage long-lived collaborators; construct ordinary values normally.

Ordinary object creation is still ordinary Java
OrderId orderId = new OrderId("ORD-101");
Order order = new Order(orderId, customerId);
List<OrderLine> lines = new ArrayList<>();
IllegalArgumentException error =
        new IllegalArgumentException("orderId is required");

These objects represent data, domain state or a local collection. They are not application-wide collaborators with configuration and lifecycle needs. In contrast, an email client, repository, clock or service is often shared, configured and injected.

Practice checkpoint

Write it before reading the solution.

Suggested time: 12 minutes

Refactor a tightly coupled OrderService so the same class can send order confirmations through either email or SMS.

  • Create the contract and two implementations.
  • Use constructor injection.
  • Create two differently wired services in Application.main.
  • Do not use Spring yet.
Hints, in increasing order
  1. Put the shared send operation in an interface.
  2. Make the OrderService constructor accept that interface.
  3. Let Application.main choose and construct email and SMS implementations.
Working solution
Five separate Java files
// NotificationSender.java
public interface NotificationSender {
    void send(String destination, String message);
}

// EmailNotificationSender.java
public class EmailNotificationSender implements NotificationSender {
    @Override
    public void send(String destination, String message) {
        System.out.println("EMAIL to " + destination + ": " + message);
    }
}

// SmsNotificationSender.java
public class SmsNotificationSender implements NotificationSender {
    @Override
    public void send(String destination, String message) {
        System.out.println("SMS to " + destination + ": " + message);
    }
}

// OrderService.java
public class OrderService {
    private final NotificationSender notificationSender;

    public OrderService(NotificationSender notificationSender) {
        this.notificationSender = notificationSender;
    }

    public void placeOrder(String orderId, String destination) {
        notificationSender.send(
                destination,
                "Order " + orderId + " placed successfully"
        );
    }
}

// Application.java
public class Application {
    public static void main(String[] args) {
        NotificationSender email = new EmailNotificationSender();
        NotificationSender sms = new SmsNotificationSender();

        new OrderService(email).placeOrder(
                "ORD-101",
                "customer@example.com"
        );
        new OrderService(sms).placeOrder(
                "ORD-102",
                "+919876543210"
        );
    }
}

The important result is not merely the interface. The service no longer creates a concrete sender, and the constructor makes its dependency visible.

Record after the attempt

Elapsed time · hint level (H0 means no hint; H1–Hn is the last hint used) · conceptual errors · mechanical errors · explanation confidence from 0–2.

Foundation check

Can you explain this without saying “Spring does it”?

Constructor injection is a Java design technique. Spring automates the object creation and wiring, but the loose coupling comes from the dependency boundary you designed.