Messaging patterns
The library's patterns, wired into a Spring Boot application. Each of
these is a library type reached through the AceMq bean, so
the Spring-specific part is always the same question: which bean holds
it, and what closes it.
| Request and reply | A synchronous answer over a queue |
| Scheduling | Deliver this in an hour |
| Transactional outbox | A message and a database row, atomically |
| Saga | Several steps, with compensations when one fails |
| Pipelines | A typed chain of queues, one step per queue |
| Ordered per key | Order within a customer, parallelism across customers |
| Routing slips | The route travels with the message |
Retries, dead letters, replay and idempotency are on their own page, as are serialization, schemas, claim checks and encryption.
Request and reply
A request onto a queue, and an answer back on a reply queue, without HTTP. Worth it when the caller already has a broker connection and the callee is a consumer rather than a service with an endpoint — and not worth it merely to avoid writing a controller.
The answering side:
@Configuration
class PriceService {
@Bean(destroyMethod = "close")
Responder priceResponder(AceMq mq, Prices prices) {
return mq.respond("prices.requests", PriceRequest.class, request -> prices.quote(request));
}
}respond takes a plain Function<Q, A>:
the return value is the reply, and the library matches it to the request
and publishes it to whatever reply queue the request named. A
four-argument form takes ConsumerOptions, which is how a
responder gets a prefetch, a retry policy or a codec of its own.
The asking side:
@Configuration
class Requesting {
/** One per application. It owns an exclusive reply queue and a correlation table. */
@Bean(destroyMethod = "close")
Requester requester(AceMq mq) {
return mq.requester();
}
}
@Service
class Checkout {
private final Requester requester;
Checkout(Requester requester) {
this.requester = requester;
}
Price price(PriceRequest request) {
return requester.request("prices", "price.requested", request,
Price.class, Duration.ofSeconds(2));
}
CompletableFuture<Price> priceAsync(PriceRequest request) {
return requester.requestAsync("prices", "price.requested", request, Price.class);
}
}Make the Requester a bean, not a local.
It owns a reply queue and a subscription to it, and creating one per
call means a queue per call — which a broker will let you do right up to
the point where it does not.
request(...) blocks for the timeout and then throws
RequestTimedOutException. requestAsync has no
timeout parameter; bound it with orTimeout on the future,
or use the synchronous form. A timeout is not a failure of the request —
the answer may well arrive afterwards and be counted in
requester.unmatched(), which together with
timedOut() is the pair worth putting on a gauge. A rising
unmatched means the timeout is too short rather than that
the responder is broken.
A responder that fails says so. When the responder's
function throws, it replies with the failure in the
acemq-error header (ExceptionType: message)
and rejects the request rather than retrying it, and the requester
throws ResponderFailedException at once — from
request(...), or as the cause of the failed
requestAsync future — instead of waiting out the timeout.
So a timeout now means no responder answered, and a
ResponderFailedException means one did and failed; the two
want different handling, and a retry helps only the first. This holds
across languages: a Go, Python or Ruby responder's failure arrives the
same way. Before 0.3.1 (acemq-java-amqp 0.7.15) a failing
Java responder sent nothing and the caller saw a timeout.
The requester's reply queue declares no .dlq or
.parked queue: a reply nobody is waiting for is counted in
unmatched, not kept.
A blocking request from a web request thread is a thread held
for the timeout. Two seconds under load is a thread pool.
requestAsync and a CompletableFuture-returning
controller method, or a virtual-thread executor, are the ways out.
Scheduling
@Configuration
class Scheduling {
@Bean(destroyMethod = "close")
Scheduler scheduler(AceMq mq) {
return Scheduler.on(mq);
}
}
@Service
class Reminders {
private final Scheduler scheduler;
void remindAboutAbandonedBasket(Basket basket) {
scheduler.in(Duration.ofHours(1), "baskets", "basket.abandoned", basket);
}
void chargeAtRenewal(Subscription subscription) {
scheduler.at(subscription.renewsAt(), "billing", "subscription.renew", subscription);
}
}The delay is held on the broker, in queues whose time-to-live expires
the message onward. So a scheduled message survives a restart of the
application, which is the whole reason not to use
@Scheduled and an in-memory timer for this.
scheduler.hops() is the counter to understand: a long
delay is built from several shorter hops, because a queue's time-to-live
is fixed when the queue is declared. Rising hops are normal for long
delays and are not a fault.
This is not a replacement for @Scheduled. A cron job
that runs every night belongs in Spring's scheduler; a message about one
basket, an hour from now, belongs here. The distinction is whether the
schedule is about the application or about a single message.
Transactional outbox
The problem: a request has to write a row and publish a message, and there is no transaction spanning a database and a broker. Publish first and the transaction may roll back, leaving a message about something that did not happen. Publish last and the process may die, leaving a row nothing was told about.
The outbox writes the message into the same database in the same transaction, and a relay publishes it afterwards.
@Configuration
class Outbox {
/**
* Two connection sources on purpose. The first is Spring's — so add() joins whatever
* transaction the calling @Transactional method is in, which is the entire point. The
* second is the pool, for the relay, which must not be inside anybody's transaction.
*/
@Bean
OutboxStore outboxStore(DataSource dataSource) {
JdbcOutboxStore store = new JdbcOutboxStore(
() -> DataSourceUtils.getConnection(dataSource),
dataSource);
store.createSchemaIfAbsent();
return store;
}
@Bean(destroyMethod = "close")
OutboxRelay outboxRelay(AceMq mq, OutboxStore store) {
OutboxRelay relay = new OutboxRelay(mq, store);
relay.start();
return relay;
}
}DataSourceUtils.getConnection(dataSource) is the
load-bearing line. It returns the connection bound to the current
transaction when there is one, and a fresh one otherwise — which is
exactly what add() needs and what
dataSource::getConnection would get wrong. With the plain
data source the insert would be in its own transaction and would commit
even when the business transaction rolled back, which is the failure the
outbox exists to prevent, reintroduced one method call lower down.
Writing to it:
@Service
class Orders {
private final OrderRepository orders;
private final OutboxStore outbox;
@Transactional
public void place(Order order) {
orders.save(order);
outbox.add(OutboxRecord.of(
"orders", "order.created",
Envelope.of("order.created").correlationId(order.id()).build(),
serialise(order)));
}
}One transaction, two rows, no message published yet. If the transaction rolls back, both disappear together.
OutboxRecord.of(exchange, routingKey, envelope, payload)
takes the payload as a String — the outbox stores the
encoded body, and the relay publishes those bytes rather than
re-encoding an object. JSON from your object mapper is the ordinary
answer.
The relay polls, claims a batch under a lease, publishes, and marks
each record published or failed.
new OutboxRelay(mq, store, batchSize, pollInterval, lease)
sets those; the defaults are sensible and the lease is the one worth
thinking about — it is how long a claimed batch stays claimed if the
relay dies mid-publish, and it must exceed the time a batch takes to
publish or two relays will publish the same records.
Several replicas each run a relay, and that is fine.
claimBatch leases rows, so two relays take different
batches. What it does not give you is exactly-once delivery: a relay
that publishes and then dies before markPublished will
publish that record again. The outbox guarantees at-least-once, which is
why its consumers want idempotency.
Watch pendingCount(). A relay that has stopped looks
exactly like a quiet system until somebody asks why nothing downstream
has happened:
@Bean
MeterBinder outboxDepth(OutboxStore store, OutboxRelay relay) {
return registry -> {
Gauge.builder("outbox.pending", store::pendingCount).register(registry);
Gauge.builder("outbox.relay.running", () -> relay.isRunning() ? 1 : 0).register(registry);
FunctionCounter.builder("outbox.published", relay, OutboxRelay::published).register(registry);
FunctionCounter.builder("outbox.failed", relay, OutboxRelay::failed).register(registry);
};
}relay.drainOnce() publishes one batch synchronously and
returns how many, which is what a test uses instead of waiting for a
poll interval. drain() publishes until the outbox is
empty.
Saga
Several steps that must all happen, each with something to undo it. When step four fails, the first three are compensated in reverse.
@Service
class Fulfilment {
private final Payments payments;
private final Inventory inventory;
private final Shipping shipping;
SagaResult fulfil(Order order) {
Saga<Order> saga = Saga.<Order>named("fulfil-order")
.step("take-payment", payments::take)
.compensateWith(payments::refund)
.step("reserve-stock", inventory::reserve)
.compensateWith(inventory::release)
.step("book-courier", shipping::book)
.compensateWith(shipping::cancel)
.build();
return saga.run(order);
}
}Each compensateWith undoes the step immediately before
it. A step with no compensation is allowed and means "nothing to undo" —
which is a claim worth being sure of, because a failure later on will
not come back to it.
SagaResult result = saga.run(order);
result.isComplete(); // every step ran
result.compensated(); // it failed and every compensation ran
result.failedAt(); // which step
result.failure(); // why
result.completed(); // the steps that ran
result.unresolved(); // compensations that themselves failed
result.hasUnresolved(); // the case that needs a humanhasUnresolved() is the one to handle. A
saga that failed and compensated cleanly is a normal outcome and needs
no attention. A saga whose compensation failed has left the
system half-changed and there is nobody left to fix it automatically — a
payment taken and not refunded. Log it loudly, alert on it, and give
somebody a way to see the list:
SagaResult result = saga.run(order);
if (result.hasUnresolved()) {
log.error("saga fulfil-order left {} unresolved for order {}; manual action needed",
result.unresolved(), order.id());
}Saga is a plain object, not a bean — build it where the
steps are, from the beans that do the work. The steps are
Consumer<T>, so they can publish, call a database, or
do both; the saga does not know or care.
This is an orchestrated saga: one process runs the steps and knows the order. A choreographed saga, where each service reacts to the previous one's event, is a pipeline or plain listeners, and does not use this type.
Pipelines
A typed chain where each step is its own queue: the output of one step is the input of the next, and each step can fail, retry and scale independently. The type changes along the chain and the compiler checks it.
@Configuration
class OrderPipeline {
@Bean(destroyMethod = "close")
Pipeline<OrderPlaced> orders(AceMq mq, Validation validation, Pricing pricing, Warehouse warehouse) {
return mq.pipeline("orders", OrderPlaced.class)
.step("validate", ValidatedOrder.class, message -> validation.check(message.payload()))
.step("price", PricedOrder.class, message -> pricing.apply(message.payload()))
.step("reserve", ReservedOrder.class, message -> warehouse.reserve(message.payload()))
.withRetry(RetryPolicy.exponential(5, Duration.ofSeconds(2), Duration.ofSeconds(30)))
.concurrency(4)
.prefetch(40)
.build();
}
}build() declares the queues and starts a consumer on
each. send(payload) puts a payload in at the top and
returns the run id.
pipeline.send(order); // returns the run id
pipeline.queueFor("price"); // the queue that step consumes, for a dashboard
pipeline.step("price"); // that step's ConsumerGroup, for scaling and counters
pipeline.entered();
pipeline.completed();
pipeline.endedEarly(); // a step returned null and stopped the run
pipeline.inFlight();A step that returns null ends the run
without it being a failure, which is how a filter step works —
validation that rejects an order stops it going further rather than
throwing. endedEarly() counts those, and a pipeline where
endedEarly is most of entered is usually a
validation rule that is wrong rather than a lot of bad orders.
withRetry, idempotent,
concurrency, prefetch and
encodedAs apply to every step. A single step that needs
different settings is a sign the chain should be two pipelines, or that
the step should be an ordinary listener publishing onward.
The step handler is a Step<I, O>, which takes a
Message<I> rather than a bare payload — so a step can
read the envelope, the correlation id and the attempt count, which is
most of what a step wants that a Function would not give
it.
Ordered per key
Concurrency above one on a queue gives up ordering. Usually that is fine; sometimes order within one customer, one account or one aggregate is a correctness requirement while order across them is not.
@Configuration
class Accounts {
@Bean(destroyMethod = "close")
OrderedQueue<AccountEvent> accountEvents(AceMq mq, Ledger ledger) {
return mq.<AccountEvent>ordered("account.events", AccountEvent.class)
.partitions(12)
.keyedBy(AccountEvent::accountId)
.prefetch(20)
.onFailure(OrderedQueue.OnFailure.STOP)
.declare()
.consume(event -> ledger.apply(event.payload()));
}
}Twelve partitions, twelve queues, one consumer each. Every event for one account hashes to the same partition and is therefore handled in order by one consumer, while twelve accounts are handled at once.
Publish through the queue rather than through a publisher, so the partition is chosen for you:
int partition = accountEvents.send(event);The three failure behaviours are a real choice and worth making deliberately:
STOP |
The partition halts. Order is preserved; that account stops moving until somebody intervenes |
RETRY_IN_PLACE |
Retry the same message, in order, attempts times with a
delay |
SKIP |
Drop it and carry on. Order is preserved; a message is gone |
STOP is the default and the honest one for a ledger: an
event that cannot be applied means the next event for that account
cannot be applied correctly either, and carrying on produces a balance
that is wrong and does not look wrong. haltedPartitions()
is what a health check reads — a halted partition is a stuck customer
and nothing else will tell you.
@Bean
HealthIndicator accountOrdering(OrderedQueue<AccountEvent> accountEvents) {
return () -> accountEvents.haltedPartitions().isEmpty()
? Health.up().withDetail("partitions", accountEvents.partitions()).build()
: Health.down().withDetail("halted", accountEvents.haltedPartitions()).build();
}onFailure(OnFailure.RETRY_IN_PLACE, 3, Duration.ofSeconds(5))
is the form that takes the attempt count and delay.
Partitioning.partitionFor(key, partitions) is the same hash
the queue uses, for a test that wants to assert two keys land apart, or
for a dashboard that maps a customer to a queue.
Twelve is a number, not a recommendation. Partitions cannot be changed after declaration without breaking the mapping from key to queue, so pick a count above the concurrency you expect to need and leave it.
Routing slips
The route travels in the message rather than being known by each hop. Useful when the sequence of services differs per message — a claim that needs underwriting only above a threshold, say.
Itinerary route = Itinerary.empty()
.then("underwriting", "policy.underwrite")
.then("billing", "policy.bill")
.then("documents", "policy.issue");
mq.publisher("underwriting", "policy.underwrite", Policy.class)
.send(policy, Envelope.of("policy.submitted").header(Itinerary.HEADER, route.toHeader()).build());A step reads the slip, does its work, and forwards to the next stop:
@AceListener(queue = "underwriting.requests")
void underwrite(Message<Policy> message) {
underwriting.assess(message.payload());
Itinerary remaining = Itinerary.from(message.headers())
.orElse(Itinerary.empty())
.advance();
remaining.next().ifPresent(stop ->
mq.publisher(stop.exchange(), stop.routingKey(), Policy.class)
.send(message.payload(), message.envelope()
.causing("policy.underwritten")
.header(Itinerary.HEADER, remaining.toHeader())
.build()));
}Itinerary.from(headers) reads it back,
advance() moves past the current stop, next()
is where to send it, isFinished() says the route is done,
and done() is where it has been — which is the audit trail
the pattern is actually for.
This is the manual form and the only one there is: nothing in the library forwards a slip for you. A pipeline is the better answer when the sequence is fixed, because the chain is declared once and typed. A slip is for when the route genuinely varies per message, and the cost is that every step has the forwarding code above.
RoutingSlip is the same idea carried in three separate
headers rather than one JSON blob, and Envelope.route()
exposes it. Both exist because the family's libraries had to agree on a
wire format; Itinerary is the one to write new code
against.