← Back to Advanced Java
Practical Work 2

Dependency Injection with Spring

Build a notification service using Spring Core DI patterns

Duration 4 hours
Difficulty Intermediate
Session 2 - Dependency Injection

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
    • 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-service project
  • 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

Resources