Section 10

Integration & messaging

How systems talk to each other: real-time, near-real-time or batch; queues or events; orchestration or choreography; and the patterns that keep messages from getting lost, duplicated or stuck.

10.1 Types of integration: real-time, near-real-time & batch

Choose by how fresh the data must be and how much of it there is.

◄ fresher staler, bigger volume ► FRESHNESS REAL-TIME (ms–2 s) NEAR REAL-TIME (secs–mins) BATCH (hours–daily) sync API call "check balance now" events · CDC · streaming · micro-batch "send fraud alert / update CRM" scheduled files / ETL "end-of-day statements"
Real-timeNear-real-timeBatch
LatencyMilliseconds, user waitsSeconds to minutesHours, scheduled
TriggerRequestEvent or changeTime (cron) or file arrival
Volume per callOne recordOne or a few records per eventMillions per run
CouplingTight (both up)Loose (broker in between)Loosest
TechREST, gRPC, SOAP via APIMKafka, Service Bus, Event Grid, CDC, webhooksSFTP/MFT, ETL (ADF, SSIS), Spark
Banking examplePayment authorisation, balance enquiryTransaction notifications, fraud scoring, CRM syncStatements, GL posting, regulatory reports, reconciliation
Failure handlingCaller retries or fails fastRetry, DLQ, replayRestart from checkpoint, rerun
  • Don't make everything real-time. It's the most expensive and most tightly coupled option. Ask: "What decision needs this data, and how fast?"
  • Micro-batch (every 1–5 min) is a practical middle ground.
  • Many real flows are hybrid: a real-time API for the user, an event for downstream systems, and batch reconciliation at end of day.
How to implement it
TypeLibrary / APINotes
Real-timebuilt-in ASP.NET Core minimal APIs / Grpc.AspNetCore; IHttpClientFactory + Microsoft.Extensions.Http.ResilienceServe and call sync APIs. Add timeouts, retries and a circuit breaker on every outbound call
Near-real-timeAzure.Messaging.ServiceBus, Azure.Messaging.EventHubs.Processor, Azure.Messaging.EventGrid, Confluent.KafkaQueues, topics, event streams and push events through a broker
CDCMicrosoft.Azure.Cosmos change feed processorReact to every insert or update in Cosmos DB. For SQL Server, use Debezium or Fabric mirroring outside .NET
Micro-batchbuilt-in BackgroundService + PeriodicTimerWake every 1–5 minutes and process what has arrived since the last checkpoint
BatchQuartz (4.x; DI and hosting built in), Hangfire.Core check licence, Azure Functions timer triggerCron-style scheduling. Hangfire core is LGPL; Hangfire Pro (batches, extra features) is commercial

Real-time, near-real-time or batch? Three questions decide it ⏱

1️⃣ Who is waiting? A human → real-time API

2️⃣ Must others react soon? → events / CDC (near-real-time)

3️⃣ Is it big, periodic, and fine by tomorrow? → batch

Most good architectures use all three for the same business process.

#Integration #Architecture #EventDriven

10.2 Integration styles & architecture approaches Added

Hohpe & Woolf's four styles (file transfer, shared DB, RPC, messaging), and how enterprises organise integration at scale.

📁 File transfer

Systems exchange files. Simple, batch, loosely coupled.

🗄 Shared database

Systems read and write the same DB. Tight coupling. Avoid it.

📞 Remote procedure call

Sync APIs (REST, SOAP, gRPC).

✉️ Messaging

Async through a broker. Decoupled and reliable.

🍝 Point-to-point

Every system connects directly to every other. n systems ⇒ up to n(n−1)/2 links. Becomes spaghetti.

🎡 Hub-and-spoke / ESB

A central bus does routing and transformation. Fewer links, but the bus can become a bottleneck and a monolith.

🧱 API-led connectivity

Three layers: System APIs (unlock core systems) → Process APIs (business logic) → Experience APIs (per channel). Built for reuse.

⚡ Event-driven / event mesh

Producers publish events. Any consumer subscribes. Most decoupled.

☁️ iPaaS

Cloud integration platform with connectors: MuleSoft, Boomi, Azure Integration Services (Logic Apps + APIM + Service Bus + Event Grid + Functions).

API-led connectivity
Experience APIsMobile API · Web API · Partner API
↓
Process APIsOnboard Customer · Make Payment
↓
System APIsCore Banking · CRM · Card System · Mainframe
How to implement it
StyleLibrary / APINotes
RPC / APIsbuilt-in ASP.NET Core + Microsoft.AspNetCore.OpenApi; Grpc.AspNetCore; CoreWCF.HttpREST with built-in OpenAPI docs, gRPC for service-to-service, CoreWCF to host SOAP services on modern .NET
MessagingWolverineFx, Rebus, Paramore.BrighterFree messaging frameworks: handlers, retries, outbox and broker transports on top of the raw SDKs
MessagingMassTransit commercial licence (v9+; v8 is OSS), NServiceBus commercial licenceMature, feature-rich frameworks with sagas and tooling. MassTransit v8 gets patches only until the end of 2026
Event-drivenCloudNative.CloudEvents (+ .SystemTextJson)A standard event envelope (id, source, type, time) so any consumer can read your events
Event contractsNeuroglia.AsyncApi.AspNetCore or SaunterGenerate and serve AsyncAPI docs for your channels and messages (the event version of OpenAPI)
Hub / iPaaSAzure Logic Apps, API Management, Service BusMostly configuration. .NET code plugs in as Functions or custom connectors

10.3 Queue vs Event-driven (pub/sub & streaming)

A queue delivers each message (usually a command, "do this") to one consumer. An event ("this happened") goes to every interested subscriber.

Queue: point-to-point
Producer
→
Queue▓▓▓
→
Worker 1
Worker 2each message → ONE worker
Pub/Sub: topic
Publisher
→
Topic
→
Fraud svc
Notify svc
Analyticseach gets a COPY
Event streaming: an append-only log you can replay
Producers
→
Topic partitionsP0: e1 e2 e3 e4 … · P1: e1 e2 e3 … (retained for days)
→
Consumer groupseach tracks its own offset
Message queuePub/sub (event broker)Event streaming
DeliveryOne consumer per messageAll subscribersAll consumer groups, replayable
After consumptionMessage removedRemoved per subscriptionKept until retention expires
IntentCommand / work itemNotification of a factHistory of facts
OrderingFIFO, sessionsUsually not guaranteedPer partition (by key)
ExamplesAzure Service Bus queue, RabbitMQ, SQS, IBM MQService Bus topics, Event Grid, SNSKafka, Azure Event Hubs, Kinesis
Use forLoad levelling, background jobs, reliable hand-offFan-out to many servicesHigh volume, analytics, event sourcing, CDC

Command

"CreateAccount": tells one handler to act. Can be rejected.

Event

"AccountCreated": a past fact, broadcast. Can't be rejected.

Event notification vs event-carried state

A thin event says "id 42 changed, call back for details". A fat event carries all the data, so no callback is needed.

  • Queues give load levelling: the producer can burst while consumers process at their own pace.
  • Events invert dependencies: the producer doesn't know or care who listens. Add consumers without changing the producer.
  • Version event schemas carefully. Use a schema registry and backward-compatible changes.
  • Event-driven systems are eventually consistent and harder to debug, so invest in correlation IDs and tracing.
How to implement it
ModelLibrary / APINotes
QueueAzure.Messaging.ServiceBus (ServiceBusProcessor); RabbitMQ.ClientPoint-to-point work queues. RabbitMQ.Client v7 is async-first (CreateConnectionAsync, IChannel)
Pub/subService Bus topics + subscriptions; Azure.Messaging.EventGridEach subscription gets its own copy. Event Grid pushes events to webhooks, Functions and queues
StreamingConfluent.Kafka; Azure.Messaging.EventHubs.Processor (EventProcessorClient)Partitioned, replayable logs. Consumer groups track their own offsets. Event Hubs also speaks the Kafka protocol
SchemasConfluent.SchemaRegistry.Serdes.Avro (or .Json / .Protobuf)Serialize with a schema registry and enforce backward-compatible changes
In-process queuebuilt-in System.Threading.ChannelsA fast producer/consumer queue inside one app. Not durable, so it's lost on restart
// Kafka consumer: each consumer group gets every event and tracks its own offset
var config = new ConsumerConfig
{
    BootstrapServers = "localhost:9092",
    GroupId = "fraud-service",
    AutoOffsetReset = AutoOffsetReset.Earliest,
    EnableAutoCommit = false            // commit only after we've handled the event
};
using var consumer = new ConsumerBuilder<string, string>(config).Build();
consumer.Subscribe("payments");

while (!ct.IsCancellationRequested)
{
    var result = consumer.Consume(ct);  // blocks until a record arrives
    await HandleAsync(result.Message.Key, result.Message.Value, ct);
    consumer.Commit(result);            // move this group's offset forward
}

Queue vs Event: the difference in one line 📬📢

Queue = a to-do list. Each task is done by ONE worker. ("Process payment #42")

Event = a public announcement. EVERYONE interested hears it. ("Payment #42 completed")

Streaming (Kafka) = announcements recorded on tape, so you can replay them later.

#EventDriven #Kafka #Integration #Architecture

10.4 Delivery guarantees & ordering Added

The three delivery guarantees, the ordering options, and what happens to messages that keep failing.

At-most-once

Fire and forget. May lose messages, never duplicates. Fine for metrics.

At-least-once ⭐ default

Retries until acknowledged. No loss, but duplicates are possible, so consumers must be idempotent.

Exactly-once

Hard end to end. In practice it's "effectively once" = at-least-once + idempotent consumer (or Kafka transactions inside Kafka).

How to implement it
NeedLibrary / APINotes
At-least-onceService Bus PeekLock + CompleteMessageAsync / AbandonMessageAsyncThe message stays locked until you settle it. ReceiveAndDelete mode gives at-most-once
Dead-letterDeadLetterMessageAsync, queue MaxDeliveryCount, SubQueue.DeadLetterService Bus dead-letters automatically after N deliveries. Read the DLQ with a receiver on the sub-queue
OrderingServiceBusSessionProcessor (SessionId = account id); Kafka message keyOrder per key, not globally
DedupService Bus duplicate detection (set MessageId); EF Core unique index on MessageIdBroker dedup works only within its time window. The idempotent consumer is your real safety net
Kafka "exactly-once"Confluent.Kafka: EnableIdempotence, TransactionalId, SendOffsetsToTransactionExactly-once only for read-process-write inside Kafka. External side effects still need idempotency
var processor = client.CreateProcessor("payments", new ServiceBusProcessorOptions
{
    AutoCompleteMessages = false,   // peek-lock: we settle each message ourselves
    MaxConcurrentCalls = 8
});
processor.ProcessMessageAsync += async args =>
{
    var msg = args.Message;
    var payment = msg.Body.ToObjectFromJson<PaymentDto>();
    if (payment is null)            // poison message: never retry it
    {
        await args.DeadLetterMessageAsync(msg, "InvalidBody", "Could not parse payment");
        return;
    }
    await using var scope = scopes.CreateAsyncScope();
    var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    if (!await db.ProcessedMessages.AnyAsync(p => p.Id == msg.MessageId))
    {
        db.ProcessedMessages.Add(new ProcessedMessage(msg.MessageId)); // unique key
        db.Payments.Add(Payment.From(payment));
        await db.SaveChangesAsync(args.CancellationToken);  // one transaction
    }
    await args.CompleteMessageAsync(msg);  // ack only after the DB commit
};
processor.ProcessErrorAsync += args => Task.CompletedTask; // log args.Exception
await processor.StartProcessingAsync();

10.5 Back-pressure & flow control Added

When a consumer can't keep up, it pushes back on the producer. Work slows down, or is shed on purpose, instead of piling up until something falls over.

Strategies

🪣 Buffer, with a bound

A queue soaks up bursts. Always set a maximum size or age, then decide what happens when it's full.

🚦 Throttle the producer

Rate limit at the edge. Return 429 or 503 with Retry-After. Callers back off with jitter.

🗑️ Drop / shed load

Drop the oldest or newest items, sample, or reject low-priority work first. Fine for telemetry. Never for payments.

📥 Pull, don't push

The consumer asks for the next batch when it's ready (Kafka poll, prefetch count). It sets its own pace.

🎟️ Credit-based flow

The receiver grants "credits" for how much it can take: AMQP link credit, TCP and HTTP/2 flow-control windows, Reactive Streams request(n).

📈 Scale the consumers

Add competing consumers when lag grows. KEDA scales pods on queue length or Kafka lag, even down to zero.

✅ Queue-based load levelling

  • A bounded queue between a spiky producer and a steady consumer
  • The consumer runs at a safe, fixed rate
  • Alerts and autoscaling on lag

❌ Unbounded queue

  • Hides the problem until memory or storage runs out
  • Latency grows silently: messages are hours old
  • Fails later and bigger
  • Every buffer needs a bound and a "when full" rule: block, drop or reject.
  • Choose per message type: block or reject for payments (never drop), drop or sample for metrics and logs.
  • Alert and autoscale on the age of the oldest message, not just the count.
How to implement it
NeedLibrary / APINotes
Bounded in-process queuebuilt-in System.Threading.Channels: Channel.CreateBoundedBoundedChannelFullMode.Wait makes the writer wait (back-pressure). DropOldest / DropNewest / DropWrite shed load instead
Pipelines of stepsbuilt-in TPL Dataflow (System.Threading.Tasks.Dataflow)Set BoundedCapacity on each block, and use SendAsync so the sender waits when the block is full
Byte streamsbuilt-in System.IO.PipelinesPipeOptions pauseWriterThreshold / resumeWriterThreshold: the writer's FlushAsync waits until the reader catches up
Service Bus consumersAzure.Messaging.ServiceBus: MaxConcurrentCalls, PrefetchCountCap in-flight work per instance. The broker holds the rest safely (pull-based)
Kafka consumersConfluent.Kafka: consumer.Pause(partitions) / Resume(partitions)Stop fetching while a downstream system is slow. Keep calling Consume so you stay in the group
HTTP edgebuilt-in Microsoft.AspNetCore.RateLimiting (AddRateLimiter)Concurrency, token bucket or per-client limits. Return 429 and set Retry-After in OnRejected. The old UseConcurrencyLimiter middleware is obsolete
// Bounded channel: when 100 items are waiting, the producer has to wait
var channel = Channel.CreateBounded<Payment>(new BoundedChannelOptions(100)
{
    FullMode = BoundedChannelFullMode.Wait,   // back-pressure, nothing is dropped
    SingleWriter = true
});

// Producer: WriteAsync only completes when there is room
async Task ProduceAsync(IAsyncEnumerable<Payment> incoming, CancellationToken ct)
{
    await foreach (var p in incoming.WithCancellation(ct))
        await channel.Writer.WriteAsync(p, ct);  // slows to the consumers' pace
    channel.Writer.Complete();
}

// Consumers: 4 workers drain the channel at their own pace
var workers = Enumerable.Range(0, 4).Select(_ => Task.Run(async () =>
{
    await foreach (var p in channel.Reader.ReadAllAsync(ct))
        await ProcessAsync(p, ct);
}));
await Task.WhenAll(workers);

A channel lives in memory, so it's lost on restart. Use it to protect one process. Business messages that must survive belong in a durable broker (Service Bus, Kafka).

A queue doesn't fix a slow consumer. It just delays the outage 🧯

🪣 Bound every buffer. "Unbounded" means it fails later, and bigger.

🚦 Push back: block the writer, or return 429/503 with Retry-After.

🗑️ Shed what's cheap to lose (telemetry). Never shed payments.

📥 Let consumers pull at their own pace (prefetch, credits).

📈 Watch consumer lag and the age of the oldest message, and autoscale on them.

Back-pressure isn't a failure. It's your system being honest about its capacity.

#SystemDesign #EventDriven #Resilience #Integration

10.6 Messaging & Enterprise Integration Patterns Added

A shared vocabulary for designing message flows (from Enterprise Integration Patterns, Hohpe & Woolf).

Competing consumers

Many workers read one queue to scale processing.

Request-reply

Send on a request queue, reply on a reply queue, matched by correlation ID.

Content-based router

Route by message content (e.g. AU vs NZ payments).

Message filter

Drop messages a consumer doesn't care about.

Splitter / Aggregator

Split a batch into items. Aggregate results back together.

Scatter-gather

Ask many (e.g. FX quotes), collect and pick the best.

Message translator / canonical model

Map to a shared format so N systems don't need N² mappings.

Claim check

Store the big payload in blob storage and send only a reference.

Idempotent receiver

Deduplicate by message ID.

Transactional outbox ⭐

Write business data + an "outbox" row in one DB transaction. A relay publishes the outbox to the broker. No lost or phantom events.

Queue-based load levelling

A queue between a spiky producer and a steady consumer.

Priority queue

Urgent messages get processed first.

How to implement it
PatternLibrary / APINotes
Transactional outboxWolverineFx.EntityFrameworkCore; MassTransit EF Core outbox commercial licence (v9+); or a hand-rolled outbox tableSave the business row and the outgoing message in one EF Core transaction, then relay it
Competing consumersServiceBusProcessor (MaxConcurrentCalls) on several instancesScale out by adding instances that read the same queue
Request-replyServiceBusMessage.ReplyTo, ReplyToSessionId, CorrelationIdThe requester waits on a session-enabled reply queue for its own session id
Router / filterService Bus subscription SQL and correlation filters (CreateRuleOptions)Let the broker route AU vs NZ payments, so consumers only get what they need
Claim checkAzure.Storage.BlobsUpload the big payload, send only the blob name or a short-lived SAS URL
Many EIPs at onceWolverineFx, Paramore.Brighter, NServiceBus commercial licenceRouting, retries, idempotent receivers, scatter-gather and outbox built in
// 1. One SaveChanges = one transaction: the order AND its outbox row
db.Orders.Add(order);
db.Outbox.Add(new OutboxMessage { Id = Guid.NewGuid(), Type = "OrderCreated",
    Payload = JsonSerializer.Serialize(new OrderCreated(order.Id)) });
await db.SaveChangesAsync(ct);

// 2. Relay: publish unsent rows to the broker, then mark them sent
public sealed class OutboxRelay(IServiceScopeFactory scopes, ServiceBusSender sender)
    : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromSeconds(2));
        while (await timer.WaitForNextTickAsync(ct))
        {
            await using var scope = scopes.CreateAsyncScope();
            var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
            var batch = await db.Outbox.Where(m => m.SentAt == null)
                .OrderBy(m => m.CreatedAt).Take(100).ToListAsync(ct);
            foreach (var m in batch)
            {
                await sender.SendMessageAsync(new ServiceBusMessage(m.Payload)
                    { MessageId = m.Id.ToString(), Subject = m.Type }, ct);
                m.SentAt = DateTime.UtcNow;
            }
            await db.SaveChangesAsync(ct);
        }
    }
}

The relay can crash after sending but before saving SentAt, so a message may go out twice. Reuse the outbox id as the MessageId and keep consumers idempotent.

10.7 Orchestration vs Choreography Added

Orchestration: a conductor tells each service what to do. Choreography: each service reacts to events on its own, like dancers who know the routine.

Orchestration
OrchestratorLogic Apps / Durable Functions / Temporal / Camunda
↓ ↓ ↓
Payment
Inventory
Shipping
Choreography
Order svc
↓ OrderPlaced
Payment svc
↓ PaymentDone
Shipping svc
OrchestrationChoreography
ControlCentral, explicit workflowDecentralised, emergent
VisibilityEasy to see state and debugHard to see the end-to-end flow
CouplingOrchestrator knows all servicesServices only know events
Best forComplex flows, compensation, SLAs, human stepsSimple flows, high autonomy, many listeners
How to implement it
Library / APIPackageUse it for
Durable FunctionsMicrosoft.Azure.Functions.Worker.Extensions.DurableTaskCode-first orchestrations on Azure Functions (isolated worker): chaining, fan-out/fan-in, timers, human approval
Durable Task SDKMicrosoft.DurableTask.Worker.AzureManaged + .Client.AzureManagedThe same orchestration model in any host (ASP.NET Core, containers), backed by the managed Durable Task Scheduler
TemporalTemporalioDurable workflows on a Temporal cluster (self-hosted or Temporal Cloud)
Dapr WorkflowDapr.WorkflowWorkflows that run on the Dapr sidecar, portable across clouds
Elsa WorkflowsElsaVisual designer + code workflows embedded in your own app
ChoreographyAzure.Messaging.ServiceBus topics, or WolverineFx handlersEach service subscribes to events and publishes the next one. No central controller
public static class OrderWorkflow
{
    [Function(nameof(RunOrder))]
    public static async Task<string> RunOrder(
        [OrchestrationTrigger] TaskOrchestrationContext context)
    {
        var order = context.GetInput<Order>()!;
        // The orchestrator calls each step in order. State is checkpointed
        // after every await, so it survives restarts.
        await context.CallActivityAsync("TakePayment", order);
        await context.CallActivityAsync("ReserveStock", order);
        return await context.CallActivityAsync<string>("BookShipping", order);
    }

    [Function("TakePayment")]
    public static Task TakePayment([ActivityTrigger] Order order) => Task.CompletedTask;
}

// Start it from an HTTP function or API:
// await client.ScheduleNewOrchestrationInstanceAsync(nameof(OrderWorkflow.RunOrder), order);

Orchestrator code must be deterministic: no DateTime.UtcNow, Guid.NewGuid() or direct I/O. Use context.CurrentUtcDateTime and do I/O in activities.

10.8 Batch & file integration done right Added

Controls that make file and batch integrations reliable, auditable and safe to rerun.

Source extract
→
SFTP / MFTPGP-encrypted
→
Validateschema · checksum · control totals
→
Transform & loadchunks + checkpoints
→
Reconcile & report
  • Control totals (record count, sum of amounts) in a header or trailer, or a separate control file.
  • Avoid reading half-written files: use a .tmp → rename convention or a "done" marker file.
  • Idempotent and restartable: process in chunks with checkpoints, and make reruns safe.
  • Schedulers: Control-M, Autosys, ADF triggers, cron. Track dependencies and SLAs.
  • Keep an audit trail and archive files under your retention policy.
How to implement it
StepLibrary / APINotes
TransferSSH.NET (SFTP), FluentFTP (FTP/FTPS)List, download, upload and rename files on the MFT server
Encrypt / signPgpCorePGP encrypt, decrypt, sign and verify files and streams
Validatebuilt-in SHA256.HashDataAsync(stream); CsvHelperChecksums, then stream records one by one to check schema and control totals
Big filesbuilt-in System.IO.PipelinesParse huge fixed-width or custom files with low memory use
LoadSqlBulkCopy (Microsoft.Data.SqlClient); EF Core ExecuteUpdateAsyncBulk insert in chunks, then set-based updates. Record the last chunk in a checkpoint table
ScheduleQuartz (4.x; DI and hosting built in), Hangfire.Core check licence, Functions timer triggerCron triggers with persistence and retries (Hangfire Pro is commercial)
using var sftp = new SftpClient(host, "svc-batch", new PrivateKeyFile(keyPath));
sftp.Connect();

foreach (var file in sftp.ListDirectory("/inbound").Where(f => f.Name.EndsWith(".csv")))
{
    // Only pick up files the sender has finished writing
    if (!sftp.Exists(file.FullName + ".done")) continue;

    var local = Path.Combine(workDir, file.Name);
    using (var output = File.Create(local))
        sftp.DownloadFile(file.FullName, output);

    await ProcessWithCheckpointsAsync(local, ct);           // validate, load, reconcile
    sftp.RenameFile(file.FullName, $"/archive/{file.Name}"); // never process it twice
}

10.9 Event streaming and Kafka Added

Kafka is a distributed, append-only log: producers write events to topics, and each consumer group reads them at its own pace and can replay them.

Queue vs pub/sub vs streaming basics: 10.3. Delivery guarantees: 10.4.

How it works

ConceptKey point
TopicNamed stream of events, split into partitions
PartitionOrdered log; unit of ordering and parallelism
KeySame key, same partition, same order
Consumer groupOne consumer per partition inside a group
RebalancePartitions move when consumers join or leave
OffsetRead position per partition, committed by the group
Replicatione.g. 3 replicas; acks=all waits for in-sync ones

Commit strategy = delivery semantics

StrategyResult
Commit, then processAt-most-once: may lose
Process, then commit ⭐At-least-once: may duplicate, so consumers are idempotent
Idempotent producer + transactions + read_committedExactly-once inside Kafka (read-process-write)

Retention (delete)

Keep events for a time or size (e.g. 7 days): a replay window.

Log compaction

Keep the latest value per key; a tombstone (null) deletes it. Current state.

Schema registry

Avro, Protobuf or JSON Schema. Enforces compatibility (e.g. BACKWARD) so producers can't break consumers.

Dead-letter topic

No built-in DLQ. Retry, then publish to payments.DLT with error headers and commit.

Streaming or a queue? (when to use / when not)

Pick Kafka (streaming) whenPick a queue when
Many consumers read the same eventsOne worker per task (commands)
You need replay or historyYou want per-message ack, retry, DLQ built in
High volume, ordered per keyDelayed or scheduled messages
CDC, analytics, event sourcingLow volume, small team, simple ops

Managed options: Azure Event Hubs (Kafka endpoint), Amazon MSK, Confluent Cloud. Check feature support per tier (e.g. transactions, compaction).

  • Order is per partition only. Key by the entity whose order matters (e.g. account ID).
  • Partitions cap parallelism: extra consumers sit idle. A hot key makes a hot partition.
  • Default: at-least-once + idempotent consumers. Exactly-once is Kafka-to-Kafka only.
  • Watch consumer lag: the main health and autoscale signal.
  • Adding partitions remaps keys. Size them up front.
How to implement it
NeedPackage / APINotes
Producer, consumer, adminConfluent.Kafka (2.16.x, Apache-2.0)Wraps librdkafka. Supports idempotence, transactions, cooperative rebalancing
Schema registryConfluent.SchemaRegistry.Serdes.Avro / .Protobuf / .JsonSame version line as Confluent.Kafka
Azure Event HubsSame Confluent.Kafka clientBootstrap <namespace>.servicebus.windows.net:9093, SASL_SSL
Hostingbuilt-in BackgroundServiceConsume blocks, so give the loop its own thread
using var producer = new ProducerBuilder<string, string>(new ProducerConfig
{
    BootstrapServers = servers,
    Acks = Acks.All,
    EnableIdempotence = true          // librdkafka default is false: set it explicitly
}).Build();

await producer.ProduceAsync("payments",
    new Message<string, string> { Key = accountId, Value = json });   // key = ordering scope

using var consumer = new ConsumerBuilder<string, string>(new ConsumerConfig
{
    BootstrapServers = servers,
    GroupId = "ledger-service",
    EnableAutoCommit = false,         // process, then commit = at-least-once
    PartitionAssignmentStrategy = PartitionAssignmentStrategy.CooperativeSticky
}).Build();
consumer.Subscribe("payments");

while (!ct.IsCancellationRequested)
{
    var r = consumer.Consume(ct);
    try { await HandleIdempotentlyAsync(r.Message, ct); }
    catch (Exception ex) when (ex is not OperationCanceledException)
    {
        await producer.ProduceAsync("payments.DLT", new Message<string, string>
        {
            Key = r.Message.Key, Value = r.Message.Value,
            Headers = new Headers { { "error", Encoding.UTF8.GetBytes(ex.Message) } }
        }, ct);                       // park it, don't block the partition
    }
    consumer.Commit(r);
}

Kafka isn't a queue. It's a log you can rewind ⏪

📜 Events are appended to partitions and kept for days, not deleted on read.

🔑 Same key, same partition, same order. Pick the key carefully (account ID, not random).

👥 A consumer group splits partitions between its members. More consumers than partitions? They sit idle.

🔁 Process, then commit. Expect duplicates and make consumers idempotent.

☠️ No built-in DLQ: park poison events in a dead-letter topic so one bad message doesn't stall a partition.

Queue for tasks, Kafka for facts many teams need to replay. Which do you reach for first?

#Kafka #EventStreaming #Integration #SolutionArchitecture

⚡ Quick-fire Q&A

Service Bus or Event Hubs?

Service Bus for enterprise messaging (commands, transactions, sessions, DLQ, ordered workflows). Event Hubs for high-throughput event streaming and telemetry with replay.

How do you guarantee an event is published when the DB commits?

Transactional outbox, or CDC on the outbox table. Never "write the DB, then publish" as two separate steps.

How do you handle duplicates with at-least-once delivery?

Idempotent consumers: deduplicate by message ID or business key, use upserts, and use version checks.

What is back-pressure, and how do you apply it?

The consumer signals that it is full, so the producer slows down instead of overwhelming it. Use bounded queues with a clear "when full" rule (block, drop or reject), rate limits that return 429/503 with Retry-After, pull-based or credit-based consumption, and autoscaling on consumer lag.

Why is an unbounded queue dangerous?

It hides overload. The queue grows until memory or storage runs out, and latency climbs silently because messages wait for hours. A bound turns a hidden, late failure into an early, visible signal you can act on.

How do you keep Kafka events for one account in order?

Use the account ID as the message key, so all its events land on the same partition, and one consumer in the group reads that partition. Enable the idempotent producer so retries don't duplicate or reorder events. There is no order across partitions.

Kafka consumer lag keeps growing. What do you check?

A slow or failing handler (poison events blocking the partition: send them to a dead-letter topic), then scale consumers up to the partition count, process in batches, and look for a hot key. Adding partitions helps throughput but changes which partition each key maps to.

← 09 · API Management