← Series overview

Spring Boot from first principles · Module 03 of 05

Let Spring create objects and inject their dependencies.

The design from module 1 stays the same: OrderService depends on NotificationSender. The difference is that Spring now discovers the classes, creates the objects and calls the constructors.

After this module, you should be able to:
  • define a Spring bean and distinguish it from an ordinary Java object
  • use stereotypes and single-constructor injection
  • diagnose an ambiguous dependency
  • select a default with @Primary and an explicit implementation with @Qualifier

Bean basics

A bean is an object managed by the Spring container.

A bean definition tells Spring how to create an object. The resulting bean instance can then be injected into another bean. By default, a bean is a singleton within one ApplicationContext and one bean definition; this is not the same as a JVM-wide Singleton-pattern object.

@Component

A general-purpose class that component scanning should register.

@Service

A component whose role is application or business service logic.

@Repository

A persistence component. Its additional database behaviour matters later in this series.

These stereotypes describe roles; @Service and @Repository are specialised forms of @Component.

It helps to separate two things that are often both called “a bean.” A bean definitionis Spring's recipe: type, name, scope and construction information. A bean instance is the actual Java object created from that recipe.

A scanned definition becomes an instance
@Service
public class OrderService {
    // Spring registers a bean definition while scanning.
    // During context refresh, it creates the OrderService object.
}

Registration phase

Spring scans metadata and records that an OrderService bean can be created.

Instantiation phase

Spring calls the constructor after it has worked out what every parameter should receive.

Component scanning does not inject an annotation into the object. It discovers a class, registers a definition, and later creates and wires the object through normal Java constructors.

What makes a bean different?

The Java object is ordinary; container ownership adds behaviour around it.

Manual object
OrderService service =
        new OrderService(notificationSender);
Container-managed object
@Service
public class OrderService {
    public OrderService(NotificationSender notificationSender) {
        // Spring calls this same Java constructor.
    }
}

Both values are instances of the same Java class. The second is a bean because the context owns its creation, dependency resolution, scope and lifecycle. Annotating a class does not prevent manual construction, although a manually created instance is not automatically registered with Spring.

Constructor injection

The constructor makes required dependencies explicit.

OrderService.java
package com.example.orders.service;

import org.springframework.stereotype.Service;
import com.example.orders.notification.NotificationSender;

@Service
public class OrderService {
    private final NotificationSender notificationSender;

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

Spring sees one constructor, resolves its parameters from the context and calls it. @Autowired is unnecessary when a class has a single constructor. The final field prevents the required dependency from being replaced after construction.

How Spring reasons about the constructor
OrderService(
    NotificationSender notificationSender
)

// Question Spring asks:
// "Which bean in this context can satisfy NotificationSender?"

Injection is primarily type-based. Spring does not care about the local field name when one unique bean satisfies the type. It first identifies the constructor, then resolves each parameter, then invokes the constructor with those instances.

One constructor

No @Autowired required

Spring uses the only available constructor.

Several constructors

The choice must be unambiguous

Mark the intended injectable constructor or redesign the alternatives so construction is clear.

Why field injection hides information
@Service
public class OrderService {
    @Autowired
    private NotificationSender notificationSender;
}

A normal Java caller can construct this class without setting the field, so the object is temporarily incomplete. The dependency is also absent from the public construction contract. Constructor injection avoids both problems.

Multiple implementations

Matching the type is necessary, but sometimes not sufficient.

If email and SMS beans both implement NotificationSender, an unqualified injection point has two candidates. Spring knows the required type but cannot infer the business decision.

Default and explicit selection
@Component
@Primary
public class EmailNotificationSender implements NotificationSender {
    // ...
}

@Component
@Qualifier("sms")
public class SmsNotificationSender implements NotificationSender {
    // ...
}

public OtpService(
        @Qualifier("sms") NotificationSender notificationSender
) {
    this.notificationSender = notificationSender;
}
  • @Primary marks email as the default candidate.
  • @Qualifier("sms") explicitly narrows the OTP dependency to SMS.
  • An explicit qualifier wins over the primary choice at that injection point.

A parameter name can sometimes act as a fallback when it exactly matches a bean name and parameter-name metadata is present. That is a fragile place to encode a business rule; use a qualifier when the choice matters.

Candidates for NotificationSenderInjection pointResult
NoneUnqualifiedStartup fails: the required dependency is missing.
Email onlyUnqualifiedEmail is injected because the type has one candidate.
Email + SMSUnqualifiedStartup fails unless one candidate is preferred.
Email @Primary + SMSUnqualifiedEmail is injected.
Email @Primary + SMS @Qualifier("sms")@Qualifier("sms")SMS is injected; the explicit qualifier narrows the candidates.

Think of @Primary as the answer to “which candidate should be the general default?” Think of @Qualifieras part of the injection point's requirement: “I need the SMS-flavoured candidate here.”

Bean names and qualifier labels

A default bean name and a semantic qualifier are related but distinct.

Default names from component classes
@Component
public class EmailNotificationSender { }
// default bean name: emailNotificationSender

@Component
@Qualifier("sms")
public class SmsNotificationSender { }
// default bean name: smsNotificationSender
// qualifier metadata: sms

Spring normally converts the class name to lower camel case for the default bean name. @Qualifier("sms") adds selection metadata; it does not rename the bean to sms. An injection point with the same qualifier selects candidates carrying that label.

Naming a bean is a different operation
@Component("smsSender")
public class SmsNotificationSender
        implements NotificationSender {
}
// explicit bean name: smsSender

Bean-name fallback can occasionally make a parameter named smsNotificationSender resolve successfully. That couples the choice to a local variable name and compiler metadata. For an intentional business distinction such as “OTP must use SMS,” a qualifier communicates the rule directly.

Scope and identity

Default singleton means one instance per definition per context.

Two consumers receive the same default bean instance
@Service
class OrderService {
    OrderService(NotificationSender sender) { /* ... */ }
}

@Service
class ReceiptService {
    ReceiptService(NotificationSender sender) { /* ... */ }
}

// If email is the selected singleton bean, both constructors
// receive the same email bean object in this ApplicationContext.

This is a container scope, not a JVM-wide guarantee. A second ApplicationContext creates its own singleton. Two different bean definitions can also produce two instances of the same Java class.

Two definitions, therefore two managed instances
@Bean
NotificationSender customerEmailSender() {
    return new EmailNotificationSender();
}

@Bean
NotificationSender operationsEmailSender() {
    return new EmailNotificationSender();
}

Run code after startup

CommandLineRunner is itself a bean.

Boot calls every CommandLineRunner after the context has started. A runner is useful for this non-web learning application because it lets us exercise injected services without manually asking the context for them.

The container creates the whole path
EmailNotificationSender
        ↓ injected into
OrderService
        ↓ injected into
DemoRunner
        ↓ invoked by Boot after startup

If any arrow cannot be resolved, the context fails before DemoRunner.run is called. That fail-fast behaviour keeps an application from accepting work with an incomplete object graph.

Practice checkpoint

Write it before reading the solution.

Suggested time: 20 minutes

Extend the order application with two notification implementations.

  • Email must be the default for OrderService.
  • OtpService must always request SMS explicitly.
  • A runner must invoke both services after startup.
  • Include correct package declarations and imports; the submission must compile as written.
Hints, in increasing order
  1. Place every class below com.example.orders so it is scanned.
  2. Make email @Primary; give SMS the qualifier sms.
  3. Put @Qualifier("sms") on the OtpService constructor parameter.
  4. Inject both services into a CommandLineRunner component.
Working solution
notification package
// NotificationSender.java
package com.example.orders.notification;

public interface NotificationSender {
    void send(String destination, String message);
}

// EmailNotificationSender.java
package com.example.orders.notification;

import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Component;

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

// SmsNotificationSender.java
package com.example.orders.notification;

import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;

@Component
@Qualifier("sms")
public class SmsNotificationSender implements NotificationSender {
    @Override
    public void send(String destination, String message) {
        System.out.println("SMS to " + destination + ": " + message);
    }
}
service package
// OrderService.java
package com.example.orders.service;

import org.springframework.stereotype.Service;
import com.example.orders.notification.NotificationSender;

@Service
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"
        );
    }
}

// OtpService.java
package com.example.orders.service;

import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import com.example.orders.notification.NotificationSender;

@Service
public class OtpService {
    private final NotificationSender notificationSender;

    public OtpService(
            @Qualifier("sms") NotificationSender notificationSender
    ) {
        this.notificationSender = notificationSender;
    }

    public void sendOtp(String destination, String otp) {
        notificationSender.send(destination, "OTP: " + otp);
    }
}
DemoRunner.java
package com.example.orders.runner;

import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import com.example.orders.service.OrderService;
import com.example.orders.service.OtpService;

@Component
public class DemoRunner implements CommandLineRunner {
    private final OrderService orderService;
    private final OtpService otpService;

    public DemoRunner(OrderService orderService, OtpService otpService) {
        this.orderService = orderService;
        this.otpService = otpService;
    }

    @Override
    public void run(String... args) {
        orderService.placeOrder("ORD-201", "customer@example.com");
        otpService.sendOtp("+919876543210", "482731");
    }
}
Expected output
EMAIL to customer@example.com: Order ORD-201 placed successfully
SMS to +919876543210: OTP: 482731
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.

Failure diagnosis

What happens if both implementations have only @Component?

Startup fails at the unqualified OrderService injection point because two beans satisfy NotificationSender. The failure happens while Spring creates the context, before the runner executes.