Inbox/outbox services
View MarkdownStatus: Stable Package:
dev.vertique.inboxoutbox.servicesArtifact:vertique-inbox-outbox-servicesDepends on: inbox-outbox-core, services, context, logging, security-core API reference: javadoc.io
Service adapter for Transactional Messaging. Provides two components: TransactionalServiceClientFactory
(authoring — records a transactional service side-effect without constructing a raw OutboxEntry)
and ServiceOutboxDestinationHandler (relay — resolves stable service target ids at relay time and
dispatches using event bus request/reply semantics).
Key Classes
Section titled “Key Classes”TransactionalServiceClientFactory
Section titled “TransactionalServiceClientFactory”Creates a transaction-scoped JDK dynamic proxy for a @ServiceContract interface. Each method
invocation on the proxy records an outbox row inside the SqlClient captured at create time;
the relay delivers the call after commit.
pool.withTransaction(tx -> { NotificationService txNotification = factory.create(NotificationService.class, tx); return orderRepository.save(order, tx) .compose(v -> txNotification.sendOrderConfirmation( new OrderConfirmationPayload(order.id())));});create(Class<T> contract, SqlClient tx) validates every non-Object method on the contract
immediately and throws IllegalArgumentException if any rule fails. The proxy is not
application-scoped — create it inside the open transaction and do not inject it as a singleton.
Invoking a method on the returned proxy:
- Looks up the precomputed stable service target id for that method.
- Takes the single payload argument (proxy methods take the payload only — not
SqlClient). - Calls
OutboxService.publish(tx, OutboxEntry)withdestinationType = SERVICEanddestination = stableTargetId. - Returns
publish(...).mapEmpty()— aFuture<Void>. The contract method must itself declareFuture<Void>; the proxy cannot return the outbox entry id or the eventual service result.
A null payload argument is not rejected by the proxy. It builds the entry with a null payload;
the outbox payload column is NOT NULL, so the insert fails, the returned future fails with it,
nothing is stored and the target service is never called.
Validation rules (all checked at create):
- Every non-
Objectmethod must carry@ServiceOperation(this is what supplies the stable target id) @OneWayoperations are rejected — the relay needs request/reply confirmation- Return type must be
Future<Void> - Exactly one payload parameter
- No
SecurityContextor other dispatch-context / transport-injected parameters
ServiceOutboxDestinationHandler
Section titled “ServiceOutboxDestinationHandler”OutboxDestinationHandler implementation for DestinationType.SERVICE. Registered by
TransactionalMessagingServiceModule into the Set<OutboxDestinationHandler> multibinding.
Claim scope: Returns ClaimScope.destinations(ServiceTargetResolver::supportedTargetIds) from
claimScope(). This node claims only SERVICE outbox rows whose destination value is present in
the resolver’s supported target id set — i.e., service targets locally registered on this node.
Rows for targets not reachable here are left for another node that hosts the relevant service.
At relay time:
- Reads
destination(stable service target id) from theOutboxEnvelope. - Resolves the current event bus address via
ServiceTargetResolver. - If the target id is not resolvable (e.g., service not deployed on this node), returns
OutboxPublishResult.unresolvable(message). - Builds a
DispatchEnvelopeviaDispatchEnvelopeBuilderwith the payload and the application headers (OutboxEnvelope.headers, application-only). The durable propagation context is read fromOutboxEnvelope.metadata().context()(NOT from headers) and decoded viaDurableContextPropagator.decodeToDispatchContext(metadata.context(), ...), then merged into the caller-override map — no holder write (the relay runs on the verticle’s deployment context, not a duplicated context). The per-row carrier is reproduced frommetadata.delivery.outbox().carrierId()so a transplanted identity snapshot fails closed. Relay control inmetadata.deliveryis otherwise ignored on the service path. Before building the envelope, aDeferredExecutionOrigin(kind = "outbox-relay",reference= the event type) is also merged into the caller-override map, proving the dispatch is deferred execution for the opt-in identity-snapshot reconstruction initializer. - Sends via event bus request/reply with the service’s configured timeout.
- On a successful reply: returns
OutboxPublishResult.success(). - On a failed reply from the service: returns
OutboxPublishResult.retryable(message, cause). - Rejects
@OneWayservice targets — any operation resolving to a@OneWaymethod returnsOutboxPublishResult.permanent(...). - A stored payload that cannot be decoded to the target’s payload type, or a durable propagation
context that cannot be decoded, returns
OutboxPublishResult.retryable(message, cause); nothing escapespublish. The message names the target and the payload type and never the payload content; the decode failure is the cause. It is retried because during a rolling deploy an older node can claim an entry that only a newer node can decode — a later attempt lets the newer node deliver it — while the entry’s maximum attempts still dead-letter one that no node can decode.
SERVICE delivery guarantee: at-least-once handoff to the service. The service must be idempotent
or use InboxService for dedup if needed.
Extension Points
Section titled “Extension Points”None beyond the OutboxDestinationHandler SPI already defined in inbox-outbox-core.
ServiceOutboxDestinationHandler is the concrete extension point implementation for SERVICE
destinations.
Dagger Wiring
Section titled “Dagger Wiring”TransactionalMessagingServiceModule includes ContextRuntimeModule and LoggingContextModule.
It contributes only the destination handler; TransactionalServiceClientFactory is a @Singleton
with an @Inject constructor and is JIT-bound — there is no @Provides factory method.
@Module(includes = {ContextRuntimeModule.class, LoggingContextModule.class})public abstract class TransactionalMessagingServiceModule {
@Provides @IntoSet static OutboxDestinationHandler serviceHandler( ServiceOutboxDestinationHandler handler) { return handler; }}Include alongside TransactionalMessagingPostgresqlModule and DispatchModule:
@Component(modules = { VertxModule.class, DispatchModule.class, DbPostgresqlModule.class, DbFlywayModule.class, TransactionalMessagingPostgresqlModule.class, TransactionalMessagingServiceModule.class, AppModule.class})public interface AppComponent { ... }