Skip to main content

TP5.2 - Beans and Scopes

Objectives​

By the end of this lab, you will be able to:

  1. ✅ Create Beans with @Bean and @Configuration
  2. ✅ Understand the difference between Singleton and Prototype
  3. ✅ Use @PostConstruct and @PreDestroy
  4. ✅ Create conditional Beans

Estimated duration: 1h30


Prerequisites​

  • ✅ Chapter 3 of Module 5 completed
  • ✅ TP5.1 completed
  • ✅ Maven 3.9+ and Java 17+

Preparation: New Project​

Create the Project​

Via Spring Initializr (https://start.spring.io/):

  • Project: Maven
  • Spring Boot: 3.2.0
  • Group: com.example
  • Artifact: beans-demo
  • Dependencies: Spring Web

Structure:

src/main/java/com/example/beans/
├── BeansDemoApplication.java
├── service/
│ ├── CounterService.java
│ ├── ReportGenerator.java
│ └── CacheService.java
├── config/
│ └── AppConfig.java
└── controller/
└── TestController.java

Exercise 1: Singleton vs Prototype​

Objective​

Understand the difference between a Singleton Bean (1 instance) and Prototype (multiple instances).

Step 1.1: Create CounterService (Singleton by default)​

File: service/CounterService.java

package com.example.beans.service;

import org.springframework.stereotype.Service;

@Service // Singleton by default
public class CounterService {

private int counter = 0;

public void increment() {
counter++;
System.out.println("Counter incremented: " + counter);
}

public int getCounter() {
return counter;
}
}

Key Points:

  • @Service → Singleton by default
  • counter shared across all uses

Step 1.2: Test the Singleton​

File: controller/TestController.java

package com.example.beans.controller;

import com.example.beans.service.CounterService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/test")
public class TestController {

private final CounterService counterService;

public TestController(CounterService counterService) {
this.counterService = counterService;
}

@GetMapping("/singleton")
public String testSingleton() {
counterService.increment();
return "Counter: " + counterService.getCounter() +
" (Singleton - same instance)";
}
}

Step 1.3: Test​

Start:

mvn clean install -DskipTests spring-boot:run

Test multiple times:

http://localhost:8080/api/test/singleton

Result:

1st request: Counter: 1
2nd request: Counter: 2
3rd request: Counter: 3
...

✅ It's the SAME instance! The counter keeps increasing.


Step 1.4: Create ReportGenerator (Prototype)​

File: service/ReportGenerator.java

package com.example.beans.service;

import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

@Service
@Scope("prototype") // ← New instance each time!
public class ReportGenerator {

private String reportId;
private List<String> data = new ArrayList<>();
private LocalDateTime createdAt;

public ReportGenerator() {
this.reportId = "REPORT-" + System.currentTimeMillis();
this.createdAt = LocalDateTime.now();
System.out.println("New ReportGenerator instance created: " + reportId);
}

public void addData(String item) {
data.add(item);
}

public String getReport() {
return String.format("Report %s created at %s with %d items",
reportId, createdAt, data.size());
}
}

Key Points:

  • @Scope("prototype") → New instance at each injection
  • Each instance has its own reportId and data

Step 1.5: Test the Prototype​

Add in TestController.java:

@RestController
@RequestMapping("/api/test")
public class TestController {

private final CounterService counterService;
private final ApplicationContext context; // ← To retrieve Prototypes

public TestController(CounterService counterService,
ApplicationContext context) {
this.counterService = counterService;
this.context = context;
}

// ... singleton method ...

@GetMapping("/prototype")
public String testPrototype() {
// Retrieve 2 instances
ReportGenerator report1 = context.getBean(ReportGenerator.class);
ReportGenerator report2 = context.getBean(ReportGenerator.class);

// Modify report1
report1.addData("Item 1");
report1.addData("Item 2");

// Check both
return "Report1: " + report1.getReport() + "<br>" +
"Report2: " + report2.getReport() + "<br>" +
"(These are DIFFERENT instances)";
}
}

Test:

http://localhost:8080/api/test/prototype

Result:

Report1: Report REPORT-1234567890 created at 2025-11-28T10:00:00 with 2 items
Report2: Report REPORT-1234567891 created at 2025-11-28T10:00:01 with 0 items

✅ DIFFERENT instances! Each has its own state.


Exercise 2: Lifecycle with @PostConstruct and @PreDestroy​

Objective​

Execute code after injection and before destruction.

Step 2.1: Create CacheService with Lifecycle Hooks​

File: service/CacheService.java

package com.example.beans.service;

import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import org.springframework.stereotype.Service;

import java.util.HashMap;
import java.util.Map;

@Service
public class CacheService {

private Map<String, String> cache;

// Constructor
public CacheService() {
System.out.println("1. CacheService constructor called");
// ⚠️ Do not initialize here if it depends on other beans!
}

// Called AFTER all dependencies are injected
@PostConstruct
public void init() {
System.out.println("2. @PostConstruct - Initializing cache");
cache = new HashMap<>();
cache.put("key1", "value1");
cache.put("key2", "value2");
System.out.println(" Cache initialized with " + cache.size() + " items");
}

// Called BEFORE the bean is destroyed
@PreDestroy
public void cleanup() {
System.out.println("3. @PreDestroy - Cleaning up cache");
if (cache != null) {
System.out.println(" Saving " + cache.size() + " items");
cache.clear();
}
System.out.println(" Cache cleaned");
}

public void put(String key, String value) {
cache.put(key, value);
System.out.println("Added to cache: " + key + " = " + value);
}

public String get(String key) {
return cache.get(key);
}

public int size() {
return cache.size();
}
}

Step 2.2: Test​

Add in TestController.java:

private final CacheService cacheService;

@GetMapping("/lifecycle")
public String testLifecycle() {
cacheService.put("test", "value");
return "Cache size: " + cacheService.size() + " - See console for lifecycle";
}

Start the application:

Console on startup:

1. CacheService constructor called
2. @PostConstruct - Initializing cache
Cache initialized with 2 items

Test:

http://localhost:8080/api/test/lifecycle

Console:

Added to cache: test = value

Stop the application (Ctrl+C):

Console on shutdown:

3. @PreDestroy - Cleaning up cache
Saving 3 items
Cache cleaned

✅ Full lifecycle tested!


Exercise 3: Create Beans with @Bean and @Configuration​

Objective​

Create Beans for classes you do not control (external libraries).

Step 3.1: Create the Configuration​

File: config/AppConfig.java

package com.example.beans.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.time.format.DateTimeFormatter;
import java.util.Random;

@Configuration
public class AppConfig {

// Bean 1: DateTimeFormatter (standard Java class)
@Bean
public DateTimeFormatter dateFormatter() {
System.out.println("Creating Bean dateFormatter");
return DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm:ss");
}

// Bean 2: Random (standard Java class)
@Bean
public Random randomGenerator() {
System.out.println("Creating Bean randomGenerator");
return new Random();
}

// Bean 3: Custom Bean with dependency
@Bean
public ReportFormatter reportFormatter(DateTimeFormatter dateFormatter) {
System.out.println("Creating Bean reportFormatter");
// Spring automatically injects dateFormatter!
return new ReportFormatter(dateFormatter);
}
}

Step 3.2: Create ReportFormatter​

File: service/ReportFormatter.java

package com.example.beans.service;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

public class ReportFormatter {

private final DateTimeFormatter dateFormatter;

public ReportFormatter(DateTimeFormatter dateFormatter) {
this.dateFormatter = dateFormatter;
}

public String formatReport(String title, String content) {
String timestamp = LocalDateTime.now().format(dateFormatter);
return String.format("=== %s ===\nDate: %s\n%s",
title, timestamp, content);
}
}

Step 3.3: Test​

Add in TestController.java:

private final Random random;
private final ReportFormatter reportFormatter;

@GetMapping("/config-beans")
public String testConfigBeans() {
int randomNumber = random.nextInt(100);
String report = reportFormatter.formatReport(
"Test Report",
"Random number: " + randomNumber
);
return report.replace("\n", "<br>");
}

Test:

http://localhost:8080/api/test/config-beans

Result:

=== Test Report ===
Date: 28/11/2025 10:30:45
Random number: 42

✅ Beans created via @Configuration work!


Exercise 4: Conditional Beans (Bonus)​

Objective​

Create Beans only under certain conditions.

Step 4.1: Simple Conditional Bean​

Add in AppConfig.java:

import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;

@Configuration
public class AppConfig {

// ... other beans ...

// Bean created only if the property is set
@Bean
@ConditionalOnProperty(name = "app.feature.advanced", havingValue = "true")
public String advancedFeature() {
return "Advanced Feature Enabled!";
}
}

Step 4.2: Enable the Feature​

File: src/main/resources/application.properties

app.feature.advanced=true

Step 4.3: Test​

Add in TestController.java:

import org.springframework.beans.factory.annotation.Autowired;

@RestController
@RequestMapping("/api/test")
public class TestController {

@Autowired(required = false) // ← Optional!
private String advancedFeature;

@GetMapping("/conditional")
public String testConditional() {
if (advancedFeature != null) {
return "✅ " + advancedFeature;
} else {
return "❌ Advanced feature disabled";
}
}
}

Test with app.feature.advanced=true:

✅ Advanced Feature Enabled!

Test with app.feature.advanced=false:

❌ Advanced feature disabled

Summary​

What You Have Learned​

ConceptSyntaxUsage
Singleton@Service (default)Stateless services, 1 instance
Prototype@Scope("prototype")Stateful objects, N instances
@PostConstructAnnotated methodInitialization after injection
@PreDestroyAnnotated methodCleanup before destruction
@BeanIn @ConfigurationExternal classes, complex config
Conditional Bean@ConditionalOnPropertyBeans based on configuration

Singleton vs Prototype Comparison​

// Singleton (default)
@Service
public class SingletonService {
private int state = 0; // ⚠️ Shared!
}

// Prototype
@Service
@Scope("prototype")
public class PrototypeService {
private int state = 0; // ✅ Unique to each instance
}

When to Use What?​

Singleton ✅ (default):

  • Stateless services
  • Repositories
  • Controllers
  • 99% of cases!

Prototype ⚠️ (rare):

  • Objects with mutable state
  • Temporary objects
  • Reports, file processors

Self-Assessment​

  1. What is the difference between Singleton and Prototype?
  2. When are @PostConstruct and @PreDestroy called?
  3. Why use @Bean instead of @Service?
  4. How to create a conditional Bean?

Bonus Challenge 🎯​

Create a cache system with TTL (Time To Live):

  1. @Service @Scope("singleton") CacheManager
  2. Method put(key, value, ttlSeconds)
  3. Method get(key) that returns null if expired
  4. @PostConstruct for initialization
  5. @PreDestroy for saving the cache

Bonus: Use @Scheduled to automatically clean up expired entries.


Next Step​

Proceed to TP3 to learn about configuration by profiles!

➡️ TP5.3: Configuration by Profiles

⬅️ Previous TP: Advanced Injection

⬅️ Back to Module 5