RTPayments is an Azure-based payment batch processing solution. It accepts a batch of payments, places the work on Azure Service Bus, and processes each payment independently through Azure Functions and Azure SQL.
The design uses at-least-once delivery with durable idempotency. It does not attempt to provide exactly-once execution across the message broker, database, and an external payment provider.
An alternative design was considered where PaymentsFD would write every payment directly to SQL and a SQL change feed would drive downstream processing. That would make SQL the initial ingestion point, but a large batch could create a burst of database writes at request time. Many concurrent submissions could therefore concentrate both ingestion and processing-state traffic on SQL before the system had a chance to smooth the workload.
This design publishes payment work to Service Bus first. The queue absorbs bursts and lets Functions apply SQL writes at a controlled processing rate, protecting the database from an immediate batch-sized write spike. The tradeoff is that payment state is not visible in SQL until the worker consumes the message, and Service Bus becomes an important availability and operational dependency.
Client
|
v
PaymentsFD (ASP.NET Core API)
|
v
Azure Service Bus - payments queue
|
v
Payments.Functions (Azure Functions)
|
+--> Azure SQL Payments table
|
+--> Payment provider
The deployment is designed to continue accepting and processing work through a regional failure, subject to the failover actions and limitations below:
+--> PaymentsFD - East US --+
Client --> Front Door +--> Service Bus primary
+--> PaymentsFD - West Europe+
|
v
Functions in both regions
|
v
Azure SQL primary database
|
v
SQL geo-secondary
- API availability: PaymentsFD is deployed in East US and West Europe so either region can serve requests. Azure Front Door is the intended global entry point and health-based router, but it is not yet included in the Bicep deployment.
- Processing availability: Function Apps are deployed in both regions and consume from the shared replicated queue. Service Bus distributes work to available consumers, allowing the processing tier to scale independently from the API tier.
- Durable buffering: The API publishes to Service Bus before returning
202 Accepted. If Functions or SQL are temporarily unavailable, messages remain in the queue and can be retried instead of being lost in the API process. - Message-store resilience: The Service Bus Premium namespace is configured with Geo-Replication from East US to West Europe. Replication is modeled synchronously for acknowledged messages, but only one namespace region is active at a time; regional failover requires promoting the secondary.
- Database resilience: Azure SQL has an East US primary and a West Europe geo-secondary behind a failover group with a stable read-write listener. SQL geo-replication is asynchronous, so the database has a non-zero disaster-recovery RPO even though the queue is modeled with zero replication lag.
- Failure isolation: Each payment is a separate queue message. A failed payment is retried independently and does not block unrelated payments in the same request.
- Idempotent recovery: Stable
BatchIdandPaymentIdvalues, Service Bus duplicate detection, and SQL create-or-claim logic allow ambiguous client retries and message redelivery to be handled without treating the same payment as new work.
This is a disaster-recovery and at-least-once processing design, not a claim of zero downtime or exactly-once payment execution. A regional SQL failure can lose recently committed state that has not replicated, and a provider call can succeed immediately before the worker fails. Provider-side idempotency and reconciliation are therefore required for production-grade recovery from ambiguous outcomes.
The solution is organized into these projects:
PaymentsFD- API that validates and accepts payment batches.Payments.ServiceBus- Shared queue contract and Azure Service Bus publisher.Payments.Functions- Service Bus-triggered payment worker.Payments.Database- SQL schema and stored procedures for payment state and idempotency.Payments.Deploy- Azure Bicep infrastructure definitions and deployment notes.
- The client submits a batch to
POST /api/PaymentBatches. - The request contains a client-generated
BatchId. - Every payment contains a client-generated unique
PaymentId. - The API accepts at most 100 payments per request.
- The API creates one Service Bus message per payment.
- Each message uses the payment ID as its Service Bus
MessageIdand the batch ID as itsCorrelationId. - The publisher attempts to place all messages into one Service Bus message batch and sends it once.
- If the messages do not fit in the Service Bus batch size limit, the request is rejected rather than split across multiple sends.
- The API returns
202 Acceptedafter the Service Bus send succeeds. - Azure Functions processes each payment message independently.
The API limits a request to 100 payments. This is an exercise-level boundary that keeps a logical request small enough to attempt as one Service Bus message batch, assuming payment messages remain around the expected payload size. The publisher still performs the authoritative TryAddMessage size check because 100 messages are not guaranteed to fit if individual messages become large.
Keeping the request as one broker batch and one send avoids splitting a client request across multiple independent sends, which could create partial publication if a later send fails. The tradeoff is that oversized requests are rejected rather than automatically split, so clients must submit smaller batches. A production design handling larger or variable payment payloads could split work deliberately, use an ingestion message that the Functions tier expands, or use blob-backed payloads, but each option adds coordination and operational complexity.
Example request:
{
"batchId": "11111111-1111-1111-1111-111111111111",
"treasuryAccountId": "treasury-001",
"settlementDate": "2026-08-31",
"payments": [
{
"paymentId": "22222222-2222-2222-2222-222222222222",
"beneficiaryName": "Example Beneficiary",
"beneficiaryAccount": "account-001",
"currency": "USD",
"amount": 125.50,
"description": "Example payment"
}
]
}A 4xx response is a validation or request error. The API rejects the request before publishing it, so the client should correct the request rather than retry it.
For a timeout, connection failure, or 5xx response, the client cannot always know whether Service Bus accepted the batch. In that ambiguous case, the client should retry with the same BatchId and PaymentId values. Reusing the IDs lets Service Bus duplicate detection and the SQL idempotency logic recognize the retry as the same logical work. Generating new IDs would create a new batch and new payments.
Service Bus duplicate detection provides short-term broker-level duplicate suppression using PaymentId as MessageId. SQL is the durable idempotency boundary:
PaymentIdis the primary key indbo.Payments.TryCreateOrClaimPaymentatomically creates or claims a payment.- A succeeded payment is ignored when its message is delivered again.
- An active processing lease prevents concurrent processing.
- An expired lease can be reclaimed.
The Function calls the payment provider only after successfully claiming the payment in SQL. On provider failure, it records the failure and throws an exception so Service Bus can redeliver the message. The queue is configured for five delivery attempts; messages that continue to fail become candidates for dead-lettering.
If the provider succeeds but the Function fails before recording SQL success, the message can be delivered again. A production provider integration should accept PaymentId as an idempotency key and support reconciliation for ambiguous outcomes.
Payments.Deploy/main.bicep models:
- PaymentsFD App Services in East US and West Europe.
- Azure Functions in both regions.
- An East US Service Bus Premium namespace with a West Europe Geo-Replication secondary.
- A replicated
paymentsqueue. - Azure SQL primary and geo-secondary databases with a failover group.
Service Bus Geo-Replication is modeled as active-passive, with one active primary region at a time. Azure SQL geo-replication is asynchronous, so a regional disaster can result in a small amount of data loss or ambiguous payment state. Provider idempotency and reconciliation are required to address that production risk.
Azure Front Door, managed identities, and RBAC assignments are follow-up deployment work. Connection strings in the application projects are placeholders and must be supplied through deployment configuration or application settings.
From the repository root:
dotnet build RTPayments.slnxThe API and Function projects expect Service Bus and SQL configuration through application settings. The payment provider is currently a mock implementation that always succeeds.
Database scripts are under Payments.Database and should be applied to the target Azure SQL database before running the Function worker.
The current exercise intentionally keeps the implementation small:
- No SQL write occurs in PaymentsFD.
- No separate batch table is used; batch grouping is represented by
BatchIdon payment rows. - No ledger or real payment provider integration is included yet.
- No automatic splitting of oversized requests is performed.
- No scheduled retry or exponential backoff policy is implemented.
- No batch-status endpoint is implemented.