0. Introduction: Why using interfaces?
A Java interface defines a set of behaviors as abstract methods. A class that implements that interface has to fulfill those behaviors. An interface defines what happens, an object defines how it happens.
Interfaces allow to decouple implementations. This reduces dependencies between implementations and allows to change implementations independently.
Sample Analogy: Booking a Room
Suppose you are designing an application that allows customers to book hotel rooms. When a customer makes a reservation, they are not concerned with the name of the hotel; they are primarily interested in the destination city and the check-in and check-out dates. The room booking application therefore provides an interface in which the customer requests an accommodation, rather than a specific hotel.
1. Scenario: Online Food Delivery Application
Suppose you are designing an online food delivery application.
Implementing the Place An Order use case in plain Java
In this example, the RestaurantService class implements the Place An Order use case.
When a customer places an order, the restaurant has to:
- store the order to database
- send a confirmation email to the customer
- send a payment request to the payment service
- send a delivery request to the delivery service
To avoid coupling the RestaurantService class to specific implementations, we define four interfaces.
Interfaces
public interface OrderRepository {
void save(Order order);
}
public interface NotificationClient {
void notifyCustomer(Order order);
}
public interface PaymentClient {
void processPayment(Order order);
}
public interface DeliveryClient {
void requestDelivery(Order order);
}
Application's Domain Model
MenuItem
public class MenuItem {
private final String name;
private final Double price;
public MenuItem(String name, Double price) {
this.name = name;
this.price = price;
}
public String getName() {
return name;
}
public Double getPrice() {
return price;
}
}
Customer
public class Customer {
private final String name;
private final String email;
public Customer(String name, String email) {
this.name = name;
this.email = email;
}
public String getName() {
return name;
}
public String getEmail() {
return email;
}
}
Order
public class Order {
private final Customer customer;
private final List<MenuItem> items;
public Order(Customer customer, List<MenuItem> items) {
this.customer = customer;
this.items = items;
}
public Customer getCustomer() {
return customer;
}
public List<MenuItem> getItems() {
return items;
}
public double total() {
double total = this.getItems()
.stream()
.mapToDouble(MenuItem::getPrice)
.sum();
return total;
}
}
Interface implementations
public class DatabaseOrderRepository
implements OrderRepository {
@Override
public void save(Order order) {
System.out.println(
"Saving order for customer: "
+ order.getCustomer().getName()
);
}
}
public class EmailNotificationClient
implements NotificationClient {
@Override
public void notifyCustomer(Order order) {
System.out.println(
"Sending confirmation email to "
+ order.getCustomer().getEmail()
);
}
}
public class StripePaymentClient
implements PaymentClient {
@Override
public void processPayment(Order order) {
System.out.println(
"Stripe Payment: Processing Payment of € %s"
.formatted(order.total())
);
}
}
public class DeliverooDeliveryClient
implements DeliveryClient {
@Override
public void requestDelivery(Order order) {
System.out.println(
"Deliveroo: Sent a delivery request for customer "
+ order.getCustomer().getName()
);
}
}
RestaurantService class
The RestaurantService class implements the "PLACE AN ORDER" use case
The RestaurantService class has four dependencies:
OrderRepositoryNotificationClientPaymentClientDeliveryClient
These dependencies are declared using interface types.
public class RestaurantService {
private final OrderRepository orderRepository;
private final NotificationClient notificationClient;
private final PaymentClient paymentClient;
private final DeliveryClient deliveryClient;
public RestaurantService(OrderRepository orderRepository,
NotificationClient notificationClient,
PaymentClient paymentClient,
DeliveryClient deliveryClient) {
this.orderRepository = orderRepository;
this.notificationClient = notificationClient;
this.PaymentClient = paymentClient;
this.deliveryClient = deliveryClient;
}
public void placeOrder(Order order) {
orderRepository.save(order);
notificationClient.notifyCustomer(order);
paymentClient.processPayment(order);
deliveryClient.requestDelivery(order);
}
}
The RestaurantService class does not create its dependencies directly.
Instead, the dependencies are provided from outside through the constructor.
Main class
public class Main {
public static void main(String[] args) {
OrderRepository orderRepository = new DatabaseOrderRepository();
NotificationClient notificationClient = new EmailNotificationClient();
PaymentClient paymentClient = new StripePaymentClient();
DeliveryClient deliveryClient = new DeliverooDeliveryClient();
RestaurantService restaurant = new RestaurantService(
orderRepository,
notificationClient,
paymentClient,
deliveryClient
);
Customer customer = new Customer("John Smith", "john@email.com");
MenuItem pizza = new MenuItem("Margherita Pizza", 12.50);
MenuItem beer = new MenuItem("Peroni Beer", 6.00);
List<MenuItem> items = List.of(pizza, beer);
Order order = new Order(customer, items);
restaurant.placeOrder(order);
}
}
In this plain Java example, the Main class manually creates the objects and injects them into the RestaurantService, but in a Spring application, Spring will create and inject these dependencies automatically.
2. Implementing the Place An Order use case using Spring
In the previous example, dependencies were created manually in the Main class using plain Java.
Now, you apply the Spring Framework to the same design model to see how Spring manages dependency injection automatically.
2.1 Which objects should be added to the Spring Context?
Objects are added to the Spring context so that Spring can manage them as Spring beans.
One of the main advantages of Spring-managed beans is dependency injection.
A simple rule is:
If an object has dependencies, or if it is used as a dependency by another object, it should usually be added to the Spring context.
Let's analyze the objects of the application:
RestaurantServicehas dependenciesDatabaseOrderRepositoryis a dependency ofRestaurantServiceEmailNotificationClientis a dependency ofRestaurantServiceStripePaymentClientis a dependency ofRestaurantServiceDeliverooDeliveryClientis a dependency ofRestaurantServiceOrder,Customer, andMenuItemare model objects and do not need to be Spring beans
2.2 Adding Objects to the Spring Context Using Stereotype Annotations
The easiest way to add a class to the Spring context is by using the @Component annotation.
When Spring starts the application, it scans the configured packages and automatically creates beans for classes annotated with a stereotype annotation.
Stereotype annotations are applied to concrete classes because Spring can instantiate them. Interfaces and abstract classes cannot be instantiated directly and therefore are not annotated.
@Service
RestaurantService
/ | | \
/ | | \
uses/ uses | uses | uses \
/ | | \
<<interface>> <<interface>> <<interface>> <<interface>>
OrderRepository NotificationClient PaymentClient DeliveryClient
▲ ▲ ▲ ▲
| | | |
| implements | implements | implements | implements
| | | |
@Repository @Component @Component @Component
DatabaseOrderRepository EmailNotificationClient StripePaymentClient DeliverooDeliveryClient
The diagram shows the five concrete classes annotated.
2.3 Constructor Dependency Injection with Spring
Dependency Classes annotated
@Repository
public class DatabaseOrderRepository implements OrderRepository {
@Override
public void save(Order order) {
System.out.println(
"Saving order for customer: "
+ order.getCustomer().getName()
);
}
}
@Component
public class EmailNotificationClient implements NotificationClient {
@Override
public void notifyCustomer(Order order) {
System.out.println(
"Sending confirmation email to "
+ order.getCustomer().getEmail()
);
}
}
@Component
public class StripePaymentClient implements PaymentClient {
@Override
public void processPayment(Order order) {
double total = order.getItems().stream().mapToDouble(MenuItem::getPrice).sum();
System.out.println("Processing payment of €" + total);
}
}
@Component
public class DeliverooDeliveryClient implements DeliveryClient {
@Override
public void requestDelivery(Order order) {
System.out.println("Deliveroo: Sent a delivery request for customer " + order.getCustomer().getName()
);
}
}
The RestaurantService Class
The RestaurantService class is annotated with @Service.
The constructor remains identical to the plain Java version.
@Service
public class RestaurantService {
private final OrderRepository orderRepository;
private final NotificationClient notificationClient;
private final PaymentClient paymentClient;
private final DeliveryClient deliveryClient;
public RestaurantService(
OrderRepository orderRepository,
NotificationClient notificationClient,
PaymentClient paymentClient,
DeliveryClient deliveryClient) {
this.orderRepository = orderRepository;
this.notificationClient = notificationClient;
this.paymentClient = paymentClient;
this.deliveryClient = deliveryClient;
}
public void placeOrder(Order order) {
orderRepository.save(order);
notificationClient.notifyCustomer(order);
paymentClient.processPayment(order);
deliveryClient.requestDelivery(order);
}
}
How Does Spring Inject Dependencies?
Spring uses constructor dependency injection to create the RestaurantService bean.
The constructor parameters are declared using interface types:
OrderRepository
NotificationClient
PaymentClient
DeliveryClient
Spring searches the application context for beans implementing these interfaces and automatically injects them into the constructor.
The constructor does not need the
@Autowiredannotation because the class has only one constructor. In modern Spring applications, a single constructor is automatically used for dependency injection.
2.4 Using @ComponentScan
Spring needs to know which packages must be scanned for components.
This is configured using the @ComponentScan annotation.
@Configuration
@ComponentScan(
basePackages = {
"repositories",
"clients",
"services"
}
)
public class ProjectConfiguration {
}
The ProjectConfiguration class tells Spring where to search for classes annotated with @Component.
2.5 Main Class
To test the application, create the Spring context and retrieve the RestaurantService bean from it.
public class Main {
public static void main(String[] args) {
var context = new AnnotationConfigApplicationContext(ProjectConfiguration.class);
Customer customer = new Customer("John Smith","john@email.com");
MenuItem pizza = new MenuItem("Margherita Pizza",12.50);
MenuItem beer = new MenuItem("Peroni Beer",6.00);
List<MenuItem> items = List.of(pizza, beer);
Order order = new Order(customer, items);
var restaurantService = context.getBean(RestaurantService.class);
restaurantService.placeOrder(order);
}
}
2.6 Advantages of Spring Dependency Injection
Notice that in the Spring version:
- you do not manually create the
RestaurantService - you do not manually create its dependencies
- you do not manually connect the objects together
Spring creates the objects and injects the dependencies automatically.
This reduces boilerplate code and allows developers to focus on the application logic instead of object creation and wiring.
The RestaurantService depends only on interfaces, while Spring is responsible for providing the concrete implementations at runtime. This combination of interfaces and dependency injection results in a flexible, maintainable, and loosely coupled application.
3. Spring Dependency Injection with Multiple Interfaces
3.1. Multiple Implementations of an Interface
Suppose the Spring context contains multiple beans whose classes implement the same interface.
For example, the RestaurantService service depends on four interfaces:
OrderRepositoryNotificationClientPaymentClientDeliveryClient
Assume that the application provides two implementations of the PaymentClient interface:
StripePaymentClientfor web customersWalletPaymentClientfor mobile app customers
Both implementations are candidates for injection.
RestaurantService
/ | | \
/ | | \
/ | | \
▼ ▼ ▼ ▼
OrderRepository NotificationClient PaymentClient DeliveryClient
▲
|
________________|________________
| |
| |
StripePaymentClient WalletPaymentClient
You add a new implementation of the PaymentClient interface:
@Component
public class WalletPaymentClient implements PaymentClient {
@Override
public void processPayment(Order order) {
double total = order.getItems().stream().mapToDouble(MenuItem::getPrice).sum();
System.out.println("Processing wallet payment of €" + total);
}
}
Both StripePaymentClient and WalletPaymentClient implement the PaymentClient interface, making both beans candidates for injection.
When Spring finds multiple beans matching the same dependency type, it cannot determine which one should be injected. If you run the application in this situation, Spring throws a: NoUniqueBeanDefinitionException
To resolve this ambiguity, Spring provides two common approaches:
- use
@Primaryto define a default implementation - use
@Qualifierto specify exactly which implementation should be injected
Note: Spring uses the same mechanisms, such as
@Primaryand@Qualifier, to resolve dependency injection ambiguities whether multiple beans share the same class type or multiple beans implement the same interface.
3.2 Marking a Class as Default for Injection Using @Primary
The @Primary annotation marks a bean as the default choice for dependency injection.
If Spring finds multiple beans implementing the same interface, it automatically selects the bean marked with @Primary.
@Primary
@Component
public class StripePaymentClient implements PaymentClient {
@Override
public void processPayment(Order order) {
double total = order.getItems().stream().mapToDouble(MenuItem::getPrice).sum();
System.out.println("Stripe Payment: Processing Payment of €" + total);
}
}
Now, whenever Spring needs a PaymentClient, it injects a StripePaymentClient unless instructed otherwise.
When is @Primary useful?
Suppose a third-party library provides an implementation of an interface, but that implementation is not suitable for your application. You can create your own implementation and annotate it with @Primary so that Spring uses it by default.
3.3 Naming Implementations with the @Qualifier Annotation
Sometimes different services require different implementations of the same interface.
In this case, using a single default implementation with @Primary is not sufficient.
The @Qualifier annotation allows you to assign a name to a bean and later request that specific bean for injection.
Naming the Stripe Payment implementation
@Component
@Qualifier("STRIPE")
public class StripePaymentClient implements PaymentClient {
@Override
public boolean processPayment(Order order) {
double total = order.getItems().stream().mapToDouble(MenuItem::getPrice).sum();
System.out.println("Processing Stripe payment of €" + total);
}
}
Naming the Wallet Payment implementation
@Component
@Qualifier("WALLET")
public class WalletPaymentClient implements PaymentClient {
@Override
public boolean processPayment(Order order) {
double total = order.getItems().stream().mapToDouble(MenuItem::getPrice).sum();
System.out.println("Processing wallet payment of €" + total);
}
}
Injecting a Specific Implementation
You can then use @Qualifier on the constructor parameter to tell Spring exactly which implementation to inject.
RestaurantService configured for web clients
@Service
public class WebRestaurantService {
private final OrderRepository orderRepository;
private final NotificationClient notificationClient;
private final PaymentClient paymentClient;
private final DeliveryClient deliveryClient;
public WebRestaurantService(
OrderRepository orderRepository,
NotificationClient notificationClient,
@Qualifier("STRIPE")
PaymentClient paymentClient,
DeliveryClient deliveryClient) {
this.orderRepository = orderRepository;
this.notificationClient = notificationClient;
this.PaymentClient = paymentClient;
this.deliveryClient = deliveryClient;
}
public void placeOrder(Order order) {
orderRepository.save(order);
notificationClient.notifyCustomer(order);
paymentClient.processPayment(order);
deliveryClient.requestDelivery(order);
}
}
In this example, Spring injects the bean named "STRIPE", which is the StripePaymentClient implementation.
Summary
When multiple beans implement the same interface, Spring cannot automatically determine which implementation should be injected.
Two common solutions are:
@Primary: designates a default implementation for dependency injection.@Qualifier: explicitly identifies the implementation that should be injected.
In general:
- Use
@Primarywhen one implementation should be the default choice throughout the application. - Use
@Qualifierwhen different parts of the application require different implementations of the same interface.
In the Online Food Delivery Application, both StripePaymentClient and WalletPaymentClient implement the PaymentClient interface. By using @Primary or @Qualifier, you can control which payment provider Spring injects into the RestaurantService service.
No comments:
Post a Comment