Oracle Sessionless Transactions with Micronaut Data and Kafka
Learn how to use Oracle sessionless transactions with Micronaut Data and Kafka.
On this guide
In this section
Getting Started
In this guide, we will create a Micronaut application written in Java.
In this guide you will build a flight-booking workflow with an HTTP controller and two Kafka consumers. The controller reserves a seat in Oracle Database, suspends its transaction, and publishes a payment request. The first consumer processes a simulated payment. The second resumes the original Oracle Database transaction and confirms the booking, or rolls back the reservation if payment is declined.
The controller and consumers exchange the encoded global transaction identifier (GTRID), rather than a JDBC connection. The payment consumer never resumes the transaction and does not need to read the uncommitted booking.
What you will need
To complete this guide, you will need the following:
-
Some time on your hands
-
A decent text editor or IDE (e.g. IntelliJ IDEA)
-
JDK 21 or greater installed with
JAVA_HOMEconfigured appropriately -
Docker, for Oracle Database Free and Kafka through Micronaut Test Resources.
-
Micronaut Data 5.2.0 or later.
-
Oracle AI Database 26ai (version 23.6 or later) and a recent Oracle JDBC Thin driver exposing
startTransaction,suspendTransactionImmediately, andresumeTransaction.
Read Developing Applications with Sessionless Transactions in the Oracle AI Database Development Guide for how the feature works, and Sessionless Transactions in the Oracle AI Database JDBC Developer’s Guide for database and driver requirements.
Solution
We recommend that you follow the instructions in the next sections and create the application step by step. However, you can go right to the completed example.
-
Download and unzip the source
The Booking Flow
The following diagram illustrates the booking flow:
-
Reserve the seat —
BookingControllerReceives
POST /bookings, reserves the seat, and saves a pending booking. It suspends the Oracle Database transaction, and then publishes aPaymentRequestedmessage containing the booking ID and GTRID to thepayment-requestsKafka topic. -
Process payment —
PaymentConsumerConsumes messages from the
payment-requestsKafka topic and processes payment while the reservation transaction remains suspended. It publishes aPaymentCompletedmessage containing the payment outcome, booking ID, and the same GTRID to thepayment-resultsKafka topic. -
Complete the booking —
CompletionConsumerConsumes messages from the
payment-resultsKafka topic and resumes the original Oracle Database transaction using the GTRID. If payment is approved, it confirms the booking and commits. If payment is declined, it rolls back the reservation.
The sample runs the controller and both consumers in one application. The consumers have separate consumer groups. They can be deployed in separate applications using the same event contracts. Reservation and completion must connect to the same Oracle Database instance and use the same application database user. No connection is held by the application while payment runs, but the suspended transaction continues to hold its database locks.
|
Important
|
This is an Oracle Database transaction spanning workflow steps. Kafka publishing, Kafka offset commits, and the external payment are separate operations. The example demonstrates propagation and completion; it does not provide atomic delivery or exactly-once booking across these systems. |
Writing the Application
Create an application using the Micronaut Command Line Interface or with Micronaut Launch.
mn create-app example.micronaut.micronautguide \
--features=data-jdbc,jdbc-hikari,oracle,liquibase,kafka,serialization-jackson,validation \
--build=maven \
--lang=java \
--test=junit|
Note
|
If you don’t specify the --build argument, Gradle with the Kotlin DSL is used as the build tool. If you don’t specify the --lang argument, Java is used as the language.If you don’t specify the --test argument, JUnit is used for Java and Kotlin, and Spock is used for Groovy.
|
The previous command creates a Micronaut application with the default package example.micronaut in a directory named micronautguide.
If you use Micronaut Launch, select Micronaut Application as application type and add data-jdbc, jdbc-hikari, oracle, liquibase, kafka, serialization-jackson, and validation features.
|
Note
|
If you have an existing Micronaut application and want to add the functionality described here, you can view the dependency and configuration changes from the specified features, and apply those changes to your application. |
Oracle Driver
Add also the Oracle Driver
<dependency>
<groupId>com.oracle.database.jdbc</groupId>
<artifactId>ojdbc11</artifactId>
<scope>runtime</scope>
</dependency>Configure Oracle Database and Kafka
Configure the default datasource for Oracle Database:
datasources.default.driver-class-name=oracle.jdbc.OracleDriver
datasources.default.db-type=oracle
datasources.default.dialect=ORACLE
datasources.default.schema-generate=NONEDatabase Migration with Liquibase
We need a way to create the database schema. For that, we use Micronaut integration with Liquibase.
Add the following snippet to include the necessary dependencies:
<dependency>
<groupId>io.micronaut.liquibase</groupId>
<artifactId>micronaut-liquibase</artifactId>
<scope>compile</scope>
</dependency>Configure the database migrations directory for Liquibase in application.properties.
liquibase.datasources.default.change-log=classpath\:db/liquibase-changelog.xmlCreate the following files with the database schema creation:
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.20.xsd">
<changeSet id="1" author="micronaut">
<sqlFile path="booking.sql" relativeToChangelogFile="true"/>
</changeSet>
<changeSet id="2" author="micronaut">
<sqlFile path="seed.sql" relativeToChangelogFile="true"/>
</changeSet>
</databaseChangeLog>Create the schema that Liquibase applies. Schema generation is disabled so that application restarts preserve committed bookings:
CREATE TABLE flight_seat (
id VARCHAR2(40) PRIMARY KEY,
status VARCHAR2(20) NOT NULL CHECK (status IN ('AVAILABLE', 'HELD', 'BOOKED'))
);
CREATE SEQUENCE booking_seq START WITH 1 INCREMENT BY 1;
CREATE TABLE booking (
id NUMBER(19) PRIMARY KEY,
seat_id VARCHAR2(40) NOT NULL REFERENCES flight_seat(id),
amount NUMBER(10, 2) NOT NULL,
status VARCHAR2(20) NOT NULL CHECK (status IN ('PENDING', 'CONFIRMED')),
payment_reference VARCHAR2(80) NOT NULL
);Create the seed data. The changelog inserts two available seats when it initializes a new database:
INSERT INTO flight_seat (id, status) VALUES ('MN123-12A', 'AVAILABLE');
INSERT INTO flight_seat (id, status) VALUES ('MN123-12B', 'AVAILABLE');Kafka Configuration
Configure the Kafka producer and consumers:
kafka.consumers.default.auto.offset.reset=earliest
kafka.consumers.default.enable.auto.commit=false
kafka.producers.default.enable.idempotence=trueThe consumers start from the earliest available offset for a new group. Automatic offset commits are disabled; each listener uses SYNC_PER_RECORD. The producer requests acknowledgments from all in-sync replicas and enables Kafka producer idempotence. That setting covers producer retries; it does not deduplicate repeated business operations.
Test Resources Configuration
Configure Micronaut Test Resources to start an Oracle Database Free image that supports sessionless transactions in the tests:
test-resources.containers.oracle.image-name=gvenzl/oracle-free
test-resources.containers.oracle.image-tag=23.26.3-slim-faststart
test-resources.containers.oracle.startup-timeout=600sMicronaut Test Resources supplies the datasource URL, username, password, and kafka.bootstrap.servers when they are absent. The pinned Oracle Database Free image supports sessionless transactions. For your own services, set DATASOURCES_DEFAULT_URL, DATASOURCES_DEFAULT_USERNAME, DATASOURCES_DEFAULT_PASSWORD, and KAFKA_BOOTSTRAP_SERVERS; do not put credentials in source control.
Create the two topics before sending messages when using a broker without topic auto-creation:
for topic in payment-requests payment-results; do
kafka-topics.sh --bootstrap-server localhost:9092 --create --if-not-exists \
--topic "$topic" --partitions 1 --replication-factor 1
doneOne replica is suitable for the local demonstration. Configure replication and minimum in-sync replicas for your deployment.
Application Packages
The source code is organized under example.micronaut:
-
controller: HTTP booking endpoints. -
dto: HTTP request and response models and Kafka event payloads. -
entity: persistence entities and status enums. -
repository: Micronaut Data repositories. -
service: transactional booking operations and the demonstration payment gateway. -
kafka: the producer client and the two consumers.
The end-to-end tests exercise the booking controller.
Entities and Repositories
Create the FlightSeat entity, which maps the flight_seat table:
Create the SeatStatus enum:
SeatStatus represents an available seat, a held reservation, and a completed booking. Micronaut Data stores the enum names in the existing VARCHAR2 status column. HTTP responses expose the same names through FlightSeatView, so the enum has @Serdeable while the entity does not.
Create the Booking entity, which maps the booking table:
Create the BookingStatus enum:
BookingStatus represents the pending and confirmed states. Micronaut Data stores the enum names in the existing VARCHAR2 status column, and the HTTP response serializes them as PENDING or CONFIRMED. The enum is serializable because it appears in BookingView; the persistence entity still has no serialization annotation.
The HTTP endpoints return DTOs instead of persistence entities. Create FlightSeatView to represent a seat:
Create BookingView to represent a booking:
Create a repository interface for seats. Micronaut Data implements the interface at compilation time:
The derived findByIdAndStatusForUpdate method lets Micronaut Data generate a locking read for the Oracle Database dialect that returns the seat only when it is AVAILABLE. The service persists the new status with the standard update method while holding this lock. During completion, findByIdAndStatus retrieves only a seat whose status is HELD. Oracle Database holds the lock until the original transaction commits or rolls back, including while it is suspended. Another reservation for the same seat can wait on that lock. A normal read still sees the previously committed AVAILABLE value; it cannot tell you whether a suspended reservation holds the seat.
Create a repository interface for bookings:
The Java FlightSeat and Booking entities are records, which Micronaut Data JDBC supports; the Groovy, Kotlin, and Python variants use mutable entities. The Java service creates new record values with the updated status. None of the persistence entities has serialization annotations. The booking ID uses @GeneratedValue with its default settings. For Oracle Database, Micronaut Data selects sequence generation and derives the sequence name from the table name plus _seq, so Liquibase creates booking_seq for the booking table. A new booking has no assigned ID; the inherited save method inserts it and returns the entity with its generated ID. Sequence values can have gaps after rollback. During completion, the derived findByIdAndStatus method retrieves only a booking that is still PENDING. Changes to seats and bookings use the inherited update method. The queryById methods return the FlightSeatView and BookingView DTOs directly, so Micronaut Data selects only their properties and the controller does not map entities. Both repositories let Micronaut Data generate their SQL.
Request and Message Contracts
Create BookingRequest to bind the body of the HTTP booking request:
Create the PaymentRequested event, which the controller publishes to request a payment:
Create the PaymentCompleted event, which carries the payment outcome:
The controller passes only seatId to the booking service and uses the payment token from the HTTP request when constructing the payment message. Kafka messages contain only the dedicated PaymentRequested and PaymentCompleted DTOs. Persistence entities are never sent to Kafka. All events use the database-generated booking ID as their Kafka key. The controller gets this ID from the saved booking before constructing the payment request. The payment request carries the server-selected price because the pending booking is not visible outside its suspended transaction.
Micronaut’s propagation API returns the Oracle Database GTRID as a string encoded by OracleSessionlessTransactionIdCodec. Include this string in the transactionId field of the Kafka messages so the final consumer can resume the transaction. You can customize the codec, but the receiving application must be able to decode the transaction IDs sent by the producing application.
Treat the encoded GTRID as sensitive transaction-control data. Restrict access to these topics, use authenticated, encrypted connections outside local development, and avoid logging it. Do not send card details in Kafka messages; the sample uses only demonstration tokens.
Transactional Booking Service
Create an exception to signal a declined payment:
imports
package example.micronaut.service;public class PaymentDeclinedException extends RuntimeException {
public PaymentDeclinedException() {
super("Payment declined; roll back the seat reservation");
}
}Create BookingService. It reserves a seat in a suspended transaction and later resumes that transaction to confirm or roll back the booking:
These methods are top-level transaction boundaries on an injected bean. Do not call them inside another active transaction or through self-invocation. The sessionless modes require the default REQUIRED propagation and cannot be combined with @OracleTransactional.Recoverable.
Kafka Producer
Create an interface to send messages to Kafka. The Micronaut framework implements the interface at compilation time:
Returning RecordMetadata makes each producer method synchronous: it waits for a Kafka acknowledgment before returning. The controller waits for its payment request to be acknowledged before returning 202 Accepted. The payment consumer waits for its result publication before its input offset is committed. There is still a crash window between suspending the Oracle Database transaction and receiving a Kafka acknowledgment.
Controller: Reserve and Suspend
Create BookingController to reserve seats and to expose bookings and seats over HTTP:
The controller returns 202 Accepted with a booking ID after Kafka acknowledges the payment request. Reservation has already run and the Oracle Database transaction is suspended, but payment and booking completion are asynchronous. Blocking Kafka sends and JDBC work run on the blocking executor. The controller is an HTTP entry point; it does not listen to a Kafka topic.
Do not publish the payment request from inside the SUSPEND method: a consumer could receive it before Oracle Database has suspended the transaction. A lexical propagation scope is not transported automatically across Kafka or asynchronous threads.
First Consumer: Process Payment
Create a demonstration payment gateway:
The gateway simulates an approval for demo-approve and a decline for demo-decline. Its in-memory map makes repeated payments with the same booking ID return the same result during one process lifetime. Replace this with a payment provider that accepts a durable idempotency key; the demonstration map does not survive restarts.
Create PaymentConsumer. It listens to the payment-requests topic, processes the payment, and publishes the result:
Second Consumer: Resume and Complete
Create CompletionConsumer. It listens to the payment-results topic and completes the booking:
A declined booking leaves no booking row: the original insert was rolled back. An absent booking can also mean it is pending, has expired, or was never started. A production workflow needs a separate durable status record to distinguish those outcomes and recognize duplicate declines.
End-to-End Tests
Create an end-to-end test that books seats through the HTTP API. Liquibase seeds the two demonstration seats only once per database, and each test books one of them. Test Resources can reuse the same database, for example when native tests run after the JVM tests, so each test cleans up after itself through the repositories:
The test submits booking requests through the HTTP API and polls the booking and seat endpoints to verify the outcome. The approval scenario books the seeded MN123-12A seat, then polls until the booking is CONFIRMED with a payment reference and the seat is BOOKED.
An absent booking or an AVAILABLE seat alone would be a false positive: those are also visible while a reservation is suspended. Successfully booking the same seat again and confirming that the declined booking remains absent verify rollback through the public API. The tests use a 30-second HTTP request timeout and a 60-second polling deadline, both shorter than the five-minute Oracle Database suspension timeout, so transaction expiry cannot make this scenario pass.
The test has no surrounding transaction and uses a dedicated Micronaut Test Resources scope, with its own Oracle Database and Kafka containers. Docker is required; the build stops the disposable Test Resources services after the tests.
Testing the Application
To run the tests:
./mvnw testNative Tests
The Maven plugin for GraalVM Native Image building allows you to run native tests.
This plugin supports running tests on the JUnit Platform as native images. This means that tests will be compiled and executed as native code.
First, add the following profile to pom.xml:
<profile>
<id>native</id>
<build>
<plugins>
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<extensions>true</extensions>
<executions>
<execution>
<id>test-native</id>
<goals>
<goal>test</goal>
</goals>
<phase>test</phase>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>Then, to execute the native tests, execute:
./mvnw -Pnative testINFO: A test may be disabled within a GraalVM native image via the @DisabledInNativeImage annotation.
Try a Booking
Start the application with ./mvnw mn:run.
Test Resources starts local Oracle Database and Kafka containers if external connection properties are absent.
Liquibase inserts MN123-12A and MN123-12B when it initializes a new database. These rows are available for the booking requests below; after a successful booking, that seat remains BOOKED across application restarts.
curl -i -X POST http://localhost:8080/bookings \
-H 'Content-Type: application/json' \
-d '{"seatId":"MN123-12A","paymentToken":"demo-approve"}'Copy bookingId from the response and poll:
curl http://localhost:8080/bookings/BOOKING_ID
curl http://localhost:8080/bookings/seats/MN123-12ABefore completion, the booking lookup returns 404; after completion, it returns a booking with status: CONFIRMED and the seat is BOOKED.
Try MN123-12B with demo-decline. After payment processing and rollback, no booking is visible and the seat remains AVAILABLE. The sample has no separate rejection-status endpoint. Use a different available seat for later successful requests, or recreate the disposable database to reset the demonstration.
Generate a Micronaut Application Native Executable with GraalVM
We will use GraalVM, an advanced JDK with ahead-of-time Native Image compilation, to generate a native executable of this Micronaut application.
Compiling Micronaut applications ahead of time with GraalVM significantly improves startup time and reduces the memory footprint of JVM-based applications.
|
Note
|
Only Java and Kotlin projects support using GraalVM’s native-image tool. Groovy relies heavily on reflection, which is only partially supported by GraalVM.
|
GraalVM Installation
sdk install java 25.4.4+1-graalFor installation on Windows, or for a manual installation on Linux or Mac, see the GraalVM Getting Started documentation.
The previous command installs Oracle GraalVM, which is free to use in production and free to redistribute, at no cost, under the GraalVM Free Terms and Conditions.
Alternatively, you can use the GraalVM Community Edition:
sdk install java 25.4.4.1+1-graalceNative Executable Generation
To generate a native executable using Maven, run:
./mvnw package -Dpackaging=native-imageThe native executable is created in the target directory and can be run with target/micronautguide.
It is possible to customize the name of the native executable or pass additional build arguments using the Maven plugin for GraalVM Native Image building. Declare the plugin as follows:
Liquibase Reflection Metadata
Liquibase applies the attributes of the changelog’s sqlFile changes through reflection. Register those classes for GraalVM Native Image:
{
"reflection": [
{
"type": "liquibase.change.core.SQLFileChange",
"allPublicConstructors": true,
"allPublicMethods": true
},
{
"type": "liquibase.change.AbstractSQLChange",
"allPublicMethods": true
}
]
}Without this file, the native executable and the native tests fail at startup with a MissingReflectionRegistrationError for liquibase.change.core.SQLFileChange.
Micronaut Test Resources does not start containers for the native executable. Set DATASOURCES_DEFAULT_URL, DATASOURCES_DEFAULT_USERNAME, DATASOURCES_DEFAULT_PASSWORD, and KAFKA_BOOTSTRAP_SERVERS before you run it.
Cleanup
Stop the application and its Test Resources services.
Use ./mvnw mn:stop-testresources-service to stop the Maven Test Resources service.
The containers are disposable; no cloud resources are provisioned by this guide. Remove demonstration topics only from a broker dedicated to this example.
Next Steps
Read the Micronaut Data sessionless transaction documentation, the Micronaut Kafka guide, and the Oracle AI Database documentation on sessionless transactions: Developing Applications with Sessionless Transactions and JDBC Sessionless Transactions.
License
|
Note
|
All guides are released with an Apache License 2.0 for the code and a Creative Commons Attribution 4.0 license for the writing and media (images). |