Bridging .NET and Spring across a message broker
Two stacks, one broker, zero custom translation layer. AMQP on the producer side, JMS on the consumer side, and idempotency everywhere in between.
- Distributed Systems
- Messaging
- ActiveMQ
- AMQP
- JMS
- .NET
- Spring Boot
Bridging .NET and Spring across a message broker
#TL;DR
| Constraint | Choice |
|---|---|
| Producer | .NET — eligibility / validation service |
| Consumer | Spring Boot — fulfillment / ledger service |
| Broker | ActiveMQ Artemis (Dockerized), multi-protocol |
| Wire | AMQPS (.NET) → destination ← Jakarta JMS (Java) |
| Volume | 100K+ events/day |
| Latency | Sub-200ms (happy path) |
| Uptime | 99.99% |
Real-time cross-service handoff: upstream commits a domain event; downstream must apply it fast enough that balances and fulfillment stay consistent. Two mature codebases, two ecosystems, one latency budget.
I avoided HTTP callbacks with hand-rolled retries. A broker owns delivery semantics; consumers stay idempotent by design.
#Why not HTTP?
Synchronous HTTP looks tempting when both services already speak REST. For durable handoff across .NET and Java, the comparison usually flips once retries, poison messages, and backpressure enter the picture.
| Concern | HTTP callback | Message broker |
|---|---|---|
| Delivery guarantee | At-most-once unless you build retries | At-least-once by default |
| Backpressure | Timeouts, cascading failures | Queue buffering |
| Cross-stack contract | Custom REST + auth on both sides | Destination + message contract |
| Poison messages | Ad-hoc error handling | Dead-letter queues |
| Ops visibility | Per-integration logging | Queue depth, DLQ depth, lag |
HTTP fits synchronous request/response. This flow needed durable, asynchronous handoff — a fact that happened; process it fast, but without blocking the caller.
#Architecture
.NET producer
AMQPS · TLS 1.2+
ActiveMQ Artemis
multi-protocol · shared destination
Spring consumer
@JmsListener · idempotent handler
ActiveMQ Artemis speaks multiple protocols on one broker. .NET publishes with AMQP 1.0 over TLS. Java consumes with Jakarta JMS — idiomatic Spring, no .NET types on the classpath.
No polyglot translation microservice. Both sides agree on payload shape and metadata, not shared libraries.
#Producer (.NET / AMQPS)
Connection lifecycle: one long-lived connection, pooled sessions, cached senders per destination. TLS with broker CA pinned in config — not TrustServerCertificate=true in prod.
await using var connection = await factory.CreateConnectionAsync(cancellationToken);
await connection.StartAsync(cancellationToken);
await using var session = await connection.CreateSessionAsync(
Session.AcknowledgementMode.AutoAck, cancellationToken);
var sender = await session.CreateSenderAsync("events.domain.v1", cancellationToken);
var message = new Message(body)
{
MessageId = evt.Id,
CorrelationId = evt.CorrelationId,
ApplicationProperties =
{
["eventType"] = evt.Type,
["eventId"] = evt.Id,
["schemaVersion"] = "1",
},
};
await sender.SendAsync(message, cancellationToken);Producer invariants:
eventIdis UUID v4 — consumer dedup key, not business id alone- Payload is JSON with
schemaVersion— forward-compatible evolution - Send is fire-and-forget from caller's perspective — no blocking on downstream commit
#Consumer (Spring / JMS)
@JmsListener(
destination = "events.domain.v1",
containerFactory = "artemisListenerFactory")
public void onMessage(Message msg) throws JMSException {
String eventId = msg.getStringProperty("eventId");
if (idempotencyStore.seen(eventId)) {
msg.acknowledge();
return;
}
DomainEvent evt = mapper.read(msg);
ledgerService.apply(evt); // transactional side effect
idempotencyStore.record(eventId); // same DB transaction
msg.acknowledge(); // CLIENT_ACK after commit
}#Idempotency store schema
CREATE TABLE processed_events (
event_id UUID PRIMARY KEY,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ix_processed_events_at ON processed_events (processed_at);Retention job prunes rows older than N days — dedup window only needs to exceed max redelivery horizon.
#Ack mode matters
| Mode | Behavior | Use here? |
|---|---|---|
| AUTO_ACK | Broker acks on delivery | No — crash after delivery, before commit = lost work |
| CLIENT_ACK | Ack after handler succeeds | Yes |
| DUPS_OK | At-least-once, possible dup before ack | No — we dedup explicitly |
Never acknowledge before durable state is written. Ack after DB commit in the same unit of work.
#Dead-letter path
Poison messages (schema mismatch, bad business state) must not infinite-retry:
<!-- broker address-settings (illustrative) -->
<address-setting match="events.#">
<dead-letter-address>DLQ.events</dead-letter-address>
<max-delivery-attempts>5</max-delivery-attempts>
<redelivery-delay>1000</redelivery-delay>
<redelivery-delay-multiplier>2.0</redelivery-delay-multiplier>
</address-setting>Alert on DLQ depth > 0 — not on individual retries. Correlation ID in logs links producer span → consumer span.
#Operations that kept 99.99%
- Dockerized Artemis — same
broker.xml+ address settings in dev/stage/prod - Queue depth dashboards — consumer lag is the early warning, not error rate alone
- Correlation IDs —
correlationIdpropagated from HTTP ingress through message properties - Consumer concurrency —
concurrencytuned to partition count; avoid single-thread bottleneck at 100K+/day
Container restarts, network blips, and consumer redeploys are normal. The design assumes retries, not perfection.
#Lessons
- Brokers buy you semantics — at-least-once, backpressure, DLQ — that HTTP callbacks reinvent poorly.
- Protocol bridging is a feature — Artemis let .NET speak AMQP and Java speak JMS to the same destination.
- Idempotency keys belong in the message — store them in the same transaction as side effects.
- Measure end-to-end — producer send time means nothing if consumer queue depth grows unbounded.
Cross-stack integration doesn't require a third stack in the middle. It requires a clear contract, a broker that speaks both languages, and consumers that behave correctly when messages duplicate.
#Message property contract (producer → consumer)
Every cross-language event carried the same logical envelope — the .NET side wrote AMQP properties; the Java consumer read JMS headers:
| Field | Purpose |
|---|---|
correlationId | Tie HTTP request span to async handler logs |
idempotencyKey | Dedupe replays in the consumer DB transaction |
eventType | Versioned string (EnrollmentCompleted.v1) |
occurredAt | ISO timestamp for lag dashboards |
Consumers that ignored idempotencyKey were the source of duplicate enrollment rows during broker redelivery tests — not “lost messages.”
#When queue depth lied
During a consumer deploy in staging, error rate stayed flat while queue depth climbed for ten minutes. The Java pod had restarted with a lower concurrency than partition count; messages were not failing — they were waiting. That incident is why ops dashboards treat lag as the primary alert, not 5xx on the producer.
#Closing thought
When two language stacks must see the same event, standardize on broker semantics—at-least-once delivery, idempotent handlers, visible queue depth—not on ad hoc HTTP callbacks that retry without keys.
#Reader field guide
When this pattern applies: A .NET (or other) producer must notify a Java (or other) consumer without shared libraries; you need at-least-once delivery, backpressure, and DLQ visibility; the caller must not block on downstream commit. Skip the broker if the flow is strictly request/response with no durability requirement.
Operational checklist
- Publish with a stable
eventId(UUID) in message properties — consumer dedup key, not business id alone. - Use CLIENT_ACK (or equivalent) only after side effects commit in the same transaction as the idempotency row.
- Table
processed_events(or equivalent) with retention past max redelivery horizon. - Pin TLS and broker address settings (
dead-letter,max-delivery-attempts, backoff) in every environment — samebroker.xmlshape in dev and prod. - Alert on queue depth / lag, not producer 5xx alone — slow consumers look healthy until depth climbs.
- Propagate
correlationIdfrom HTTP ingress through message properties for cross-stack traces.
Integration choice
| Requirement | HTTP callback + custom retry | Message broker (AMQP ↔ JMS) |
|---|---|---|
| Caller must not wait on downstream | Awkward (async job elsewhere) | Natural |
| At-least-once without bespoke code | You build it | Built-in |
| Poison / bad schema handling | Per-integration hacks | DLQ + alerts |
| Cross-language shared types | DTO duplication + drift | JSON + schemaVersion |
| Ops signal under load | Per-endpoint 5xx | Queue depth, DLQ depth |
#Related reading
#On this site
| Post | Why |
|---|---|
| Cutting a data API from 21s to ~250ms | Synchronous OLTP path — this post is the async complement |
| Building a microfrontend platform for data products | Multiple UIs consuming the same fast APIs |
| Lessons from building a mobile events social platform | Mobile fan-out (FCM) vs broker-backed server events |
#References (curated)
When someone says “we need a queue,” I start with whether the consumer speaks AMQP 1.0 or JMS—the bridge cost is real.
| Reference | Notes |
|---|---|
| ActiveMQ Artemis documentation | Core protocol bridges, address settings, and consumer durability knobs. |
| AMQP 1.0 concepts | Wire-level model for external publishers (e.g. cloud AMQP endpoints). |
| Jakarta Messaging (JMS) API | What legacy Java services expect on the other side of the bridge. |