4 min read

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%

The business problem: real-time cross-service handoff — when an upstream service commits a domain event, a downstream service must apply it immediately so balances and fulfillment stay consistent. Two mature codebases, two ecosystems, one latency budget.

The trap is HTTP callbacks with hand-rolled retries. The better answer: let a broker own delivery semantics, and make consumers idempotent by design.


#Why not HTTP?

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.