Dependency Injection with Spring
Build a notification service using Spring Core DI patterns
Objectives
By the end of this practical work, you will be able to:
- Configure Spring Context without Spring Boot
- Use @Configuration and @Bean for explicit bean definitions
- Implement constructor injection for dependencies
- Use @Qualifier and @Primary for multiple implementations
- Create and switch between Spring profiles
- Write unit tests with manual test doubles (fakes)
Prerequisites
- Java 17+ and Maven 3.8+ installed
- Completed Practical Work 1 (Maven basics)
- Understanding of interfaces and implementations
Project Overview
You will build a Notification Service that can send notifications via different channels (Email, SMS, Push). The service demonstrates DI patterns using Spring Core (without Spring Boot).
Project Structure
- notification-service/
- pom.xml
- src/main/java/com/example/notification/
- sender/
- NotificationSender.java
- EmailSender.java
- SmsSender.java
- PushSender.java
- repository/
- UserRepository.java
- InMemoryUserRepository.java
- service/
- NotificationService.java
- config/
- AppConfig.java
- model/
- User.java
- Application.java
- sender/
- src/main/resources/
- application.properties
- application-dev.properties
- application-prod.properties
- src/test/java/com/example/notification/
- NotificationServiceTest.java
- FakeUserRepository.java
- FakeNotificationSender.java
Instructions
Step 1: Create Project Structure
mkdir notification-service
cd notification-service
mkdir -p src/main/java/com/example/notification/{sender,repository,service,config,model}
mkdir -p src/main/resources
mkdir -p src/test/java/com/example/notification
Step 2: Create POM with Spring Dependencies
Create pom.xml:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>notification-service</artifactId>
<version>1.0.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<spring.version>6.1.4</spring.version> <!-- (#1:Spring Core version) -->
</properties>
<dependencies>
<dependency> <!-- (#2:Spring Context - core DI container) -->
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>${spring.version}</version>
</dependency>
<dependency> <!-- (#3:JUnit 5 for testing) -->
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
<dependency> <!-- (#4:Spring Test for context testing) -->
<groupId>org.springframework</groupId>
<artifactId>spring-test</artifactId>
<version>${spring.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
Step 3: Create the User Model
Create src/main/java/com/example/notification/model/User.java:
package com.example.notification.model;
public class User {
private final String id;
private final String name;
private final String email;
private final String phoneNumber;
public User(String id, String name, String email, String phoneNumber) {
this.id = id;
this.name = name;
this.email = email;
this.phoneNumber = phoneNumber;
}
// Getters
public String getId() { return id; }
public String getName() { return name; }
public String getEmail() { return email; }
public String getPhoneNumber() { return phoneNumber; }
}
Step 4: Create NotificationSender Interface
Create src/main/java/com/example/notification/sender/NotificationSender.java:
package com.example.notification.sender;
import com.example.notification.model.User;
public interface NotificationSender { // (#1:Program to interface)
void send(User user, String message);
String getType(); // (#2:Identify sender type)
}
Step 5: Create Sender Implementations
Create EmailSender.java:
package com.example.notification.sender;
import com.example.notification.model.User;
public class EmailSender implements NotificationSender {
@Override
public void send(User user, String message) {
System.out.printf("[EMAIL] To: %s <%s>%n", user.getName(), user.getEmail());
System.out.printf("[EMAIL] Message: %s%n", message);
}
@Override
public String getType() {
return "EMAIL";
}
}
Create SmsSender.java:
package com.example.notification.sender;
import com.example.notification.model.User;
public class SmsSender implements NotificationSender {
@Override
public void send(User user, String message) {
System.out.printf("[SMS] To: %s%n", user.getPhoneNumber());
System.out.printf("[SMS] Message: %s%n", message);
}
@Override
public String getType() {
return "SMS";
}
}
Create PushSender.java:
package com.example.notification.sender;
import com.example.notification.model.User;
public class PushSender implements NotificationSender {
@Override
public void send(User user, String message) {
System.out.printf("[PUSH] To device of: %s%n", user.getName());
System.out.printf("[PUSH] Message: %s%n", message);
}
@Override
public String getType() {
return "PUSH";
}
}
Step 6: Create UserRepository
Create src/main/java/com/example/notification/repository/UserRepository.java:
package com.example.notification.repository;
import com.example.notification.model.User;
import java.util.Optional;
public interface UserRepository {
Optional<User> findById(String id);
void save(User user);
}
Create InMemoryUserRepository.java:
package com.example.notification.repository;
import com.example.notification.model.User;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
public class InMemoryUserRepository implements UserRepository {
private final Map<String, User> users = new HashMap<>(); // (#1:In-memory storage)
@Override
public Optional<User> findById(String id) {
return Optional.ofNullable(users.get(id));
}
@Override
public void save(User user) {
users.put(user.getId(), user);
}
}
Step 7: Create NotificationService
Create src/main/java/com/example/notification/service/NotificationService.java:
package com.example.notification.service;
import com.example.notification.model.User;
import com.example.notification.repository.UserRepository;
import com.example.notification.sender.NotificationSender;
public class NotificationService {
private final UserRepository userRepository; // (#1:Dependencies declared as fields)
private final NotificationSender notificationSender;
// (#2:Constructor injection - dependencies provided at creation)
public NotificationService(UserRepository userRepository,
NotificationSender notificationSender) {
this.userRepository = userRepository;
this.notificationSender = notificationSender;
}
public void notifyUser(String userId, String message) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("User not found: " + userId));
notificationSender.send(user, message);
System.out.printf("Notification sent via %s%n", notificationSender.getType());
}
}
Step 8: Create Spring Configuration
Create src/main/java/com/example/notification/config/AppConfig.java:
package com.example.notification.config;
import com.example.notification.repository.InMemoryUserRepository;
import com.example.notification.repository.UserRepository;
import com.example.notification.sender.*;
import com.example.notification.service.NotificationService;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.*;
@Configuration // (#1:Marks class as configuration source)
@PropertySource("classpath:application.properties") // (#2:Load properties file)
public class AppConfig {
@Bean // (#3:Defines a bean managed by Spring)
public UserRepository userRepository() {
return new InMemoryUserRepository();
}
@Bean
@Primary // (#4:Default when multiple implementations exist)
public NotificationSender emailSender() {
return new EmailSender();
}
@Bean
@Qualifier("sms") // (#5:Named qualifier for specific injection)
public NotificationSender smsSender() {
return new SmsSender();
}
@Bean
@Qualifier("push")
public NotificationSender pushSender() {
return new PushSender();
}
@Bean // (#6:Service with injected dependencies)
public NotificationService notificationService(
UserRepository userRepository,
NotificationSender notificationSender) { // (#7:Injects @Primary by default)
return new NotificationService(userRepository, notificationSender);
}
@Bean("smsNotificationService") // (#8:Alternative service with SMS sender)
public NotificationService smsNotificationService(
UserRepository userRepository,
@Qualifier("sms") NotificationSender smsSender) {
return new NotificationService(userRepository, smsSender);
}
}
Step 9: Create Properties Files
Create src/main/resources/application.properties:
# Default configuration
app.name=Notification Service
app.environment=default
Create src/main/resources/application-dev.properties:
app.environment=development
notification.log.enabled=true
Create src/main/resources/application-prod.properties:
app.environment=production
notification.log.enabled=false
Step 10: Create Main Application
Create src/main/java/com/example/notification/Application.java:
package com.example.notification;
import com.example.notification.config.AppConfig;
import com.example.notification.model.User;
import com.example.notification.repository.UserRepository;
import com.example.notification.service.NotificationService;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class Application {
public static void main(String[] args) {
// (#1:Create Spring context from configuration class)
ApplicationContext context =
new AnnotationConfigApplicationContext(AppConfig.class);
// (#2:Get beans from container)
UserRepository userRepo = context.getBean(UserRepository.class);
NotificationService emailService = context.getBean(NotificationService.class);
NotificationService smsService = context.getBean("smsNotificationService",
NotificationService.class);
// (#3:Setup test data)
User user = new User("1", "John Doe", "john@example.com", "+1234567890");
userRepo.save(user);
// (#4:Send notifications via different channels)
System.out.println("=== Sending via Email (Primary) ===");
emailService.notifyUser("1", "Welcome to our platform!");
System.out.println("\n=== Sending via SMS ===");
smsService.notifyUser("1", "Your code is 123456");
}
}
Run the application:
mvn compile exec:java -Dexec.mainClass="com.example.notification.Application"
Expected Output:
=== Sending via Email (Primary) ===
[EMAIL] To: John Doe
[EMAIL] Message: Welcome to our platform!
Notification sent via EMAIL
=== Sending via SMS ===
[SMS] To: +1234567890
[SMS] Message: Your code is 123456
Notification sent via SMS
Step 11: Create Test Doubles (Fakes)
Create src/test/java/com/example/notification/FakeUserRepository.java:
package com.example.notification;
import com.example.notification.model.User;
import com.example.notification.repository.UserRepository;
import java.util.*;
public class FakeUserRepository implements UserRepository {
private final Map<String, User> users = new HashMap<>();
@Override
public Optional<User> findById(String id) {
return Optional.ofNullable(users.get(id));
}
@Override
public void save(User user) {
users.put(user.getId(), user);
}
// (#1:Test helper methods)
public void addUser(User user) {
users.put(user.getId(), user);
}
public void clear() {
users.clear();
}
}
Create src/test/java/com/example/notification/FakeNotificationSender.java:
package com.example.notification;
import com.example.notification.model.User;
import com.example.notification.sender.NotificationSender;
import java.util.*;
public class FakeNotificationSender implements NotificationSender {
private final List<SentNotification> sentNotifications = new ArrayList<>();
@Override
public void send(User user, String message) {
sentNotifications.add(new SentNotification(user, message)); // (#1:Record instead of send)
}
@Override
public String getType() {
return "FAKE";
}
// (#2:Test verification methods)
public List<SentNotification> getSentNotifications() {
return Collections.unmodifiableList(sentNotifications);
}
public boolean wasSentTo(String userId) {
return sentNotifications.stream()
.anyMatch(n -> n.user().getId().equals(userId));
}
public void clear() {
sentNotifications.clear();
}
// (#3:Record to capture sent notifications)
public record SentNotification(User user, String message) {}
}
Step 12: Write Unit Tests
Create src/test/java/com/example/notification/NotificationServiceTest.java:
package com.example.notification;
import com.example.notification.model.User;
import com.example.notification.service.NotificationService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class NotificationServiceTest {
private FakeUserRepository userRepository;
private FakeNotificationSender notificationSender;
private NotificationService service;
@BeforeEach
void setUp() {
// (#1:Create fakes - no Spring needed!)
userRepository = new FakeUserRepository();
notificationSender = new FakeNotificationSender();
service = new NotificationService(userRepository, notificationSender);
}
@Test
void shouldSendNotificationToExistingUser() {
// Arrange
User user = new User("1", "John", "john@test.com", "123");
userRepository.addUser(user);
// Act
service.notifyUser("1", "Hello!");
// Assert
assertTrue(notificationSender.wasSentTo("1"));
assertEquals(1, notificationSender.getSentNotifications().size());
}
@Test
void shouldIncludeMessageInNotification() {
// Arrange
User user = new User("1", "John", "john@test.com", "123");
userRepository.addUser(user);
// Act
service.notifyUser("1", "Welcome!");
// Assert
var notification = notificationSender.getSentNotifications().get(0);
assertEquals("Welcome!", notification.message());
assertEquals("John", notification.user().getName());
}
@Test
void shouldThrowExceptionForNonExistentUser() {
// Act & Assert
assertThrows(IllegalArgumentException.class,
() -> service.notifyUser("999", "Hello!"));
}
@Test
void shouldNotSendNotificationWhenUserNotFound() {
// Act
try {
service.notifyUser("999", "Hello!");
} catch (IllegalArgumentException ignored) {}
// Assert
assertTrue(notificationSender.getSentNotifications().isEmpty());
}
}
Run the tests:
mvn test
Expected Output:
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
Step 13: Add Profile-Based Configuration
Update AppConfig.java to add profile support:
// Add these additional beans to AppConfig.java
@Configuration
@Profile("dev") // (#1:Only active in dev profile)
class DevConfig {
@Bean
public NotificationSender devSender() {
return new NotificationSender() {
@Override
public void send(User user, String message) {
System.out.printf("[DEV] Would send to %s: %s%n", user.getEmail(), message);
}
@Override
public String getType() {
return "DEV_CONSOLE";
}
};
}
}
@Configuration
@Profile("prod")
class ProdConfig {
@Bean
public NotificationSender prodEmailSender() {
return new EmailSender(); // Real email sender in prod
}
}
Run with a specific profile:
# Run with dev profile
mvn compile exec:java \
-Dexec.mainClass="com.example.notification.Application" \
-Dspring.profiles.active=dev
Expected Output
- Working Spring DI application without Spring Boot
- 4 passing unit tests using manual test doubles
- Multiple NotificationSender implementations
- Profile-based configuration switching
Deliverables
- Source Code: Complete
notification-serviceproject - Screenshot: Successful test run output
- Screenshot: Application output showing both Email and SMS notifications
Bonus Challenges
- Challenge 1: Add @Value injection for sender configuration (from properties)
- Challenge 2: Create a BatchNotificationService that sends to multiple senders
- Challenge 3: Add @PostConstruct to pre-populate users in dev profile
- Challenge 4: Create a Spring Boot version of the same project and compare