TP5.2 - Beans and Scopes
Objectives
By the end of this lab, you will be able to:
- ✅ Create Beans with @Bean and @Configuration
- ✅ Understand the difference between Singleton and Prototype
- ✅ Use @PostConstruct and @PreDestroy
- ✅ 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 defaultcountershared 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
reportIdanddata
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
| Concept | Syntax | Usage |
|---|---|---|
| Singleton | @Service (default) | Stateless services, 1 instance |
| Prototype | @Scope("prototype") | Stateful objects, N instances |
| @PostConstruct | Annotated method | Initialization after injection |
| @PreDestroy | Annotated method | Cleanup before destruction |
| @Bean | In @Configuration | External classes, complex config |
| Conditional Bean | @ConditionalOnProperty | Beans 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
- What is the difference between Singleton and Prototype?
- When are @PostConstruct and @PreDestroy called?
- Why use @Bean instead of @Service?
- How to create a conditional Bean?
Bonus Challenge 🎯
Create a cache system with TTL (Time To Live):
@Service @Scope("singleton")CacheManager- Method
put(key, value, ttlSeconds) - Method
get(key)that returns null if expired @PostConstructfor initialization@PreDestroyfor 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