Last Updated: May 31, 2026 at 10:00

Micronaut @Transactional Explained: A Spring Boot Developer's Guide

Learn how Micronaut handles @Transactional, propagation, rollback, and programmatic transactions — mapped directly to Spring Boot concepts you already know

Micronaut's transaction management will feel familiar if you've worked with Spring Boot, but the implementation differs meaningfully under the hood. It uses Jakarta's @Transactional and compile-time AOP weaving instead of runtime proxy generation, while preserving the same core concepts: propagation, rollback rules, and programmatic control. This article walks through declarative and programmatic transactions in Micronaut with direct Spring comparisons. By the end, you'll know exactly how to reason about transaction boundaries, failures, and edge cases in a Micronaut application.

Image

The mental model

Micronaut's transaction management is built on the same conceptual foundation as Spring's — declarative transactions via @Transactional, programmatic transactions via a transaction manager, rollback on unchecked exceptions, and propagation behaviours. The surface-level API is nearly identical. The differences are underneath.

The key architectural difference: compile-time AOP weaving vs runtime proxies.

In Spring, when you annotate a method with @Transactional, Spring generates a proxy at runtime (via CGLIB or JDK dynamic proxies) that wraps your bean. This happens when the application context starts, using reflection. In Micronaut, there are no runtime proxies. Instead, Micronaut generates proxy subclasses at compile time via AOP advice weaving. The interceptor code is produced during your build, not when the app boots. This is a core part of Micronaut's philosophy — ahead-of-time compilation for lower startup times and no reflection.

The practical consequence for you as a developer: the self-invocation problem (calling a @Transactional method from within the same bean) exists in both frameworks for the same fundamental reason. Method interception only applies when calls go through the generated proxy instance. An internal call bypasses that proxy entirely. More on this in section 5.

The other important difference is the annotation import. Micronaut uses the Jakarta EE standard:

import jakarta.transaction.Transactional; // ← Jakarta, not Spring

Not org.springframework.transaction.annotation.Transactional. This single import change catches most Spring developers on their first Micronaut service.

Everything else — propagation types, rollback semantics, read-only hints, programmatic control — transfers directly from what you know.

1. Setup

dependencies {
// For Micronaut Data JDBC
annotationProcessor("io.micronaut.data:micronaut-data-processor")
implementation("io.micronaut.data:micronaut-data-jdbc")
implementation("io.micronaut.sql:micronaut-jdbc-hikari")

// Transaction manager is included with micronaut-data-jdbc.
// For JPA, use micronaut-data-hibernate-jpa instead.

runtimeOnly("org.postgresql:postgresql")

// Test
testImplementation("io.micronaut.test:micronaut-test-junit5")
testImplementation("org.testcontainers:postgresql")
testImplementation("org.testcontainers:junit-jupiter")
}

You do not need a separate transaction manager dependency. micronaut-data-jdbc pulls in micronaut-transaction, which provides DataSourceTransactionManager. For JPA, Hibernate's Session/EntityManager is used as the transaction coordinator automatically — the wiring differs from JDBC, so if you're mixing the two or migrating, be explicit about which datasource is transactional.

2. Declarative transactions with @Transactional

Apply @Transactional to service methods that need transactional behaviour:

import jakarta.inject.Singleton;
import jakarta.transaction.Transactional;

@Singleton
public class OrderService {

private final OrderRepository orderRepository;
private final InventoryRepository inventoryRepository;
private final PaymentRepository paymentRepository;

public OrderService(OrderRepository orderRepository,
InventoryRepository inventoryRepository,
PaymentRepository paymentRepository) {
this.orderRepository = orderRepository;
this.inventoryRepository = inventoryRepository;
this.paymentRepository = paymentRepository;
}

@Transactional
public Order placeOrder(CreateOrderRequest request) {
// All three operations run in a single transaction.
// If any one throws, all three are rolled back.

Order order = orderRepository.save(
new Order(request.getCustomerId(), OrderStatus.PENDING));

inventoryRepository.reserve(request.getItems());

paymentRepository.authorise(
order.getId(), request.getPaymentDetails());

order.setStatus(OrderStatus.CONFIRMED);
return orderRepository.update(order);
}
}

Keep transactions at the service layer. Controllers are request-handling code; the transaction boundary belongs in the service where the business operation is defined.

Class-level @Transactional

Applying @Transactional to the class makes every public method transactional by default. Override individual methods to change their behaviour:

@Singleton
@Transactional // default for all methods
public class AccountService {

private final AccountRepository accountRepository;
private final AuditRepository auditRepository;

public Account findById(Long id) { // transactional (from class)
return accountRepository.findById(id).orElseThrow();
}

@Transactional(Transactional.TxType.REQUIRES_NEW) // overrides class default
public void auditAccess(Long accountId, String action) {
auditRepository.save(new AuditEntry(accountId, action));
}
}

Read-only transactions

Micronaut supports @Transactional(readOnly = true) just as Spring does. Use it for query-only methods to signal to the underlying driver and connection pool that no writes will occur — this can enable optimisations depending on your database:

@Transactional(readOnly = true)
public List findOrdersByCustomer(Long customerId) {
return orderRepository.findByCustomerId(customerId);
}

When NOT to use @Transactional

Three situations where adding @Transactional causes more harm than good:

  1. Pure read queries that don't need isolation guarantees. A single findById doesn't need a transaction. Wrapping every read adds overhead without benefit.
  2. Methods that make long-running external calls. A transaction holds a database connection. If your method calls a slow external API mid-transaction, you're holding that connection open for the duration. Move the external call outside the transaction boundary.
  3. Loops that process large datasets. Wrapping the entire loop in one transaction risks lock contention and memory pressure. Process in batches with a transaction per batch instead.

3. Transaction propagation

Propagation controls what happens when a @Transactional method is called from within another transaction. The types and their behaviour are identical to Spring's — only the syntax differs:

import jakarta.transaction.Transactional;

// REQUIRED (default) — join the existing transaction, or start a new one
@Transactional(Transactional.TxType.REQUIRED)
public void doWork() { ... }

// REQUIRES_NEW — always start a new transaction, suspending any existing one
@Transactional(Transactional.TxType.REQUIRES_NEW)
public void doIndependentWork() { ... }

// MANDATORY — must be called within an existing transaction; throws if none exists
@Transactional(Transactional.TxType.MANDATORY)
public void mustBeInTransaction() { ... }

// SUPPORTS — run in a transaction if one exists; run without one if not
@Transactional(Transactional.TxType.SUPPORTS)
public void optionalTransaction() { ... }

// NOT_SUPPORTED — always run without a transaction, suspending any existing one
@Transactional(Transactional.TxType.NOT_SUPPORTED)
public void nonTransactional() { ... }

// NEVER — must NOT be called within a transaction; throws if one exists
@Transactional(Transactional.TxType.NEVER)
public void neverInTransaction() { ... }

In Spring you write propagation = Propagation.REQUIRES_NEW. In Micronaut (Jakarta) you write Transactional.TxType.REQUIRES_NEW. The semantics are identical.

One note on nested transactions: REQUIRES_NEW gives you an independent transaction. True nested transactions with savepoints depend on your database's support, and Micronaut does not expose Spring's NESTED propagation type. If you relied on NESTED in Spring, REQUIRES_NEW is the closest equivalent, with the understanding that the inner transaction is fully independent rather than nested within the outer one.

A practical example: independent audit log

A common use of REQUIRES_NEW is audit logging — you want the audit entry to persist even if the outer transaction rolls back:

@Singleton
public class AuditService {

private final AuditRepository auditRepository;

public AuditService(AuditRepository auditRepository) {
this.auditRepository = auditRepository;
}

@Transactional(Transactional.TxType.REQUIRES_NEW)
public void log(String entityType, Long entityId, String action, String userId) {
// This runs in its own independent transaction.
// Even if the caller's transaction rolls back, this audit entry is committed.
auditRepository.save(new AuditEntry(entityType, entityId, action, userId, Instant.now()));
}
}


@Singleton
public class ProductService {

private final ProductRepository productRepository;
private final AuditService auditService;

@Transactional
public void deleteProduct(Long id, String userId) {
productRepository.deleteById(id); // in the outer transaction

auditService.log("Product", id, "DELETE", userId);
// ↑ runs in its own NEW transaction — committed regardless of what happens next

if (someConditionFails()) {
throw new RuntimeException("Rolling back product deletion");
// The product deletion is rolled back, but the audit entry is NOT.
}
}
}

4. Rollback behaviour

Default: rollback on unchecked exceptions only

By default, @Transactional rolls back for any RuntimeException or Error, and commits for checked exceptions. This is the same default as Spring.

@Transactional
public void processPayment(Long orderId) throws PaymentDeclinedException {
paymentRepository.charge(orderId);

if (paymentFailed()) {
// Checked exception — transaction COMMITS by default
// (unless rollbackOn is configured — see below)
throw new PaymentDeclinedException("Card declined");
}
}

Controlling rollback with rollbackOn and dontRollbackOn

// Roll back on a checked exception
@Transactional(rollbackOn = PaymentDeclinedException.class)
public void processPayment(Long orderId) throws PaymentDeclinedException {
paymentRepository.charge(orderId);

if (paymentFailed()) {
throw new PaymentDeclinedException("Card declined"); // now causes rollback
}
}

// Do NOT roll back on a specific runtime exception
@Transactional(dontRollbackOn = OptimisticLockException.class)
public void updateWithRetry(Product product) {
productRepository.update(product);
// OptimisticLockException won't roll back — caller handles the retry
}

The Spring equivalents are rollbackFor and noRollbackFor. Jakarta uses rollbackOn and dontRollbackOn. The semantics are identical — only the attribute names differ.

Programmatic rollback with TransactionStatus

For fine-grained control — marking a transaction for rollback without throwing an exception:

import io.micronaut.transaction.TransactionStatus;
import io.micronaut.transaction.SynchronousTransactionManager;
import java.sql.Connection;

@Singleton
public class BatchService {

private final SynchronousTransactionManager transactionManager;
private final OrderRepository orderRepository;

public BatchService(SynchronousTransactionManager transactionManager,
OrderRepository orderRepository) {
this.transactionManager = transactionManager;
this.orderRepository = orderRepository;
}

public BatchResult processBatch(List orderIds) {
return transactionManager.executeWrite(status -> {
int processed = 0;
int failed = 0;

for (Long orderId : orderIds) {
try {
orderRepository.process(orderId);
processed++;
} catch (Exception e) {
// Mark transaction for rollback without throwing
status.setRollbackOnly();
failed++;
break;
}
}

return new BatchResult(processed, failed);
});
}
}

Note: the TransactionStatus import is io.micronaut.transaction.TransactionStatus. The exact package can vary slightly across Micronaut versions, so check the docs for your version if you see a compilation error.

5. Programmatic transaction management

SynchronousTransactionManager is Micronaut's equivalent of Spring's TransactionTemplate — it lets you define transaction boundaries explicitly in code rather than relying on the @Transactional annotation. You call executeWrite when you need a read-write transaction and executeRead for read-only, passing a lambda that contains the work to be done. This is useful when you need conditional transaction logic, or when the method isn't easily annotated (e.g. it's called from a context where the interceptor won't fire).

For cases where you need full control over transaction boundaries in code, SynchronousTransactionManager replaces Spring's TransactionTemplate:

import io.micronaut.transaction.SynchronousTransactionManager;

@Singleton
public class MigrationService {

private final SynchronousTransactionManager txManager;
private final ProductRepository productRepository;
private final LegacyRepository legacyRepository;

public MigrationService(SynchronousTransactionManager txManager,
ProductRepository productRepository,
LegacyRepository legacyRepository) {
this.txManager = txManager;
this.productRepository = productRepository;
this.legacyRepository = legacyRepository;
}

public MigrationResult migrateProducts() {
// executeWrite: read-write transaction, returns a result
return txManager.executeWrite(status -> {
List legacyProducts = legacyRepository.findAll();

int migrated = 0;
for (LegacyProduct lp : legacyProducts) {
Product product = toProduct(lp);
productRepository.save(product);
migrated++;
}

return new MigrationResult(migrated);
});
}

public List readProductsInTransaction() {
// executeRead: read-only transaction
return txManager.executeRead(status ->
(List) productRepository.findAll());
}
}

Spring's TransactionTemplate.execute(status -> { ... }) maps directly to SynchronousTransactionManager.executeWrite(status -> { ... }). Micronaut adds a separate executeRead convenience method for read-only transactions, which you'd have to configure manually in Spring.

6. The self-invocation problem

This is the most common transaction bug, and it applies equally to Spring and Micronaut.

The problem

@Singleton
public class ReportService {

@Transactional
public void generateAll() {
generateSummary(); // ← PROBLEM: calls within the same bean
generateDetailed(); // bypass the transaction interceptor
}

@Transactional(Transactional.TxType.REQUIRES_NEW)
public void generateSummary() {
// This will NOT run in a new transaction when called from generateAll()
// because the call is direct — it doesn't go through the proxy instance
}

@Transactional(Transactional.TxType.REQUIRES_NEW)
public void generateDetailed() {
// Same problem
}
}

Method interception only applies when a call goes through the generated proxy instance. When generateAll() calls generateSummary() directly, it's calling this.generateSummary() — the proxy is bypassed entirely. Both methods end up running inside generateAll()'s transaction, ignoring their own @Transactional(REQUIRES_NEW) declaration.

This is identical to the Spring proxy limitation, just with compile-time interception instead of a runtime CGLIB proxy.

The fix: extract to a separate bean

@Singleton
public class ReportOrchestrator {

private final ReportGeneratorService reportGeneratorService;

public ReportOrchestrator(ReportGeneratorService reportGeneratorService) {
this.reportGeneratorService = reportGeneratorService;
}

public void generateAll() {
// Calls go through the DI container — @Transactional is honoured
reportGeneratorService.generateSummary();
reportGeneratorService.generateDetailed();
}
}

@Singleton
public class ReportGeneratorService {

@Transactional(Transactional.TxType.REQUIRES_NEW)
public void generateSummary() { ... }

@Transactional(Transactional.TxType.REQUIRES_NEW)
public void generateDetailed() { ... }
}

When ReportOrchestrator calls into ReportGeneratorService, the call goes through the DI container's proxy and the transaction interceptor fires correctly.

7. Spring → Micronaut quick reference

Annotation import Spring: org.springframework.transaction.annotation.Transactional Micronaut: jakarta.transaction.Transactional

Propagation attribute Spring: propagation = Propagation.REQUIRES_NEW Micronaut: value = Transactional.TxType.REQUIRES_NEW

Rollback on checked exception Spring: rollbackFor = SomeException.class Micronaut: rollbackOn = SomeException.class

Suppress rollback on runtime exception Spring: noRollbackFor = SomeException.class Micronaut: dontRollbackOn = SomeException.class

Read-only transaction Spring: @Transactional(readOnly = true) Micronaut: @Transactional(readOnly = true) — same syntax

Programmatic transactions Spring: TransactionTemplate.execute(status -> {...}) Micronaut: SynchronousTransactionManager.executeWrite(status -> {...}) / executeRead(...)

Mark rollback only Spring: status.setRollbackOnly() Micronaut: status.setRollbackOnly() — identical

Proxy mechanism Spring: Runtime CGLIB / JDK proxy generated at startup Micronaut: Compile-time generated proxy subclass, no runtime reflection

Self-invocation behaviour Same bug in both — bypasses the proxy. Fix by extracting to a separate @Singleton.

Test setup Spring: @SpringBootTest + H2 / Testcontainers Micronaut: @MicronautTest + Testcontainers

What to explore next

Reactive transactions — @TransactionalAdvice and non-blocking transaction management with R2DBC and Project Reactor work differently from the blocking JDBC model covered here. If you're building reactive Micronaut services, the transaction handling is a separate topic worth dedicated coverage.

Distributed transactions — the saga pattern for transactions spanning multiple microservices and Kafka topics.

Optimistic locking — using @Version on entities to detect concurrent modifications without pessimistic database locks.

Connection pool tuning — HikariCP settings (maximum-pool-size, connection-timeout) that directly affect transaction throughput under load.

N

About N Sharma

Lead Architect at StackAndSystem

N Sharma is a technologist with over 28 years of experience in software engineering, system architecture, and technology consulting. He holds a Bachelor’s degree in Engineering, a DBF, and an MBA. His work focuses on research-driven technology education—explaining software architecture, system design, and development practices through structured tutorials designed to help engineers build reliable, scalable systems.

Disclaimer

This article is for educational purposes only. Assistance from AI-powered generative tools was taken to format and improve language flow. While we strive for accuracy, this content may contain errors or omissions and should be independently verified.

Micronaut @Transactional Explained for Spring Boot Developers