Event Queues in Java
For concepts, use cases, and guarantees, see the Transactional Event Queues guide. This page covers the Java-specific APIs and configuration.
In Java, event queues are exposed as outbox services. The runtime ships two default outboxes. First, DefaultOutboxOrdered that is used by messaging services and processes entries in submission order. Second, DefaultOutboxUnordered that is used by the audit-log service and processes entries in parallel. You can register custom outbox services for advanced isolation, scaling, or shared-database scenarios.
Programmatic API
Queueing a Service
Wrap any CAP service with outbox handling. Events triggered on the returned wrapper are stored in the outbox first and executed asynchronously after commit. Relevant information from the RequestContext is stored with the event data. The user context is downgraded to a system user context.
OutboxService myCustomOutbox = ...;
CqnService remoteS4 = ...;
CqnService outboxedS4 = myCustomOutbox.outboxed(remoteS4);A typical use case is outboxing a remote OData service, but any CAP service can be wrapped this way to decouple its calls from the calling transaction.
If a method on the outboxed service has a return value, it returns null because the call is executed asynchronously — a common surprise on CqnService.run(...). To make the asynchronous nature explicit at the type level, use the two-argument variant of outboxed to wrap the service with an interface whose methods return void. CAP ships AsyncCqnService for the common case of outboxing a CqnService:
OutboxService myCustomOutbox = ...;
CqnService remoteS4 = ...;
AsyncCqnService outboxedS4 = myCustomOutbox.outboxed(remoteS4, AsyncCqnService.class);For CqnService instances, the static factory AsyncCqnService.of(service, outbox) is the most direct way to obtain an outboxed proxy:
OutboxService myCustomOutbox = ...;
CqnService remoteS4 = ...;
AsyncCqnService outboxedS4 = AsyncCqnService.of(remoteS4, myCustomOutbox);The outboxed service is thread-safe and can be cached. Any service that implements the Service interface can be outboxed, and each call is asynchronously executed if the API method internally calls Service.emit(EventContext).
To get the original synchronous service from a wrapped one:
CqnService synchronous = OutboxService.unboxed(outboxedS4);Custom asynchronous-ready APIs
You can define your own asynchronous-ready interface (analogous to AsyncCqnService for other service types). It must provide the same method signatures as the interface of the outboxed service, except for the return types — those must be void.
Java Proxy
A service wrapped by an outbox is a Java Proxy. It only implements the interfaces of the underlying object, which means you can't cast an outboxed service proxy back to its concrete implementation class.
Scheduling
CAP Java offers two ways to schedule a queued event, both controlled by a Schedule builder. They differ in the level of abstraction they operate at.
Option 1 — Technical level: pass a Schedule to submit
You submit directly to the outbox service with a generic, untyped payload. This gives you lower-level access and is the right fit when you don't have (or don't want) a CDS service interface for the event.
@Autowired
OutboxService outbox; // DefaultOutboxUnordered — injectable without qualifier
OutboxMessage message = OutboxMessage.create();
message.setParams(Map.of("entity", "Airports"));
outbox.submit("replicate", message,
Schedule.create().every(Duration.ofMinutes(10)));Option 2 — CDS level: wrap a service with Schedulable
You wrap a CDS service with Schedulable, and subsequent calls go through the service's typed API. This offers a more type-safe and domain-oriented programming model.
@Autowired
OutboxService outbox;
@Autowired
TravelService xflights;
Schedulable.of(xflights, outbox)
.scheduled(Schedule.create().every(Duration.ofMinutes(10)))
.replicateTravels(...);Every outboxed service is guaranteed to implement Schedulable<T> — its single method scheduled(Schedule) returns the same service typed to use the given schedule on every subsequent emit.
Schedule Options
Schedule is a small builder with three timing options:
// Immediate execution
Schedule.NOW;
// Execute once, after a delay
Schedule.create().after(Duration.ofHours(1));
// Execute repeatedly, with a fixed delay between successful runs
Schedule.create().every(Duration.ofMinutes(10));
// Execute with an initial delay, then recurring
Schedule.create().after(Duration.ofSeconds(10)).every(Duration.ofMinutes(5));
// Execute repeatedly, on a Spring cron expression
Schedule.create().cron("0 0 3 * * *");after and every accept any java.time.Duration. cron follows the Spring cron syntax. cron is mutually exclusive with after/every — combining them throws IllegalArgumentException. after and every may be combined: the first execution is delayed by after, then every applies between subsequent runs. Omitting after with every starts the first execution immediately.
Cron field counts differ between stacks
Java cron expressions are six fields including seconds (Spring syntax); Node.js cron expressions are five fields. A cron string copied between stacks won't behave the same way.
Cron times are UTC
All cron expressions are evaluated in UTC. A cron of "0 0 8 * * MON-FRI" means 08:00 UTC, not local time.
Common cron examples (six fields: second minute hour day month weekday):
| Expression | Fires |
|---|---|
0 0 * * * * | Every hour |
0 */15 * * * * | Every 15 minutes |
0 0 8 * * MON-FRI | Weekdays at 08:00 UTC |
0 0 2 * * * | Daily at 02:00 UTC |
0 0 0 1 * * | First of every month at midnight |
Never-matching cron expressions
A cron that never matches (for example, February 30th) is silently deleted — the task is marked as completed without ever executing.
Every scheduled task has a name — by default it inherits the event name, which makes scheduling idempotent: a subsequent submission for the same event name overwrites the previous schedule (tasks are upserted, not deduplicated). Use .as(name) explicitly only when you want a custom name different from the event name — for example, to schedule the same event with different payloads as separate, independently-managed tasks.
// Two independent singleton tasks for the same "replicate" event
OutboxMessage airports = OutboxMessage.create();
airports.setParams(Map.of("entity", "Airports"));
outbox.submit("replicate", airports,
Schedule.create().as("replicate-airports").every(Duration.ofMinutes(10)));
OutboxMessage airlines = OutboxMessage.create();
airlines.setParams(Map.of("entity", "Airlines"));
outbox.submit("replicate", airlines,
Schedule.create().as("replicate-airlines").every(Duration.ofHours(1)));
// Each can be removed independently by its task name
outbox.submit("replicate", OutboxMessage.create(),
Schedule.create().as("replicate-airports").cancel());
outbox.submit("replicate", OutboxMessage.create(),
Schedule.create().as("replicate-airlines").cancel());To remove a task that uses the default event name, submit a cancellation without .as():
outbox.submit("replicate", OutboxMessage.create(),
Schedule.create().cancel());Technical Outbox API
The technical API outboxes custom messages for arbitrary events or processing logic. The OutboxMessage instance is serialized to JSON and stored in the database, so all data must be JSON-serializable.
OutboxService outbox = runtime.getServiceCatalog()
.getService(OutboxService.class, "<OutboxServiceName>");
OutboxMessage message = OutboxMessage.create();
message.setParams(Map.of("name", "John", "lastname", "Doe"));
outbox.submit("myEvent", message);Register an @On handler on the outbox service to perform the processing logic when the message is published:
@On(service = "<OutboxServiceName>", event = "myEvent")
void processMyEvent(OutboxMessageEventContext context) {
OutboxMessage message = context.getMessage();
Map<String, Object> params = message.getParams();
String name = (String) params.get("name");
String lastname = (String) params.get("lastname");
// Perform processing logic for myEvent
context.setCompleted();
}The handler must complete the context after executing the processing logic.
Learn more about event handlers.
Custom Serialization
The outbox has no information about the structure or data types being serialized. If your custom messages use non-default data types, or you need extra context properties, register @Before and @On handlers to customize serialization and deserialization. This isn't required for CDS-model-based services.
@Component
@ServiceName(value = "*", type = OutboxService.class)
public class CustomOutboxHandler implements EventHandler {
@On
void publishedByOutbox(OutboxMessageEventContext context) {
// Restore custom values from context only
if (Boolean.FALSE.equals(context.getIsInbound())) {
return;
}
// custom deserialization logic
Long date = (Long) context.getMessage().getParams().get("orderDate");
context.getMessage().getParams().put("orderDate", Instant.ofEpochSecond(date));
}
@Before(event = "*")
void prepareOutboxMessage(OutboxMessageEventContext context) {
// prepare outbox message for storage only
if (Boolean.TRUE.equals(context.getIsInbound())) {
return;
}
// custom serialization logic
Instant date = (Instant) context.getMessage().getParams().get("orderDate");
context.getMessage().getParams().put("orderDate", date.getEpochSecond());
}
}Don't complete the context in either of those handlers
Calling setCompleted here breaks the chain. The next handler isn't called and processing fails.
Error Handling
By default, the outbox retries publishing a message on error until it reaches maxAttempts. This makes applications resilient against unavailability of external systems.
Some errors aren't worth retrying, for example, a 400 Bad Request from a downstream service indicates a semantic error that the same payload reproduces on every attempt. Wrap the processing in a try/catch and call context.setCompleted() to remove the message from the queue without further retries:
@On(service = "<OutboxServiceName>", event = "myEvent")
void processMyEvent(OutboxMessageEventContext context) {
try {
// Perform processing logic for myEvent
} catch (Exception e) {
if (isUnrecoverableSemanticError(e)) {
// Perform application-specific counter-measures
context.setCompleted(); // indicate message deletion to outbox
} else {
throw e; // indicate error to outbox
}
}
}If the original processing logic isn't yours and you need to wrap its error handling, use EventContext.proceed():
@On(service = OutboxService.PERSISTENT_ORDERED_NAME, event = AuditLogService.DEFAULT_NAME)
void handleAuditLogProcessingErrors(OutboxMessageEventContext context) {
try {
context.proceed(); // wrap default logic
} catch (Exception e) {
if (isUnrecoverableSemanticError(e)) {
// Perform application-specific counter-measures
context.setCompleted();
} else {
throw e;
}
}
}Learn more about EventContext.proceed().
Callbacks not yet available
The #succeeded / #failed callback events documented for Node.js have no Java equivalent yet, see Callbacks in the common guide.
Configuration
Default Outbox Services
DefaultOutboxUnordered is the primary persistent outbox — it is used by the AuditLog service by default and registered as the primary Spring bean for OutboxService, so it can be injected directly without a qualifier:
@Autowired
OutboxService outbox; // DefaultOutboxUnorderedDefaultOutboxOrdered is used by messaging services by default; it processes entries in submission order.
The configuration of both can be overridden in application.yaml:
cds:
outbox:
services:
DefaultOutboxOrdered:
maxAttempts: 10
DefaultOutboxUnordered:
maxAttempts: 10| Option | Default | Description |
|---|---|---|
maxAttempts | 10 | Number of unsuccessful emits until the message is ignored. It still remains in the database. |
enabled | true | Set to false to disable an outbox service. |
Status Lock Timeout
A separate, runtime-global setting controls how long a processing entry can be held before another instance picks it up, which is useful when an instance crashes mid-processing:
cds:
outbox:
persistent:
statusLock:
timeout: 1h # defaultCollector Strategies
In a multitenant environment, outbox entries reside in tenant-specific databases. The outbox collector is triggered when events are submitted. However, if an application instance crashes, unprocessed entries for a tenant are only retried when that tenant next produces a new outbox event. If a tenant goes quiet after a crash, remaining entries stay unprocessed.
Both strategies are disabled by default and must be enabled explicitly.
Hot-Tenant Task
Tracks which tenants have been recently active and only triggers the collector for those tenants. Lookups are distributed over time to avoid activity jams — a lighter alternative to the all-tenants task for large tenant counts.
cds:
outbox:
persistent:
scheduler:
hotTenantTask:
enabled: true
maxTaskDelay: 2h # max time after a tenant event before checking its outboxThe hot-tenant task tracks tenant activity in the provider persistence (MTXs/T0 by default). To use a custom provider persistence instead, set cds.multiTenancy.provider.persistenceService:
cds:
multiTenancy:
provider:
persistenceService: "my-custom-ps"Switching provider persistence loses tracked tenants
Changing from the default MTXs/T0 persistence to a custom provider persistence discards all currently tracked hot tenants — there's no automatic migration. Plan accordingly before changing this setting.
All-Tenants Task
Periodically iterates over all tenant outboxes. Acts as a safety net to ensure no entries are missed regardless of tenant activity.
cds:
outbox:
persistent:
scheduler:
enabled: true
allTenantsTask:
enabled: true
startDelay: 30s # delay after startup before first run
interval: 2h # interval between runs
spreadTime: 15m # spread individual tenant checks to avoid thundering-herdPerformance for large tenant counts
Traversing all tenants can cause significant overhead due to tenant context switches. Consider the hot-tenant task as a lighter alternative.
Prerequisite: outbox scheduler
Both strategies require the outbox scheduler to be enabled. The scheduler is enabled by default (cds.outbox.persistent.scheduler.enabled: true). Set it to false to disable all outbox-based task scheduling across both strategies.
Custom Outbox Services
Configure custom persistent outboxes in application.yaml:
cds:
outbox:
services:
MyCustomOutbox:
maxAttempts: 5
MyOtherCustomOutbox:
maxAttempts: 10Access them either via the service catalog:
OutboxService myCustomOutbox = cdsRuntime.getServiceCatalog()
.getService(OutboxService.class, "MyCustomOutbox");or by Spring injection:
@Component
public class MySpringComponent {
private final OutboxService myCustomOutbox;
public MySpringComponent(@Qualifier("MyCustomOutbox") OutboxService myCustomOutbox) {
this.myCustomOutbox = myCustomOutbox;
}
}Removing a custom outbox
Before removing a custom outbox from the configuration, ensure no unprocessed entries remain in cds.outbox.Messages for it. Removing the outbox configuration does not delete the entries — they remain in the table and aren't processed anymore.
Shared Databases
Workaround for unsupported scenario
CAP Java does not yet support microservices with a shared database out of the box: the two static-named default outboxes (DefaultOutboxOrdered, DefaultOutboxUnordered) are shared across all services and introduce conflicts.
The manual workaround uses isolated custom outboxes with service-specific names:
1. Deactivate the default outboxes and create service-specific ones
cds:
outbox:
services:
# deactivate default outboxes
DefaultOutboxUnordered.enabled: false
DefaultOutboxOrdered.enabled: false
# custom outboxes with unique names
Service1CustomOutboxOrdered:
maxAttempts: 10
Service1CustomOutboxUnordered:
maxAttempts: 102. Adapt audit log configuration
The default audit-log outbox is DefaultOutboxUnordered. Point it at the new custom outbox:
cds:
auditlog:
outbox.name: Service1CustomOutboxUnordered3. Adapt messaging configuration
For each messaging service in the application, point it at the new ordered outbox:
cds:
messaging:
services:
MessagingService1:
outbox.name: Service1CustomOutboxOrdered
MessagingService2:
outbox.name: Service1CustomOutboxOrderedRequired for isolation
Both deactivating the defaults and using unique outbox namespaces are required to achieve service isolation in a shared-database scenario.
Event Versions
In blue/green scenarios, outbox collectors of an older deployment cannot process events emitted by a newer deployment. Configure each deployment with an event version so older collectors skip newer events:
cds.environment.deployment.version: 2
Ascending versions only
Configured deployment versions must increase. Messages are processed by an outbox collector only if the event version is less than or equal to the deployment version.
To automate versioning from the Maven app version, enable resource filtering in srv/pom.xml:
<build>
...
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
</resource>
</resources>
...Then use the ${project.version} placeholder:
cds.environment.deployment.version: ${project.version}
A startup log entry shows the configured version:
2024-12-19T11:21:33.253+01:00 INFO 3420 --- [main] cds.services.impl.utils.BuildInfo : application.deployment.version: 1.0.0-SNAPSHOTTo bypass the version check for a specific custom outbox, set cds.outbox.services.MyCustomOutbox.checkVersion: false.
Troubleshooting
Inspecting cds.outbox.Messages
To see what's currently queued, query cds.outbox.Messages directly through the PersistenceService. The columns most useful for triage are status, attempts, target, lastError, and lastAttemptTimestamp:
@Autowired @Qualifier(PersistenceService.DEFAULT_NAME)
PersistenceService db;
Result result = db.run(Select.from(Messages_.class)
.columns(m -> m.ID(), m -> m.target(), m -> m.status(),
m -> m.attempts(), m -> m.lastAttemptTimestamp(), m -> m.lastError())
.orderBy(m -> m.timestamp().desc()));For a managed view with bound revive and delete actions, see Dead Letter Queue in the common guide.
Deleting Entries
To clear stuck messages programmatically:
db.run(Delete.from(Messages_.class));Working in Node.js? See Event Queues in Node.js.