Custom Transports¶
Verified by tests
ITransportTests, TransportCapabilitiesTests, SubscribeBatchTests, ISubscriptionTests, InProcessTransportTests, TransportDestinationTests, ITransportSubscribeToDeadLetterAsyncDefaultTests — library CI run #31657041675 (2026-08-13)
Custom transports enable Whizbang to work with any messaging system by implementing the ITransport interface. Support HTTP, gRPC, Kafka, RabbitMQ, NATS, or any custom communication protocol.
:::note For built-in transports, see Azure Service Bus and In-Memory. This guide focuses on implementing custom transport backends. :::
Why Custom Transports?¶
Built-in transports cover common scenarios, but custom transports enable:
| Scenario | Built-In Transport | Custom Transport |
|---|---|---|
| Azure Service Bus | ✅ Built-in (Whizbang.Transports.AzureServiceBus) |
No customization needed |
| RabbitMQ | ✅ Built-in (Whizbang.Transports.RabbitMQ) |
No customization needed |
| In-Process (Testing) | ✅ Built-in (InProcessTransport) |
No customization needed |
| HTTP/REST APIs | ❌ Not included | ✅ HTTP client transport |
| gRPC | ❌ Not included | ✅ gRPC channel transport |
| Kafka | ❌ Not included | ✅ Kafka producer/consumer |
| NATS | ❌ Not included | ✅ NATS client transport |
| Redis Pub/Sub | ❌ Not included | ✅ Redis channel transport |
When to implement custom transport: - ✅ Existing messaging infrastructure (Kafka, NATS) - ✅ HTTP/gRPC microservices - ✅ Legacy systems integration - ✅ Custom protocols (IoT, WebSockets) - ✅ Multi-cloud deployments
Architecture¶
ITransport Interface¶
ITransport Interface
namespace Whizbang.Core.Transports;
/// <summary>
/// A deserialized transport message ready for batch processing.
/// Value type to avoid heap allocations when batching many messages.
/// </summary>
public readonly record struct TransportMessage(
IMessageEnvelope Envelope,
string? EnvelopeType
);
public interface ITransport {
/// <summary>
/// Whether transport is initialized and ready.
/// </summary>
bool IsInitialized { get; }
/// <summary>
/// Initialize transport and verify connectivity.
/// Idempotent - safe to call multiple times.
/// </summary>
Task InitializeAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Cheap, non-throwing connectivity signal for the managed-resource health
/// model. Default returns IsInitialized; transports holding a live connection
/// handle override to detect a post-initialization drop.
/// </summary>
ValueTask<bool> CheckConnectivityAsync(CancellationToken cancellationToken = default)
=> ValueTask.FromResult(IsInitialized);
/// <summary>
/// Capabilities this transport supports.
/// </summary>
TransportCapabilities Capabilities { get; }
/// <summary>
/// Maximum per-message wire size in bytes; null means no enforced limit.
/// Size-aware strategies (composite events, body offload) read this
/// pre-flight to decide inline send vs. claim-check offload.
/// Default implementation returns null.
/// </summary>
long? MaxMessageSizeBytes => null;
/// <summary>
/// Publish message (fire-and-forget).
/// When preSerializedBytes is set, wire transports MUST use those bytes
/// and skip their internal serialization.
/// </summary>
Task PublishAsync(
IMessageEnvelope envelope,
TransportDestination destination,
string? envelopeType = null,
ReadOnlyMemory<byte>? preSerializedBytes = null,
CancellationToken cancellationToken = default
);
/// <summary>
/// Subscribe with transport-level batch collection. The transport collects
/// incoming messages into batches and invokes the handler once per batch
/// (size reached, sliding window timeout, or hard max timeout).
/// </summary>
Task<ISubscription> SubscribeBatchAsync(
Func<IReadOnlyList<TransportMessage>, CancellationToken, Task> batchHandler,
TransportDestination destination,
TransportBatchOptions batchOptions,
CancellationToken cancellationToken = default
);
/// <summary>
/// Push subscription on the broker's dead-letter queue/subqueue.
/// Default implementation throws NotSupportedException; the
/// TransportDeadLetterDrainWorker falls back to polling.
/// </summary>
Task<ISubscription> SubscribeToDeadLetterAsync(
Func<TransportMessage, CancellationToken, Task> handler,
TransportDestination destination,
CancellationToken cancellationToken = default
) => throw new NotSupportedException(/* ... */);
/// <summary>
/// Send request and wait for response (request/response pattern).
/// Only supported if Capabilities includes RequestResponse.
/// </summary>
Task<IMessageEnvelope> SendAsync<TRequest, TResponse>(
IMessageEnvelope requestEnvelope,
TransportDestination destination,
CancellationToken cancellationToken = default
) where TRequest : notnull where TResponse : notnull;
/// <summary>
/// Publish a batch of messages to the same destination in one operation.
/// Default implementation throws NotSupportedException - check
/// Capabilities.HasFlag(TransportCapabilities.BulkPublish) first.
/// </summary>
Task<IReadOnlyList<BulkPublishItemResult>> PublishBatchAsync(
IReadOnlyList<BulkPublishItem> items,
TransportDestination destination,
CancellationToken cancellationToken = default
) => throw new NotSupportedException(/* ... */);
}
:::note
SubscribeToDeadLetterAsync, PublishBatchAsync, MaxMessageSizeBytes, and CheckConnectivityAsync have default interface implementations — a minimal custom transport only implements IsInitialized, InitializeAsync, Capabilities, PublishAsync, SubscribeBatchAsync, and SendAsync.
:::
Transport Capabilities¶
Transport Capabilities
[Flags]
public enum TransportCapabilities {
None = 0,
RequestResponse = 1 << 0, // Send/Receive (HTTP, gRPC)
PublishSubscribe = 1 << 1, // Pub/Sub (Kafka, Service Bus)
Streaming = 1 << 2, // IAsyncEnumerable streaming
Reliable = 1 << 3, // At-least-once delivery
Ordered = 1 << 4, // FIFO ordering within a stream/partition
ExactlyOnce = 1 << 5, // Exactly-once semantics (requires Inbox/Outbox dedup)
BulkPublish = 1 << 6, // Multiple messages in a single transport operation
All = RequestResponse | PublishSubscribe | Streaming | Reliable | Ordered | ExactlyOnce | BulkPublish
}
Example Capability Declarations:
| Transport | Capabilities |
|---|---|
| HTTP (custom) | RequestResponse |
| gRPC (custom) | RequestResponse \| Streaming |
| Kafka (custom) | PublishSubscribe \| Reliable \| Ordered |
| In-Process (built-in) | RequestResponse \| PublishSubscribe \| Ordered \| Reliable |
| RabbitMQ (built-in) | PublishSubscribe \| Reliable \| BulkPublish (+ Ordered when single-active-consumer is enabled) |
| Azure Service Bus (built-in) | PublishSubscribe \| Reliable \| BulkPublish (+ Ordered when sessions are enabled) |
HTTP Transport Implementation¶
Pattern 1: HTTP Client Transport¶
Use Case: Call remote HTTP APIs using Whizbang message patterns.
Pattern 1: HTTP Client Transport
using Whizbang.Core;
using Whizbang.Core.Transports;
using System.Net.Http.Json;
using System.Text.Json;
public class HttpTransport : ITransport {
private readonly HttpClient _http;
private readonly JsonSerializerOptions _jsonOptions;
private readonly ILogger<HttpTransport> _logger;
private bool _isInitialized;
public HttpTransport(
HttpClient http,
JsonSerializerOptions jsonOptions,
ILogger<HttpTransport> logger
) {
_http = http;
_jsonOptions = jsonOptions;
_logger = logger;
}
public bool IsInitialized => _isInitialized;
public TransportCapabilities Capabilities =>
TransportCapabilities.RequestResponse; // HTTP supports request/response only
public async Task InitializeAsync(CancellationToken ct = default) {
// Verify HTTP endpoint is reachable
try {
var healthCheck = await _http.GetAsync("/health", ct);
healthCheck.EnsureSuccessStatusCode();
_isInitialized = true;
_logger.LogInformation("HTTP transport initialized successfully");
} catch (HttpRequestException ex) {
throw new InvalidOperationException("HTTP transport initialization failed", ex);
}
}
public Task PublishAsync(
IMessageEnvelope envelope,
TransportDestination destination,
string? envelopeType = null,
ReadOnlyMemory<byte>? preSerializedBytes = null,
CancellationToken cancellationToken = default
) {
throw new NotSupportedException("HTTP transport does not support publish (use SendAsync instead)");
}
public Task<ISubscription> SubscribeBatchAsync(
Func<IReadOnlyList<TransportMessage>, CancellationToken, Task> batchHandler,
TransportDestination destination,
TransportBatchOptions batchOptions,
CancellationToken cancellationToken = default
) {
throw new NotSupportedException("HTTP transport does not support subscribe (use polling or webhooks)");
}
public async Task<IMessageEnvelope> SendAsync<TRequest, TResponse>(
IMessageEnvelope requestEnvelope,
TransportDestination destination,
CancellationToken ct = default
) where TRequest : notnull where TResponse : notnull {
// Serialize request envelope
var envelopeType = requestEnvelope.GetType();
var typeInfo = _jsonOptions.GetTypeInfo(envelopeType)
?? throw new InvalidOperationException($"No JsonTypeInfo for {envelopeType.Name}");
// POST envelope to remote endpoint
var response = await _http.PostAsJsonAsync(
destination.Address, // e.g., "https://api.example.com/orders/create"
requestEnvelope,
typeInfo,
ct
);
response.EnsureSuccessStatusCode();
// Deserialize response envelope
var responseEnvelopeType = typeof(MessageEnvelope<TResponse>);
var responseTypeInfo = _jsonOptions.GetTypeInfo(responseEnvelopeType)
?? throw new InvalidOperationException($"No JsonTypeInfo for {responseEnvelopeType.Name}");
var responseEnvelope = await response.Content.ReadFromJsonAsync(responseTypeInfo, ct)
as IMessageEnvelope;
if (responseEnvelope is null) {
throw new InvalidOperationException("Failed to deserialize response envelope");
}
_logger.LogInformation(
"HTTP request sent to {Address}, received response {MessageId}",
destination.Address,
responseEnvelope.MessageId
);
return responseEnvelope;
}
}
Registration: Pattern 1: HTTP Client Transport (2)
builder.Services.AddHttpClient<HttpTransport>(client => {
client.BaseAddress = new Uri("https://api.example.com");
client.Timeout = TimeSpan.FromSeconds(30);
});
builder.Services.AddSingleton<ITransport, HttpTransport>();
Usage: Pattern 1: HTTP Client Transport (3)
var request = new MessageEnvelope<CreateOrder> {
MessageId = MessageId.New(),
DispatchContext = new MessageDispatchContext {
Mode = DispatchModes.Local,
Source = MessageSource.Local
},
Payload = new CreateOrder(orderId, customerId, items),
Hops = [
new MessageHop {
ServiceInstance = new ServiceInstanceInfo {
ServiceName = "order-api",
InstanceId = Guid.NewGuid(),
HostName = Environment.MachineName,
ProcessId = Environment.ProcessId
},
CorrelationId = CorrelationId.New()
}
]
};
var destination = new TransportDestination(Address: "/orders/create");
var response = await transport.SendAsync<CreateOrder, OrderCreated>(request, destination);
gRPC Transport Implementation¶
Pattern 2: gRPC Channel Transport¶
Use Case: High-performance RPC with streaming support.
Pattern 2: gRPC Channel Transport
using Whizbang.Core;
using Whizbang.Core.Transports;
using Grpc.Net.Client;
using System.Text.Json;
public class GrpcTransport : ITransport {
private readonly GrpcChannel _channel;
private readonly JsonSerializerOptions _jsonOptions;
private readonly ILogger<GrpcTransport> _logger;
private bool _isInitialized;
public GrpcTransport(
GrpcChannel channel,
JsonSerializerOptions jsonOptions,
ILogger<GrpcTransport> logger
) {
_channel = channel;
_jsonOptions = jsonOptions;
_logger = logger;
}
public bool IsInitialized => _isInitialized;
public TransportCapabilities Capabilities =>
TransportCapabilities.RequestResponse |
TransportCapabilities.Streaming; // gRPC supports both
public async Task InitializeAsync(CancellationToken ct = default) {
// Verify gRPC channel is connected
await _channel.ConnectAsync(ct);
_isInitialized = true;
_logger.LogInformation(
"gRPC transport initialized for {Target}",
_channel.Target
);
}
public async Task<IMessageEnvelope> SendAsync<TRequest, TResponse>(
IMessageEnvelope requestEnvelope,
TransportDestination destination,
CancellationToken ct = default
) where TRequest : notnull where TResponse : notnull {
// Create gRPC client for service
var client = CreateClient(destination.Address); // e.g., "OrderService"
// Serialize request envelope to protobuf/JSON
var request = SerializeEnvelope(requestEnvelope);
// Invoke gRPC method
var response = await client.ProcessMessageAsync(request, cancellationToken: ct);
// Deserialize response envelope
var responseEnvelope = DeserializeEnvelope<TResponse>(response);
_logger.LogInformation(
"gRPC request sent to {Service}, method {Method}",
destination.Address,
destination.RoutingKey
);
return responseEnvelope;
}
// Simplified for example - actual implementation depends on protobuf schema
private dynamic CreateClient(string serviceName) {
// Use reflection or code generation to create gRPC client
// e.g., var client = new OrderService.OrderServiceClient(_channel);
throw new NotImplementedException("gRPC client creation");
}
private object SerializeEnvelope(IMessageEnvelope envelope) {
// Convert MessageEnvelope to protobuf message
throw new NotImplementedException("Protobuf serialization");
}
private IMessageEnvelope DeserializeEnvelope<T>(object response) {
// Convert protobuf message to MessageEnvelope<T>
throw new NotImplementedException("Protobuf deserialization");
}
public Task PublishAsync(
IMessageEnvelope envelope,
TransportDestination destination,
string? envelopeType = null,
ReadOnlyMemory<byte>? preSerializedBytes = null,
CancellationToken cancellationToken = default
) {
throw new NotSupportedException("gRPC transport is request/response only (use streaming for pub/sub)");
}
public Task<ISubscription> SubscribeBatchAsync(
Func<IReadOnlyList<TransportMessage>, CancellationToken, Task> batchHandler,
TransportDestination destination,
TransportBatchOptions batchOptions,
CancellationToken cancellationToken = default
) {
// For streaming gRPC, implement server-side streaming subscription
throw new NotImplementedException("gRPC streaming subscription");
}
}
Kafka Transport Implementation¶
Pattern 3: Kafka Producer/Consumer Transport¶
Use Case: High-throughput event streaming with ordering and persistence.
Pattern 3: Kafka Producer/Consumer Transport
using Whizbang.Core;
using Whizbang.Core.Transports;
using Confluent.Kafka;
using System.Text.Json;
public class KafkaTransport : ITransport {
private readonly IProducer<string, string> _producer;
private readonly ConsumerConfig _consumerConfig;
private readonly JsonSerializerOptions _jsonOptions;
private readonly ILogger<KafkaTransport> _logger;
private bool _isInitialized;
public KafkaTransport(
ProducerConfig producerConfig,
ConsumerConfig consumerConfig,
JsonSerializerOptions jsonOptions,
ILogger<KafkaTransport> logger
) {
_producer = new ProducerBuilder<string, string>(producerConfig).Build();
_consumerConfig = consumerConfig;
_jsonOptions = jsonOptions;
_logger = logger;
}
public bool IsInitialized => _isInitialized;
public TransportCapabilities Capabilities =>
TransportCapabilities.PublishSubscribe |
TransportCapabilities.Reliable |
TransportCapabilities.Ordered; // Kafka guarantees within partitions
public async Task InitializeAsync(CancellationToken ct = default) {
// Verify Kafka cluster is reachable
try {
// Produce a test message to verify connectivity
var metadata = _producer.GetMetadata(TimeSpan.FromSeconds(5));
_isInitialized = true;
_logger.LogInformation(
"Kafka transport initialized, connected to {BrokerCount} brokers",
metadata.Brokers.Count
);
} catch (KafkaException ex) {
throw new InvalidOperationException("Kafka transport initialization failed", ex);
}
}
public async Task PublishAsync(
IMessageEnvelope envelope,
TransportDestination destination,
string? envelopeType = null,
ReadOnlyMemory<byte>? preSerializedBytes = null,
CancellationToken ct = default
) {
// Honor the pre-serialized bytes hint when upstream already serialized once
string json;
var clrEnvelopeType = envelope.GetType();
var typeName = envelopeType ?? clrEnvelopeType.AssemblyQualifiedName!;
if (preSerializedBytes is { } bytes) {
json = System.Text.Encoding.UTF8.GetString(bytes.Span);
} else {
var typeInfo = _jsonOptions.GetTypeInfo(clrEnvelopeType)
?? throw new InvalidOperationException($"No JsonTypeInfo for {clrEnvelopeType.Name}");
json = JsonSerializer.Serialize(envelope, typeInfo);
}
// Create Kafka message
var message = new Message<string, string> {
Key = envelope.GetCurrentStreamId() ?? envelope.MessageId.Value.ToString(), // Partition by stream
Value = json,
Headers = new Headers {
{ "MessageId", System.Text.Encoding.UTF8.GetBytes(envelope.MessageId.Value.ToString()) },
{ "CorrelationId", System.Text.Encoding.UTF8.GetBytes(envelope.GetCorrelationId()?.ToString() ?? "") },
{ "EnvelopeType", System.Text.Encoding.UTF8.GetBytes(typeName) }
}
};
// Publish to topic
var result = await _producer.ProduceAsync(
destination.Address, // Kafka topic name
message,
ct
);
_logger.LogInformation(
"Published message {MessageId} to Kafka topic {Topic}, partition {Partition}, offset {Offset}",
envelope.MessageId,
destination.Address,
result.Partition.Value,
result.Offset.Value
);
}
public Task<ISubscription> SubscribeBatchAsync(
Func<IReadOnlyList<TransportMessage>, CancellationToken, Task> batchHandler,
TransportDestination destination,
TransportBatchOptions batchOptions,
CancellationToken ct = default
) {
// Create Kafka consumer
var consumer = new ConsumerBuilder<string, string>(_consumerConfig).Build();
// Subscribe to topic
consumer.Subscribe(destination.Address); // Kafka topic name
_logger.LogInformation(
"Subscribed to Kafka topic {Topic}, consumer group {ConsumerGroup}",
destination.Address,
_consumerConfig.GroupId
);
// Background task to consume messages in batches
var consumeTask = Task.Run(async () => {
var batch = new List<TransportMessage>(batchOptions.BatchSize);
var results = new List<ConsumeResult<string, string>>(batchOptions.BatchSize);
try {
while (!ct.IsCancellationRequested) {
// Collect a batch: flush on size, or on SlideMs of no new messages
// (production code also honors MaxWaitMs as a hard ceiling)
var consumeResult = consumer.Consume(TimeSpan.FromMilliseconds(batchOptions.SlideMs));
if (consumeResult?.Message != null) {
try {
// Deserialize envelope
var envelopeTypeName = System.Text.Encoding.UTF8.GetString(
consumeResult.Message.Headers.GetLastBytes("EnvelopeType")
);
var envelopeType = Type.GetType(envelopeTypeName)
?? throw new InvalidOperationException($"Unknown envelope type: {envelopeTypeName}");
var typeInfo = _jsonOptions.GetTypeInfo(envelopeType)
?? throw new InvalidOperationException($"No JsonTypeInfo for {envelopeType.Name}");
var envelope = JsonSerializer.Deserialize(
consumeResult.Message.Value,
typeInfo
) as IMessageEnvelope
?? throw new InvalidOperationException("Failed to deserialize envelope");
batch.Add(new TransportMessage(envelope, envelopeTypeName));
results.Add(consumeResult);
} catch (Exception ex) {
_logger.LogError(
ex,
"Error deserializing Kafka message from topic {Topic}, partition {Partition}, offset {Offset}",
consumeResult.Topic,
consumeResult.Partition.Value,
consumeResult.Offset.Value
);
// Don't commit - message will be retried
}
}
// Flush when batch is full, or when the sliding window elapsed with a partial batch
var windowElapsed = consumeResult?.Message == null && batch.Count > 0;
if (batch.Count >= batchOptions.BatchSize || windowElapsed) {
// Invoke batch handler once per batch
await batchHandler(batch, ct);
// Commit offsets after successful batch processing
foreach (var result in results) {
consumer.Commit(result);
}
_logger.LogDebug("Processed Kafka batch of {Count} messages", batch.Count);
batch.Clear();
results.Clear();
}
}
} catch (OperationCanceledException) {
// Expected on shutdown
} finally {
consumer.Close();
}
}, ct);
return Task.FromResult<ISubscription>(new KafkaSubscription(consumer, consumeTask));
}
public Task<IMessageEnvelope> SendAsync<TRequest, TResponse>(
IMessageEnvelope requestEnvelope,
TransportDestination destination,
CancellationToken ct = default
) where TRequest : notnull where TResponse : notnull {
throw new NotSupportedException("Kafka transport does not support request/response (use pub/sub instead)");
}
}
/// <summary>
/// Subscription handle for Kafka consumer.
/// ISubscription is IDisposable with pause/resume control and an
/// OnDisconnected event for reconnection triggers.
/// </summary>
internal class KafkaSubscription : ISubscription {
private readonly IConsumer<string, string> _consumer;
private readonly Task _consumeTask;
private volatile bool _isActive = true;
private bool _disposed;
public KafkaSubscription(IConsumer<string, string> consumer, Task consumeTask) {
_consumer = consumer;
_consumeTask = consumeTask;
}
public event EventHandler<SubscriptionDisconnectedEventArgs>? OnDisconnected;
public bool IsActive => _isActive;
public Task PauseAsync() {
// Pause delivery (e.g., consumer.Pause(consumer.Assignment))
_isActive = false;
return Task.CompletedTask;
}
public Task ResumeAsync() {
// Resume delivery (e.g., consumer.Resume(consumer.Assignment))
_isActive = true;
return Task.CompletedTask;
}
public void Dispose() {
if (_disposed) return;
_disposed = true;
// Stop consuming and leave the group
_consumer.Close();
_consumer.Dispose();
OnDisconnected?.Invoke(this, new SubscriptionDisconnectedEventArgs {
Reason = "Disposed",
IsApplicationInitiated = true
});
}
}
Registration: Pattern 3: Kafka Producer/Consumer Transport (2)
var producerConfig = new ProducerConfig {
BootstrapServers = "localhost:9092",
Acks = Acks.All, // Wait for all replicas
EnableIdempotence = true // Exactly-once producer
};
var consumerConfig = new ConsumerConfig {
BootstrapServers = "localhost:9092",
GroupId = "whizbang-consumer-group",
AutoOffsetReset = AutoOffsetReset.Earliest,
EnableAutoCommit = false // Manual commit after processing
};
builder.Services.AddSingleton<ITransport>(sp =>
new KafkaTransport(
producerConfig,
consumerConfig,
sp.GetRequiredService<JsonSerializerOptions>(),
sp.GetRequiredService<ILogger<KafkaTransport>>()
)
);
Advanced Patterns¶
Pattern 4: Transport with Health Checks¶
Pattern 4: Transport with Health Checks
using Whizbang.Core.Transports;
using Microsoft.Extensions.Diagnostics.HealthChecks;
public class KafkaTransportHealthCheck : IHealthCheck {
private readonly KafkaTransport _transport;
public KafkaTransportHealthCheck(ITransport transport) {
_transport = (KafkaTransport)transport;
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken ct = default
) {
if (!_transport.IsInitialized) {
return HealthCheckResult.Unhealthy("Kafka transport not initialized");
}
try {
// Verify producer is healthy
var metadata = _transport.GetMetadata(TimeSpan.FromSeconds(2));
var brokerCount = metadata.Brokers.Count;
if (brokerCount == 0) {
return HealthCheckResult.Degraded("No Kafka brokers available");
}
return HealthCheckResult.Healthy($"Kafka transport healthy, {brokerCount} brokers connected");
} catch (KafkaException ex) {
return HealthCheckResult.Unhealthy("Kafka transport unhealthy", ex);
}
}
}
// Registration
builder.Services.AddHealthChecks()
.AddCheck<KafkaTransportHealthCheck>("kafka_transport");
Pattern 5: Batching Transport (High Throughput)¶
Pattern 5: Batching Transport (High Throughput)
using Whizbang.Core.Transports;
using System.Threading.Channels;
public class BatchingTransport : ITransport {
private readonly ITransport _innerTransport;
private readonly Channel<(IMessageEnvelope, TransportDestination)> _queue;
private readonly Task _batchProcessor;
private readonly CancellationTokenSource _cts;
private const int BatchSize = 100;
private static readonly TimeSpan BatchTimeout = TimeSpan.FromMilliseconds(100);
public BatchingTransport(ITransport innerTransport) {
_innerTransport = innerTransport;
_queue = Channel.CreateBounded<(IMessageEnvelope, TransportDestination)>(10000);
_cts = new CancellationTokenSource();
_batchProcessor = Task.Run(() => ProcessBatchesAsync(_cts.Token));
}
public bool IsInitialized => _innerTransport.IsInitialized;
public TransportCapabilities Capabilities => _innerTransport.Capabilities;
public Task InitializeAsync(CancellationToken ct = default) =>
_innerTransport.InitializeAsync(ct);
public async Task PublishAsync(
IMessageEnvelope envelope,
TransportDestination destination,
string? envelopeType = null,
ReadOnlyMemory<byte>? preSerializedBytes = null,
CancellationToken cancellationToken = default
) {
// Queue message for batching
await _queue.Writer.WriteAsync((envelope, destination), cancellationToken);
}
private async Task ProcessBatchesAsync(CancellationToken ct) {
var batch = new List<(IMessageEnvelope, TransportDestination)>(BatchSize);
while (!ct.IsCancellationRequested) {
// Collect batch
while (batch.Count < BatchSize) {
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(BatchTimeout);
try {
var item = await _queue.Reader.ReadAsync(timeoutCts.Token);
batch.Add(item);
} catch (OperationCanceledException) {
break; // Timeout or cancellation
}
}
// Publish batch in parallel
if (batch.Count > 0) {
await Task.WhenAll(
batch.Select(item =>
_innerTransport.PublishAsync(item.Item1, item.Item2, ct)
)
);
batch.Clear();
}
}
}
// Other ITransport methods delegate to _innerTransport
public Task<ISubscription> SubscribeBatchAsync(
Func<IReadOnlyList<TransportMessage>, CancellationToken, Task> batchHandler,
TransportDestination destination,
TransportBatchOptions batchOptions,
CancellationToken cancellationToken = default
) => _innerTransport.SubscribeBatchAsync(batchHandler, destination, batchOptions, cancellationToken);
public Task<IMessageEnvelope> SendAsync<TRequest, TResponse>(
IMessageEnvelope requestEnvelope,
TransportDestination destination,
CancellationToken cancellationToken = default
) where TRequest : notnull where TResponse : notnull =>
_innerTransport.SendAsync<TRequest, TResponse>(requestEnvelope, destination, cancellationToken);
}
:::note
If the underlying broker supports batched sends natively, prefer declaring the TransportCapabilities.BulkPublish capability and implementing PublishBatchAsync instead of a wrapper — that is how the built-in RabbitMQ and Azure Service Bus transports batch outbox publishes.
:::
Usage: Pattern 5: Batching Transport (High Throughput) (2)
// Wrap existing transport with batching
var kafkaTransport = new KafkaTransport(...);
var batchingTransport = new BatchingTransport(kafkaTransport);
builder.Services.AddSingleton<ITransport>(batchingTransport);
Benefits: - 10x Throughput: Batch 100 messages in single Kafka produce call - Lower Latency: Parallel publishing within batch - Backpressure: Bounded channel prevents memory issues
Testing Custom Transports¶
Testing Initialization¶
Testing Initialization
public class KafkaTransportTests {
[Test]
public async Task InitializeAsync_ValidBroker_SucceedsAsync() {
// Arrange
var producerConfig = new ProducerConfig { BootstrapServers = "localhost:9092" };
var consumerConfig = new ConsumerConfig { BootstrapServers = "localhost:9092", GroupId = "test" };
var jsonOptions = JsonContextRegistry.CreateCombinedOptions();
var logger = new NullLogger<KafkaTransport>();
var transport = new KafkaTransport(producerConfig, consumerConfig, jsonOptions, logger);
// Act
await transport.InitializeAsync();
// Assert
await Assert.That(transport.IsInitialized).IsTrue();
}
[Test]
public async Task InitializeAsync_InvalidBroker_ThrowsAsync() {
// Arrange
var producerConfig = new ProducerConfig { BootstrapServers = "invalid:9092" };
var consumerConfig = new ConsumerConfig { BootstrapServers = "invalid:9092", GroupId = "test" };
var jsonOptions = JsonContextRegistry.CreateCombinedOptions();
var logger = new NullLogger<KafkaTransport>();
var transport = new KafkaTransport(producerConfig, consumerConfig, jsonOptions, logger);
// Act & Assert
await Assert.That(async () => await transport.InitializeAsync())
.ThrowsException<InvalidOperationException>()
.WithMessage("Kafka transport initialization failed");
}
}
Testing Publish/Subscribe¶
Testing Publish/Subscribe
public class KafkaTransportIntegrationTests {
[Test]
public async Task PublishAndSubscribe_MessageReceivedAsync() {
// Arrange
var transport = CreateKafkaTransport();
await transport.InitializeAsync();
var receivedEnvelope = default(IMessageEnvelope);
var messageReceived = new TaskCompletionSource<bool>();
var destination = new TransportDestination(Address: "test-topic");
// Subscribe (batch handler - invoked once per batch, not per message)
await transport.SubscribeBatchAsync(
batchHandler: (batch, ct) => {
receivedEnvelope = batch[0].Envelope;
messageReceived.SetResult(true);
return Task.CompletedTask;
},
destination: destination,
batchOptions: new TransportBatchOptions { BatchSize = 1 }
);
// Act - Publish
var envelope = new MessageEnvelope<TestMessage> {
MessageId = MessageId.New(),
DispatchContext = new MessageDispatchContext {
Mode = DispatchModes.Local,
Source = MessageSource.Local
},
Payload = new TestMessage("Hello Kafka!"),
Hops = [
new MessageHop {
ServiceInstance = new ServiceInstanceInfo {
ServiceName = "test",
InstanceId = Guid.NewGuid(),
HostName = "test-host",
ProcessId = Environment.ProcessId
},
CorrelationId = CorrelationId.New()
}
]
};
await transport.PublishAsync(envelope, destination);
// Assert - Wait for message
var received = await messageReceived.Task.WaitAsync(TimeSpan.FromSeconds(10));
await Assert.That(received).IsTrue();
await Assert.That(receivedEnvelope).IsNotNull();
await Assert.That(receivedEnvelope!.MessageId).IsEqualTo(envelope.MessageId);
}
}
Best Practices¶
DO ✅¶
- ✅ Implement InitializeAsync for connectivity verification
- ✅ Declare accurate Capabilities flags
- ✅ Use AOT-compatible serialization (JsonTypeInfo)
- ✅ Handle errors gracefully with retry logic
- ✅ Log all operations for observability
- ✅ Support cancellation via CancellationToken
- ✅ Add health checks for transport status
- ✅ Test with real backends (Docker containers)
DON'T ❌¶
- ❌ Block async operations with
.Resultor.Wait() - ❌ Skip initialization verification (fail fast!)
- ❌ Ignore Capabilities (declare what you support)
- ❌ Forget to dispose subscriptions (memory leaks)
- ❌ Use reflection for serialization (breaks AOT)
- ❌ Swallow exceptions silently (log errors!)
- ❌ Hardcode configuration (use options pattern)
Further Reading¶
Transports: - Azure Service Bus - Built-in Service Bus transport - In-Memory - Testing transport
Messaging: - Outbox Pattern - Reliable event publishing - Work Coordination - Lease-based processing
Source Generators: - JSON Contexts - AOT-compatible serialization
Infrastructure: - Health Checks - Transport health monitoring
Version 1.0.0 - Foundation Release | Last Updated: 2024-12-12