DEV Community

Cover image for Calling Services Over Azure Service Bus Without Writing a Client Layer
Justin
Justin

Posted on

Calling Services Over Azure Service Bus Without Writing a Client Layer

TL;DR

Azure Service Bus guarantees delivery between services, but it never writes the code that turns a delivered message into a typed method call with a result, and that layer is where request and reply calls go wrong under load. A missed session filter or a mismatched correlation field doesn't throw an error, it just looks like a slow caller. This piece covers what a correct Service Bus call requires by hand, where that work gets expensive, and how a transport plugin removes the client layer without changing anything about Service Bus itself.

Getting Started: What Azure Service Bus Handles and What It Leaves to You

Azure Service Bus is a managed message broker that sits between services, accepting a message from a producer and delivering it to a consumer without either side needing to be online at the same moment. It supports two core patterns: queues, where each message goes to exactly one consumer, and topics with subscriptions, where each message can go to several independent consumers at once.

Teams reach for Service Bus for the same reason they reach for any managed broker, reliable delivery, durability, and platform-handled ordering without running that infrastructure themselves. What Service Bus doesn't hand you is the code that turns a delivered message into an actual method call with a typed result. That part stays with whichever team is consuming the message, and it's easy to underestimate until a reply routes to the wrong caller or a failure disappears into a timeout with no clear cause.

The running example for this piece is a service that runs a physics calculation and returns a result. Several callers across a system need to invoke it, and each one needs its own reply to come back correctly, even when three or four calls are in flight on the same reply queue at once. Getting that right by hand is where most of this piece's complexity lives.

What Building a Correct Service Bus Call Requires

Getting a request and reply call working correctly over Service Bus means solving three separate problems, and it's easy to get the first two working in a demo and only discover the third one is broken once two callers hit the service at the same time. A request has to reach the right queue. A reply has to find its way back to the specific client that asked for it, not just whichever client happens to be listening on the reply queue. And a server-side failure has to reach the caller as something recognizable as a failure, not as a message that quietly never shows up.

That middle problem is where teams go wrong. Without a session strategy, two clients sharing a reply queue can end up reading each other's responses, or one client blocks while waiting for a reply that has already been delivered elsewhere. Solving it by hand looks roughly like this:

# Everything below exists only to get a reply back to the right caller; none of it is business logic
from azure.servicebus import ServiceBusClient, ServiceBusMessage
import uuid

def call_physics_calculator(payload: bytes) -> bytes:
    session_id = str(uuid.uuid4())
    with ServiceBusClient.from_connection_string(CONN_STR) as client:
        sender = client.get_queue_sender(queue_name="myqueue")
        receiver = client.get_queue_receiver(
            queue_name="myqueue.reply", session_id=session_id
        )
        message = ServiceBusMessage(payload)
        message.reply_to_session_id = session_id
        sender.send_messages(message)

        reply = receiver.receive_messages(max_wait_time=30)
        if not reply:
            raise TimeoutError("no response within timeout")
        return b"".join(reply[0].body)
Enter fullscreen mode Exit fullscreen mode

None of this describes what the physics calculation actually does. It's session setup, a reply filter, and a timeout, three things a team has to get right before any real logic runs, and get wrong silently if they don't.

Where the Integration Work Piles Up as Patterns Change

The shape of this work changes depending on how a service consumes Service Bus, but it doesn't get smaller:

  • A simple queue consumer needs SDK setup and message deserialization written by hand before any downstream call happens, and the consumer's expected schema can drift from what the producer actually sends with no build-time warning.
  • Request and reply over queues needs everything a simple consumer needs, plus session generation, a session-filtered receiver, and correlation matching. A missed session filter or a mismatched correlation field means a reply lands with the wrong caller, or never arrives at all.
  • Topics and subscriptions need SDK setup and deserialization repeated per subscription, so every subscription reimplements the same consumer logic even when the message shape is identical across all of them.

Request and reply is the expensive one of the three. Sessions exist so a reply queue can be shared safely across many callers at once, but that safety only holds if the session filter, the reply-to field, and the correlation match are all correct, and a mistake in any one of them doesn't throw an error. It just looks like latency.

The Cost of Writing This Layer by Hand

Backend teams commonly lose 30 to 40 percent of engineering time to integration code in general, HTTP clients, DTO drift, SDK versioning, and Service Bus consumers carry that same tax plus a few costs specific to the broker:

  • Session plumbing per caller: every service that needs request and reply correlation reimplements session generation and session-filtered receivers on its own, even when three other teams are calling the same downstream service.
  • Silent correlation bugs: a session or correlation mismatch doesn't throw an error, it just means a caller times out waiting for a reply that already went somewhere else.
  • Manual timeout and retry logic: every hand-written client ends up with its own version of this, rarely consistent with how any other caller of the same service handles it.
  • Schema drift above the broker: nothing enforces that a consumer's expected message shape still matches what the producer sends, the same problem REST has, just moved to sit above Service Bus instead of behind an endpoint.

None of this shows up on an architecture diagram. It shows up in how long it takes to debug a caller that's timing out for no visible reason, and in how many times different teams rewrite the same session-handling code for the same dependency.

Graftcode

A Transport Plugin That Handles Sessions and Correlation for You

Graftcode is a runtime-level integration platform, and its Azure Service Bus plugin lets a service call route through Service Bus without writing any session or correlation code by hand. The architecture underneath is the same three pieces Graftcode uses everywhere: a Graft, a strongly typed interface installed via a package manager that mirrors the provider's public methods, GraftConfig, an internal setting of each Graft that decides whether a call runs in process or through a configured transport, and the Graftcode Gateway, which runs and hosts the provider service itself.

On top of that, the plugin offers two modes, set purely by configuration, request and reply over queues for methods that return something, and one-way over a topic for methods that don't. Configuring the client side of a request and reply call looks like this in C#, the only language this configuration pattern is currently confirmed for:

graft.nuget.PhysicsCalculator.GraftConfig.SetConfig("""
{
  "configurations": {
    "graft.nuget.PhysicsCalculator": {
      "runtime": "netcore",
      "stateless": true,
      "plugin": {
        "name": "ServiceBusPlugin",
        "connectionString": "Endpoint=sb://<namespace>.servicebus.windows.net/;SharedAccessKeyName=<keyName>;SharedAccessKey=<key>",
        "queue": "myqueue",
        "replyQueue": "myqueue.reply",
        "rpcTimeoutMs": 30000
      }
    }
  }
}
""");
Enter fullscreen mode Exit fullscreen mode

Everything the earlier hand-written snippet did now happens inside the plugin. Session generation reuses the request's own correlation ID, and the reply receiver attaches a session filter scoped to that session, the same filter mechanism the official .NET and Java SDKs use. Matching is a little more forgiving than a single rigid field too, either the correlation ID or the message ID is accepted. A server-side failure reaches the caller the same way a local exception would, the response's first byte doubles as a sentinel, where a specific value means everything after it is an error message, thrown client-side as a native exception, and any other value means a normal response.

The one-way mode skips sessions and correlation entirely, since there's no reply expected. A client publishes to a topic and returns immediately, and the server consumes from a topic subscription without sending anything back. Multiple subscriptions on the same topic each receive their own independent copy of the message, this is native Service Bus pub and sub, not something built on top of the plugin.

How This Extends Your Existing Service Bus Setup

Adopting this plugin doesn't ask a team to change how it already uses Service Bus. Whatever namespace, session, and dead-lettering configuration already exists stays exactly as it is, and the plugin gets adopted per service pair, usually starting with whichever one currently has the most hand-written session and correlation code sitting on top of it.

It also doesn't touch anything Service Bus already does well, durability, ordering, and dead-lettering are unaffected. What it removes is the layer above that, client setup, session handling, correlation matching, and the deserialization a consumer would otherwise write by hand.

Local development splits into two genuinely separate options worth keeping apart:

  • Pointing at the official Service Bus emulator, which still runs something locally but removes the dependency on a live Azure namespace during testing.
  • Setting GraftConfig to in-process mode, which skips the broker entirely so the call never leaves the process, useful for fast logic iteration without needing an emulator or a namespace at all.

Here's what changes in practice between writing this layer by hand and letting a plugin handle it:

  • Session handling: manual session generation and a session-filtered receiver written per caller, versus session handling that lives inside the plugin.
  • Correlation: manual matching between request and reply, versus automatic matching with a documented fallback field.
  • Failure visibility: a dropped or mismatched reply that just looks like a timeout, versus an error that crosses the wire as a defined signal and throws as a native exception.
  • Multiple subscriptions: separate consumer code written per subscription even for identical message shapes, versus one configured plugin path regardless of subscription count.
  • Local testing: a real namespace or a manually configured emulator client, versus GraftConfig's in-process mode or the plugin's own emulator support.

This isn't a claim that the plugin removes coupling between services, that depends on how boundaries are drawn, not on what sits underneath a call. What it removes is the session, correlation, and client code that adds overhead on top of a service boundary that's already reasonably drawn.

Where Should You Start

Service Bus solves delivery well, durability, ordering, and dead-lettering are the broker's job, not the consuming service's. What it never solved was the code a team writes to turn a delivered message into a typed method call, and that code carries real session and correlation risk regardless of how reliable the underlying broker is. The pattern that keeps causing production incidents isn't a Service Bus limitation, it's a missing session filter or a mismatched correlation field that nobody notices until two callers collide on the same reply queue.

If your team is currently maintaining hand-written session and correlation code for a request and reply call over Service Bus, that's the specific place to start looking at whether a transport plugin would help, not a wholesale migration. Explore Graftcode to see how the Service Bus plugin fits your setup, or go to the Graftcode Academy for the full configuration reference and the other broker plugins currently available.

FAQs

Does the plugin change any of Service Bus's own delivery guarantees?

No. Durability, ordering, dead-lettering, and session-based delivery are all Service Bus features and stay exactly as configured. The plugin only removes the hand-written client, session, and correlation code that would otherwise sit above those guarantees.

How does a server-side exception actually reach the calling code?

The response payload carries a one-byte sentinel. A specific first-byte value means everything after it is read as an error message and thrown client-side as a native runtime exception. Any other first byte means a normal successful response, so error handling on the calling side looks the same as it would for a local method call.

Can a service use both request and reply and one-way calls at the same time?

Yes. The mode is chosen per configuration, not system-wide. One service call can be configured for request and reply over queues while another is configured for one-way over a topic, depending on whether each method returns a value.

Is there a message size limit to plan around?

Yes. Azure Service Bus caps standard-tier messages at 256 KB and premium-tier messages considerably higher, and the plugin enforces its own ceiling around 1 MB in its current source. A method returning a large object can hit this in a way it wouldn't over a direct connection, so a large payload either needs a higher tier or should stay on a direct-connection transport instead.

What's the practical difference between a Service Bus queue and a topic?

A queue delivers each message to exactly one consumer, which is what request-and-reply calls use. A topic delivers each message to every subscription attached to it, so multiple independent consumers can each get their own copy of the same message. That's why one-way, fire-and-forget calls use a topic and request and reply calls use a queue.

Does adopting a transport plugin mean migrating everything onto Service Bus?

No. It's built to extend an existing setup incrementally. A team can configure it for a single service pair where session and correlation overhead is highest, while every other service pair keeps running however it already does. You don't need to migrate an entire system to adopt it for one call.

Top comments (0)