Run a controlled failure and inspect the committed MongoDB documents instead of guessing what the annotation does.
Open this stage in the online learning path
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
<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>
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);
}
}
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"));
}
}
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); }
}
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");
}
}
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();
}
}
}
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(); }
}
}
}
spring.main.web-application-type=none
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.
| 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. |
Predict every final collection count before running. Then add a second successful todo creation and explain which transaction owns each insert.