Stage 34: save a todo and activity atomically
Learning path / Stage 34 of 37
Run a controlled failure and inspect the committed MongoDB documents instead of guessing what the annotation does.
Download the complete Stage 34 project · Open the project guide
Before you start
Use JDK 21 or newer and Maven 3.9 or newer. Verify with java -version and mvn -version. Extract this project into its own folder; do not merge its files with another stage.
This lesson reuses the product catalog and Todo responsibilities from Stages 8–14. Only the feature described below is new. Read the ordered explanation before opening the complete files.
What we change and why
Use a concrete consistency requirement
Creating a todo and its activity record is one business operation. A partial result is confusing: the todo exists but the activity record does not. An individual MongoDB document write is already atomic; this example needs a transaction because multiple documents must agree.
Configure the transaction infrastructure
AppConfig creates the client, MongoTemplate, database factory and MongoTransactionManager. @EnableTransactionManagement enables annotation-driven transaction proxies. All template operations use the same factory so they participate in the transaction-bound session.
Prepare collections before the transaction
LearningData creates lesson-owned collections before the service is called. It clears those collections only in the chosen disposable learning database, outside transactional methods. Never point this teaching project at a database containing records you want to keep.
Call through an injected service
Lesson receives TodoService from Spring. The transaction interceptor starts the transaction before entering an annotated method, then commits or rolls back after the method exits. This is a service proxy, not a proxy created for the database itself.
Compare the three outcomes
The unannotated operation leaves the first insert behind on failure. The annotated failure rolls it back. The successful annotated operation commits both inserts. The test verifies the actual MongoDB collections, not a mocked repository.
Revisit self-invocation
createInternally calls this.create. Because that call does not pass through the proxy, it does not start the annotated transaction. Its failure leaves one todo. A helper call inside an already active transaction can still participate; self-invocation means its own annotation is not newly applied. Move a separately transactional operation into another injected service when needed.
MongoDB connection before running
Use a disposable learning database on an Atlas replica-set cluster. Follow the existing Atlas setup chapter to create a database user and allow your IP. Use a fresh database name for this lesson. The code inserts only demonstration records, clears only its lesson-owned collections, and prints the final counts.
Set LESSON_MONGODB_URI in the same terminal where Maven will run. Replace the example credentials and cluster host; percent-encode special characters in the password. Keep the URI out of source control.
set "LESSON_MONGODB_URI=mongodb+srv://USERNAME:PASSWORD@YOUR-CLUSTER.mongodb.net/spring_stage_34?retryWrites=true&w=majority"$env:LESSON_MONGODB_URI="mongodb+srv://USERNAME:PASSWORD@YOUR-CLUSTER.mongodb.net/spring_stage_34?retryWrites=true&w=majority"export LESSON_MONGODB_URI="mongodb+srv://USERNAME:PASSWORD@YOUR-CLUSTER.mongodb.net/spring_stage_34?retryWrites=true&w=majority"A standalone local mongod does not support these multi-document transactions. Use Atlas or an initialized local replica set. The database name in the URI is required by this example. The project explicitly registers MongoTransactionManager; the annotation alone does not enable MongoDB transactions. Collections are created before starting a transaction.
Run this example
Open a terminal in the extracted folder containing pom.xml. Run one command at a time.
mvn test -DmongoIntegration=true
mvn spring-boot:runThis is a console lesson. It does not open a web port. Application.main starts a Spring context, calls the demonstration and closes the context. Read the terminal, not a browser. Spring Boot’s Maven plugin is used as a convenient launcher; these examples show Spring Framework directly.
Expected result
Without transaction: todos=1, activity=0, audit=0
Transactional failure: todos=0, activity=0, audit=0
Transactional success: todos=1, activity=1, audit=0
Self-invocation failure: todos=1, activity=0, audit=0
Intentional failure messages also appear between these summaries.Object identifiers and elapsed times can vary. Compare the relationship between values rather than copying an identifier.
Understand each complete file
Create the following files at exactly these paths, or use the ZIP. Source files include their imports. The test checks the behaviour described in the lesson.
pom.xml
<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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>4.1.1</version><relativePath/></parent>
<groupId>com.edupoly</groupId><artifactId>spring-stage-34-mongo-transaction</artifactId><version>1.0.0</version>
<properties><java.version>21</java.version></properties>
<dependencies>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-mongodb</artifactId></dependency>
</dependencies>
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
</project>
src/main/java/com/edupoly/lesson/AppConfig.java
package com.edupoly.lesson;
import com.mongodb.ConnectionString;
import com.mongodb.client.MongoClient;
import com.mongodb.client.MongoClients;
import org.springframework.context.annotation.*;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.MongoTransactionManager;
import org.springframework.data.mongodb.MongoDatabaseFactory;
import org.springframework.transaction.annotation.EnableTransactionManagement;
@Configuration @ComponentScan("com.edupoly.lesson") @EnableTransactionManagement
public class AppConfig {
private ConnectionString connection() {
String uri = System.getenv("LESSON_MONGODB_URI");
if (uri == null || uri.isBlank()) throw new IllegalStateException("Set LESSON_MONGODB_URI to a disposable replica-set database URI first");
var connection = new ConnectionString(uri);
if (connection.getDatabase() == null) throw new IllegalStateException("Include a learning database name in LESSON_MONGODB_URI");
return connection;
}
@Bean(destroyMethod = "close") public MongoClient mongoClient() { return MongoClients.create(connection()); }
@Bean public MongoTemplate mongoTemplate(MongoClient client) { return new MongoTemplate(client, connection().getDatabase()); }
@Bean public MongoDatabaseFactory databaseFactory(MongoTemplate template) { return template.getMongoDatabaseFactory(); }
@Bean public MongoTransactionManager transactionManager(MongoDatabaseFactory databaseFactory) {
return new MongoTransactionManager(databaseFactory);
}
}
src/main/java/com/edupoly/lesson/LearningData.java
package com.edupoly.lesson;
import org.springframework.stereotype.Component;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Query;
@Component public class LearningData {
private final MongoTemplate template;
public LearningData(MongoTemplate template) { this.template = template; }
public void prepare() {
for (String collection : new String[]{"lesson_todos", "lesson_activity", "lesson_audit"}) {
if (!template.collectionExists(collection)) template.createCollection(collection);
}
}
public void clear() {
for (String collection : new String[]{"lesson_todos", "lesson_activity", "lesson_audit"}) {
template.remove(new Query(), collection);
}
}
public long count(String collection) { return template.count(new Query(), collection); }
public void print(String label) {
System.out.println(label + ": todos=" + count("lesson_todos") + ", activity=" + count("lesson_activity") + ", audit=" + count("lesson_audit"));
}
}
src/main/java/com/edupoly/lesson/TodoService.java
package com.edupoly.lesson;
import org.bson.Document;
import org.springframework.stereotype.Service;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.transaction.annotation.Transactional;
@Service public class TodoService {
private final MongoTemplate template;
public TodoService(MongoTemplate template) { this.template = template; }
private void write(boolean fail) {
template.insert(new Document("title", "Learn transactions"), "lesson_todos");
if (fail) throw new IllegalStateException("Intentional failure before activity save");
template.insert(new Document("action", "Todo created"), "lesson_activity");
}
public void createWithoutTransaction() { write(true); }
@Transactional public void create(boolean fail) { write(fail); }
public void createInternally() { this.create(true); }
}
src/main/java/com/edupoly/lesson/Lesson.java
package com.edupoly.lesson;
import org.springframework.stereotype.Component;
@Component public class Lesson {
private final TodoService service; private final LearningData data;
public Lesson(TodoService service, LearningData data) { this.service = service; this.data = data; }
public void run() throws Exception {
data.prepare();
data.clear();
try { service.createWithoutTransaction(); } catch (IllegalStateException ex) { System.out.println(ex.getMessage()); }
data.print("Without transaction"); data.clear();
try { service.create(true); } catch (IllegalStateException ex) { System.out.println(ex.getMessage()); }
data.print("Transactional failure"); data.clear();
service.create(false); data.print("Transactional success"); data.clear();
try { service.createInternally(); } catch (IllegalStateException ex) { System.out.println(ex.getMessage()); }
data.print("Self-invocation failure");
}
}
src/main/java/com/edupoly/lesson/Application.java
package com.edupoly.lesson;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class Application {
public static void main(String[] args) throws Exception {
try (var context = new AnnotationConfigApplicationContext(AppConfig.class)) {
context.getBean(Lesson.class).run();
}
}
}
src/test/java/com/edupoly/lesson/LessonTest.java
package com.edupoly.lesson;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
class LessonTest {
@Test
@org.junit.jupiter.api.condition.EnabledIfSystemProperty(named = "mongoIntegration", matches = "true")
void actualMongoTransactionsHaveExpectedOutcome() {
try (var context = new AnnotationConfigApplicationContext(AppConfig.class)) {
var data = context.getBean(LearningData.class); var service = context.getBean(TodoService.class);
data.prepare(); data.clear();
try {
assertThrows(IllegalStateException.class, () -> service.createWithoutTransaction());
assertEquals(1, data.count("lesson_todos")); data.clear();
assertThrows(IllegalStateException.class, () -> service.create(true));
assertEquals(0, data.count("lesson_todos")); assertEquals(0, data.count("lesson_activity"));
service.create(false); assertEquals(1, data.count("lesson_todos")); assertEquals(1, data.count("lesson_activity")); data.clear();
assertThrows(IllegalStateException.class, () -> service.createInternally()); assertEquals(1, data.count("lesson_todos"));
} finally { data.clear(); }
}
}
}
src/main/resources/application.properties
spring.main.web-application-type=none
Try a failure, then correct it
Use a standalone MongoDB server and observe the transaction-not-supported error. Correct the database deployment; removing @Transactional changes the consistency guarantee and does not fix the requirement.
Words you can now explain
| Word | Meaning in this example |
|---|---|
| Transaction | A boundary that commits related writes together or rolls them back. |
| Transaction manager | Coordinates the MongoDB session used by Spring’s transaction interceptor. |
Practice assignment
Predict every final collection count before running. Then add a second successful todo creation and explain which transaction owns each insert.
Concepts used in this stage
Use the right column for a quick reminder, or open a concept above for its full explanation.