STAGE 34 OF 37

Stage 34: save a todo and activity atomically

Learning path / Stage 34 of 37

Before you start: use JDK 21+ and Maven 3.9+. Extract this stage’s project and open a terminal in its pom.xml folder. Complete the setup below, including a database connection where required, then follow the commands in this lesson’s Run this example section. Console lessons finish after printing their result; web lessons keep running until Ctrl+C. Follow the lesson’s run section for profile and database settings.

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.

Lesson → Transactional service proxy → Todo insert → Activity / audit insert → Commit or rollback → Inspect collections

What we change and why

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

Windows Command Prompt
set "LESSON_MONGODB_URI=mongodb+srv://USERNAME:PASSWORD@YOUR-CLUSTER.mongodb.net/spring_stage_34?retryWrites=true&w=majority"
Windows PowerShell
$env:LESSON_MONGODB_URI="mongodb+srv://USERNAME:PASSWORD@YOUR-CLUSTER.mongodb.net/spring_stage_34?retryWrites=true&w=majority"
macOS / Linux
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.

Terminal · Windows, macOS and Linux
mvn test -DmongoIntegration=true
mvn spring-boot:run

This 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

Expected output · ignore framework startup messages
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
pom.xml · complete file
<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
src/main/java/com/edupoly/lesson/AppConfig.java · complete file
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
src/main/java/com/edupoly/lesson/LearningData.java · complete file
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
src/main/java/com/edupoly/lesson/TodoService.java · complete file
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
src/main/java/com/edupoly/lesson/Lesson.java · complete file
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
src/main/java/com/edupoly/lesson/Application.java · complete file
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
src/test/java/com/edupoly/lesson/LessonTest.java · complete file
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
src/main/resources/application.properties · complete file
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

WordMeaning in this example
TransactionA boundary that commits related writes together or rolls them back.
Transaction managerCoordinates 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.

Read the official documentation →

Concepts used in this stage

Use the right column for a quick reminder, or open a concept above for its full explanation.

Java 21 · Spring Boot 4.1.1 · Edupoly