7 min readCase study

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

ConstraintChoice
Producer.NET — eligibility / validation service
ConsumerSpring Boot — fulfillment / ledger service
BrokerActiveMQ Artemis (Dockerized), multi-protocol
WireAMQPS (.NET) → destination ← Jakarta JMS (Java)
Volume100K+ events/day
LatencySub-200ms (happy path)
Uptime99.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.

ConcernHTTP callbackMessage broker
Delivery guaranteeAt-most-once unless you build retriesAt-least-once by default
BackpressureTimeouts, cascading failuresQueue buffering
Cross-stack contractCustom REST + auth on both sidesDestination + message contract
Poison messagesAd-hoc error handlingDead-letter queues
Ops visibilityPer-integration loggingQueue 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.

C#
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:

  • eventId is 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)

java
@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

SQL
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

ModeBehaviorUse here?
AUTO_ACKBroker acks on deliveryNo — crash after delivery, before commit = lost work
CLIENT_ACKAck after handler succeedsYes
DUPS_OKAt-least-once, possible dup before ackNo — 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:

XML
<!-- 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 IDscorrelationId propagated from HTTP ingress through message properties
  • Consumer concurrencyconcurrency tuned 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

  1. Brokers buy you semantics — at-least-once, backpressure, DLQ — that HTTP callbacks reinvent poorly.
  2. Protocol bridging is a feature — Artemis let .NET speak AMQP and Java speak JMS to the same destination.
  3. Idempotency keys belong in the message — store them in the same transaction as side effects.
  4. 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:

FieldPurpose
correlationIdTie HTTP request span to async handler logs
idempotencyKeyDedupe replays in the consumer DB transaction
eventTypeVersioned string (EnrollmentCompleted.v1)
occurredAtISO 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 — same broker.xml shape in dev and prod.
  • Alert on queue depth / lag, not producer 5xx alone — slow consumers look healthy until depth climbs.
  • Propagate correlationId from HTTP ingress through message properties for cross-stack traces.

Integration choice

RequirementHTTP callback + custom retryMessage broker (AMQP ↔ JMS)
Caller must not wait on downstreamAwkward (async job elsewhere)Natural
At-least-once without bespoke codeYou build itBuilt-in
Poison / bad schema handlingPer-integration hacksDLQ + alerts
Cross-language shared typesDTO duplication + driftJSON + schemaVersion
Ops signal under loadPer-endpoint 5xxQueue depth, DLQ depth

#On this site

PostWhy
Cutting a data API from 21s to ~250msSynchronous OLTP path — this post is the async complement
Building a microfrontend platform for data productsMultiple UIs consuming the same fast APIs
Lessons from building a mobile events social platformMobile 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.

ReferenceNotes
ActiveMQ Artemis documentationCore protocol bridges, address settings, and consumer durability knobs.
AMQP 1.0 conceptsWire-level model for external publishers (e.g. cloud AMQP endpoints).
Jakarta Messaging (JMS) APIWhat legacy Java services expect on the other side of the bridge.