Building a Reliable Outbox Pattern for Offline .NET MAUI Apps
๐ฌ Building a Reliable Outbox Pattern for Offline .NET MAUI Apps
Mobile applications live in an unreliable world.
A backend service normally runs inside a controlled environment with stable networking, persistent infrastructure, monitoring, and predictable connectivity. A mobile application does not have those guarantees.
The user can lose Wi-Fi in the middle of an operation. The operating system can suspend the application. The process can be terminated. A request can reach the server while the response never reaches the device. The user can switch between cellular and Wi-Fi. A synchronization operation can fail halfway through a batch.
For many applications, simply wrapping an HTTP request in try/catch is not enough. Consider a field-service application where a technician completes an inspection while offline:
Inspection completed locally
โ
โผ
POST /api/inspections
โ
โผ
Network unavailable
โ
โผ
Request fails
If the application only performs the HTTP request, the operation may be lost. We could save the inspection locally first:
Save inspection to SQLite
โ
โผ
Call API
But this introduces another problem. What happens if the application is terminated after saving the inspection but before sending the HTTP request?
SQLite COMMIT
โ
โผ
๐ฅ Application terminated
โ
X
HTTP request never executed
Now the local database contains the operation, but nothing guarantees that the server will ever receive it.
There is an even more subtle failure.
Suppose the request reaches the server successfully, but the connection disappears before the response reaches the device:
.NET MAUI App API
POST /orders โโโโโโโโโโโโโโโโโบ
Order created
X โโโโโโโโโ 201 Created
network lost
The application believes the operation failed. It retries.
POST /orders โโโโโโโโโโโโโโโโโบ
Order created again
Now we have duplicate data.
This is where the Outbox Pattern becomes extremely useful.
Instead of treating remote communication as an immediate side effect of a user action, we persist the intent to perform that remote operation alongside the local state.
The application can then deliver that intent reliably when conditions allow.
In this article, we'll build a production-oriented Outbox architecture for .NET MAUI applications using C#, SQLite-style persistence, idempotency, retries, exponential backoff, concurrency protection, lifecycle integration, observability, and offline synchronization. ๐
๐ Table of Contents
- The Reliability Problem
- Why
try/catchIs Not Enough - What Is the Outbox Pattern?
- The Mobile Outbox Variant
- The Dual-Write Problem
- Designing the Architecture
- Defining an Outbox Message
- Outbox States
- Persisting Business Data and Outbox Messages Atomically
- Designing the Outbox Repository
- Building the Outbox Processor
- Designing Message Handlers
- Serialization and Message Contracts
- Processing Messages Safely
- Preventing Concurrent Processing
- At-Least-Once Delivery
- Why Exactly-Once Delivery Is Difficult
- Idempotency Keys
- Server-Side Idempotency
- Retry Policies
- Exponential Backoff
- Adding Jitter
- Permanent vs Transient Failures
- Poison Messages
- Message Ordering
- Per-Aggregate Ordering
- Connectivity-Aware Processing
- App Lifecycle Integration
- Recovering After Application Restart
- Background Execution
- Authentication and the Outbox
- Conflict Resolution
- Attachments and Large Payloads
- Outbox Cleanup
- Observability
- Health Monitoring Integration
- User Experience
- Testing
- Failure Injection
- Security
- Performance
- Production Architecture
- Common Mistakes
- Best Practices
- Conclusion
1. ๐ The Reliability Problem
Let's start with a simple application. The user creates an order:
public async Task CreateOrderAsync(
Order order,
CancellationToken cancellationToken)
{
await _api.CreateOrderAsync(
order,
cancellationToken);
}
This works perfectly when:
Device
โ
โผ
Network
โ
โผ
API
are all functioning normally.
But mobile networks aren't reliable.
The request could fail because of:
No internet connection
DNS failure
TLS failure
Timeout
Server unavailable
Wi-Fi โ cellular transition
VPN reconnect
Application suspension
Process termination
Authentication expiration
```text
We might add retry logic:
```csharp
try
{
await _api.CreateOrderAsync(
order,
cancellationToken);
}
catch
{
// Retry later
}
But where exactly is later stored?
If it's only represented by an in-memory Task, it disappears when the process disappears.
Reliable retry requires durable state.
2. โ ๏ธ Why try/catch Is Not Enough
Exception handling answers:
What should this execution path do when an operation fails?
It doesn't automatically answer:
How do I guarantee that the business operation remains pending after the application is terminated?
Consider:
await _database.SaveOrderAsync(order);
try
{
await _api.CreateOrderAsync(order);
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Could not upload order.");
}
The local operation succeeds.
The remote operation fails.
We now have two independent states:
Local Database Server
โโโโโโโโโโโโโโ โโโโโโ
Order exists Order missing
Something must remember that synchronization is still required.
That "something" is the Outbox.
3. ๐ฌ What Is the Outbox Pattern?
The traditional Transactional Outbox Pattern is commonly used in distributed systems. Instead of performing:
Database write
+
Message broker publish
as two independent operations, the application stores both:
Business data
+
Outbox record
inside the same local transaction.
Conceptually:
BEGIN TRANSACTION
INSERT Order
INSERT OutboxMessage
COMMIT
Once committed, another component processes the Outbox.
Outbox
โ
โผ
Processor
โ
โผ
Remote system
If delivery fails, the Outbox record remains.
If the application restarts, the Outbox record remains.
If connectivity disappears, the Outbox record remains.
The intent has become durable.
4. ๐ฑ The Mobile Outbox Variant
On the backend, an Outbox often bridges:
Database โ Message Broker
In a mobile application, we can adapt the same principle to:
Local Database โ REST API
or:
Local Database โ GraphQL API
or:
Local Database โ Synchronization Service
For example:
User taps Save
โ
โผ
Local Transaction
โ
โโโโโโโโโโโโโโโโ
โผ โผ
Save Order Create Outbox
Message
โ โ
โโโโโ COMMIT โโโ
โ
โผ
UI completes
โ
โผ
Outbox Processor
โ
Connectivity?
โ โ
No Yes
โ โ
โผ โผ
Wait API
โ
Success?
โ โ
Yes No
โ โ
โผ โผ
Complete Retry
The important architectural shift is:
Saving the user's operation no longer depends on immediate network availability.
5. ๐ฅ The Dual-Write Problem
Without an Outbox, we often perform two writes:
await _database.SaveOrderAsync(order);
await _api.CreateOrderAsync(order);
These are two independent systems.
No transaction can normally cover both:
SQLite transaction
+
Remote HTTP transaction
If the first succeeds and the second fails:
Local = committed
Remote = missing
If we reverse them:
await _api.CreateOrderAsync(order);
await _database.SaveOrderAsync(order);
we simply reverse the failure mode:
Remote = committed
Local = missing
This is the dual-write problem.
The Outbox doesn't create a distributed transaction.
Instead, it changes the problem.
We make the two local writes atomic:
Business Data
+
Delivery Intent
Then remote delivery becomes asynchronous and retryable.
6. ๐๏ธ Designing the Architecture
Our production architecture will separate responsibilities.
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ .NET MAUI UI โ
โโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Application / Use Case โ
โโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โผ
Local Transaction
โ โ
โผ โผ
Business Data Outbox
โ โ
โโโโโโโโฌโโโโโโโ
โ
COMMIT
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Outbox Processor โ
โ โ
โ Retry โ
โ Backoff โ
โ Ordering โ
โ Concurrency โ
โ Cancellation โ
โโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Message Handlers โ
โโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โผ
REST API
โ
โผ
Idempotency Layer
This gives us clear boundaries:
Application layer creates business operations. Outbox repository persists delivery intent. Outbox processor schedules execution. Message handlers know how to perform specific remote operations. Backend idempotency prevents duplicate effects.
7. ๐ฆ Defining an Outbox Message
Let's define the persisted model.
public sealed class OutboxMessage
{
public Guid Id { get; set; }
public string Type { get; set; } = string.Empty;
public string Payload { get; set; } = string.Empty;
public OutboxMessageStatus Status { get; set; }
public int AttemptCount { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset? LastAttemptAt { get; set; }
public DateTimeOffset? NextAttemptAt { get; set; }
public DateTimeOffset? CompletedAt { get; set; }
public string? LastError { get; set; }
public string? AggregateId { get; set; }
public string IdempotencyKey { get; set; } =
string.Empty;
}
Notice that we're storing more than a serialized request.
We also persist operational state.
That means the application can answer:
When was this operation created?
How many times has it failed?
When should it retry?
Has it completed?
What entity does it belong to?
What idempotency key identifies it?
8. ๐ฆ Outbox States
Define explicit states:
public enum OutboxMessageStatus
{
Pending,
Processing,
Completed,
Failed,
DeadLetter
}
A typical lifecycle is:
โโโโโโโโโโโโโ
โ Pending โ
โโโโโโโฌโโโโโโ
โ
โผ
โโโโโโโโโโโโโ
โProcessing โ
โโโโโโโฌโโโโโโ
โ
โโโโโโโโโโโดโโโโโโโโโโ
โผ โผ
Successful Failed
โ โ
โผ โผ
Completed Retry eligible?
โ โ
Yes No
โ โ
โผ โผ
Pending DeadLetter
Persisting these states is critical.
If they're only maintained in memory, application termination destroys the synchronization state.
9. ๐ Persisting Business Data and Outbox Messages Atomically
This is the heart of the pattern. Suppose we're creating an order. We should not do this:
await _orders.InsertAsync(order);
await _outbox.EnqueueAsync(message);
If the process terminates between those calls:
Order inserted
โ
โผ
๐ฅ Crash
โ
X
Outbox not inserted
The operation becomes permanently unsynchronized.
Instead, both writes need to participate in the same local database transaction.
Conceptually:
await _database.RunInTransactionAsync(
async transaction =>
{
await transaction.InsertAsync(order);
await transaction.InsertAsync(
CreateOutboxMessage(order));
});
The invariant becomes:
Either:
Order exists
AND
Outbox message exists
OR:
Neither exists
That invariant is the foundation of reliable offline delivery.
10. ๐ฎ Creating the Outbox Message
A factory keeps message creation consistent.
public static class OutboxMessageFactory
{
public static OutboxMessage Create<T>(
string type,
T payload,
string? aggregateId = null)
{
var id = Guid.NewGuid();
return new OutboxMessage
{
Id = id,
Type = type,
Payload = JsonSerializer.Serialize(payload),
Status = OutboxMessageStatus.Pending,
AttemptCount = 0,
CreatedAt = DateTimeOffset.UtcNow,
AggregateId = aggregateId,
IdempotencyKey = id.ToString("N")
};
}
}
For example:
var message =
OutboxMessageFactory.Create(
"order.create.v1",
new CreateOrderMessage(
order.Id,
order.CustomerId,
order.Total),
order.Id.ToString());
Notice the version:
order.create.v1
Persisted messages can survive application upgrades.
Versioning message contracts from the beginning can save significant migration pain later.
11. ๐งพ Message Contracts
Avoid serializing arbitrary application entities directly. Instead of:
JsonSerializer.Serialize(order);
prefer a stable message contract:
public sealed record CreateOrderMessage(
Guid OrderId,
Guid CustomerId,
decimal Total);
Why? Because your domain entity may evolve:
Order v1
โ
โผ
Application update
โ
โผ
Order v2
while an old Outbox message remains pending.
Persisted messages are durable contracts.
Treat them accordingly.
12. ๐๏ธ Designing the Outbox Repository
Define persistence behind an interface.
public interface IOutboxRepository
{
Task<IReadOnlyList<OutboxMessage>> GetPendingAsync(
int batchSize,
DateTimeOffset now,
CancellationToken cancellationToken);
Task MarkProcessingAsync(
Guid id,
CancellationToken cancellationToken);
Task MarkCompletedAsync(
Guid id,
CancellationToken cancellationToken);
Task MarkFailedAsync(
Guid id,
string error,
DateTimeOffset nextAttemptAt,
CancellationToken cancellationToken);
Task MarkDeadLetterAsync(
Guid id,
string reason,
CancellationToken cancellationToken);
}
This keeps the processor independent from a particular SQLite library.
You could implement the repository using:
sqlite-net-pcl
Microsoft.Data.Sqlite
Shaunebu.Data.SQLite
another local persistence abstraction
without changing the processing architecture.
13. โ๏ธ Building the Outbox Processor
Define the processor:
public interface IOutboxProcessor
{
Task<OutboxProcessingResult> ProcessAsync(
CancellationToken cancellationToken = default);
}
And a result:
public sealed record OutboxProcessingResult(
int Processed,
int Completed,
int Failed,
int DeadLettered);
The processor should operate in bounded batches.
public sealed class OutboxProcessor : IOutboxProcessor
{
private readonly IOutboxRepository _repository;
private readonly IOutboxDispatcher _dispatcher;
private readonly SemaphoreSlim _gate = new(1, 1);
public OutboxProcessor(
IOutboxRepository repository,
IOutboxDispatcher dispatcher)
{
_repository = repository;
_dispatcher = dispatcher;
}
public async Task<OutboxProcessingResult> ProcessAsync(
CancellationToken cancellationToken = default)
{
if (!await _gate.WaitAsync(
TimeSpan.Zero,
cancellationToken))
{
return new OutboxProcessingResult(
0, 0, 0, 0);
}
try
{
return await ProcessCoreAsync(
cancellationToken);
}
finally
{
_gate.Release();
}
}
private async Task<OutboxProcessingResult> ProcessCoreAsync(
CancellationToken cancellationToken)
{
var messages =
await _repository.GetPendingAsync(
25,
DateTimeOffset.UtcNow,
cancellationToken);
var completed = 0;
var failed = 0;
var deadLettered = 0;
foreach (var message in messages)
{
cancellationToken.ThrowIfCancellationRequested();
var result =
await ProcessMessageAsync(
message,
cancellationToken);
switch (result)
{
case MessageProcessingResult.Completed:
completed++;
break;
case MessageProcessingResult.Failed:
failed++;
break;
case MessageProcessingResult.DeadLetter:
deadLettered++;
break;
}
}
return new OutboxProcessingResult(
messages.Count,
completed,
failed,
deadLettered);
}
}
The SemaphoreSlim prevents two triggers from processing the same queue simultaneously inside the same process.
That can happen easily when processing is triggered by:
Application startup
Connectivity restored
User presses Sync
Foreground transition
Periodic timer
at nearly the same time.
14. ๐งฉ Designing Message Handlers
The processor shouldn't contain a massive switch statement with every API operation. Instead:
public interface IOutboxMessageHandler
{
string MessageType { get; }
Task HandleAsync(
OutboxMessage message,
CancellationToken cancellationToken);
}
For example:
public sealed class CreateOrderOutboxHandler
: IOutboxMessageHandler
{
private readonly IOrdersApi _api;
public string MessageType =>
"order.create.v1";
public CreateOrderOutboxHandler(
IOrdersApi api)
{
_api = api;
}
public async Task HandleAsync(
OutboxMessage message,
CancellationToken cancellationToken)
{
var payload =
JsonSerializer.Deserialize<CreateOrderMessage>(
message.Payload)
?? throw new InvalidOperationException(
"Invalid outbox payload.");
await _api.CreateOrderAsync(
payload,
message.IdempotencyKey,
cancellationToken);
}
}
Now new operations can be added without modifying the central processor.
15. ๐งญ Dispatching Messages
Create a dispatcher:
public interface IOutboxDispatcher
{
Task DispatchAsync(
OutboxMessage message,
CancellationToken cancellationToken);
}
Implementation:
public sealed class OutboxDispatcher
: IOutboxDispatcher
{
private readonly IReadOnlyDictionary<
string,
IOutboxMessageHandler> _handlers;
public OutboxDispatcher(
IEnumerable<IOutboxMessageHandler> handlers)
{
_handlers = handlers.ToDictionary(
x => x.MessageType,
StringComparer.Ordinal);
}
public Task DispatchAsync(
OutboxMessage message,
CancellationToken cancellationToken)
{
if (!_handlers.TryGetValue(
message.Type,
out var handler))
{
throw new UnknownOutboxMessageException(
message.Type);
}
return handler.HandleAsync(
message,
cancellationToken);
}
}
This gives us:
Outbox Processor
โ
โผ
Dispatcher
โ
โโโ order.create.v1
โโโ order.update.v1
โโโ inspection.submit.v1
โโโ profile.update.v1
โโโ attachment.upload.v1
16. ๐ Processing a Message Safely
The core execution might look like:
private async Task<MessageProcessingResult> ProcessMessageAsync(
OutboxMessage message,
CancellationToken cancellationToken)
{
await _repository.MarkProcessingAsync(
message.Id,
cancellationToken);
try
{
await _dispatcher.DispatchAsync(
message,
cancellationToken);
await _repository.MarkCompletedAsync(
message.Id,
cancellationToken);
return MessageProcessingResult.Completed;
}
catch (OperationCanceledException)
when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
// Classification comes later.
await _repository.MarkFailedAsync(
message.Id,
ex.Message,
DateTimeOffset.UtcNow.AddMinutes(1),
cancellationToken);
return MessageProcessingResult.Failed;
}
}
But there is an important reliability issue here.
Suppose this happens:
API request succeeds
โ
โผ
Server commits operation
โ
โผ
๐ฅ App terminates
โ
X
MarkCompletedAsync never executes
When the app restarts, the message is still pending or processing.
It will be sent again.
This is why the Outbox naturally leads us to at-least-once delivery.
17. ๐ฌ At-Least-Once Delivery
With durable retry, the system can generally guarantee:
A pending operation will continue being attempted until it succeeds or is classified as permanently failed.
But the same message may be delivered more than once.
Attempt #1
โ
โผ
Server processes request
โ
X
Response lost
Attempt #2
โ
โผ
Same request delivered again
Therefore:
Reliable Outbox
+
Retries
=
Possible duplicate delivery
The receiving system must account for this.
18. ๐ฏ Why Exactly-Once Delivery Is Difficult
It is tempting to say:
Send every operation exactly once.
But consider the network failure window again.
The client cannot know whether:
Request never reached server
or:
Request succeeded but response was lost
Both can appear as:
TimeoutException
from the client's perspective.
Retrying is necessary for the first case.
Retrying can duplicate the second case.
Therefore, instead of trying to make transport magically exactly-once, we typically combine:
At-least-once delivery
+
Idempotent processing
to achieve the desired business effect.
19. ๐ชช Idempotency Keys
Each logical operation receives a stable identifier. We already created:
IdempotencyKey = id.ToString("N");
The client sends it with the request. For example:
POST /api/orders
Idempotency-Key: 46dc7d83d2744d84a9c9f36f37e67b90
Every retry uses the same key. Not:
Attempt 1 โ key A
Attempt 2 โ key B
Attempt 3 โ key C
but:
Attempt 1 โโ
Attempt 2 โโผโโโบ 46dc7d83...
Attempt 3 โโ
The key represents the logical operation, not the individual HTTP attempt.
20. ๐ฅ๏ธ Server-Side Idempotency
The backend must participate. Conceptually:
if (await idempotencyStore.ExistsAsync(key))
{
return previousResult;
}
var result =
await orderService.CreateAsync(request);
await idempotencyStore.StoreAsync(
key,
result);
return result;
A production implementation needs stronger transactional guarantees than this simplified example, but the principle is important.
The server remembers:
Idempotency-Key XYZ
โ
โผ
Already processed
โ
โผ
Don't perform business side effect again
Now:
Client retries 5 times
does not mean:
Create 5 orders
It still represents one logical operation.
21. ๐ Retry Policies
Not every failure should retry immediately. Consider:
Attempt
โ
โผ
Failure
โ
โผ
Retry immediately
โ
โผ
Failure
โ
โผ
Retry immediately
If the API is unavailable for ten minutes, aggressive retries waste:
Battery
Network
CPU
Server capacity
A better strategy is delayed retry.
22. ๐ Exponential Backoff
A common retry schedule grows exponentially. For example:
Attempt 1 โ 2 seconds
Attempt 2 โ 4 seconds
Attempt 3 โ 8 seconds
Attempt 4 โ 16 seconds
Attempt 5 โ 32 seconds
We can calculate:
public static TimeSpan CalculateBackoff(
int attempt)
{
var seconds = Math.Min(
Math.Pow(2, attempt),
300);
return TimeSpan.FromSeconds(seconds);
}
The maximum prevents the delay from growing indefinitely.
For mobile synchronization, real intervals may be much larger depending on the workload.
23. ๐ฒ Adding Jitter
Imagine 100,000 devices reconnect after an outage. Without jitter:
All devices retry at 10:00:00
All fail
All retry at 10:00:02
All fail
All retry at 10:00:06
We have created synchronized retry storms. Add randomness:
public static TimeSpan CalculateBackoffWithJitter(
int attempt)
{
var baseSeconds =
Math.Min(
Math.Pow(2, attempt),
300);
var jitter =
Random.Shared.NextDouble() * 2;
return TimeSpan.FromSeconds(
baseSeconds + jitter);
}
Now devices spread their retry attempts.
Device A โ 8.4s
Device B โ 9.1s
Device C โ 8.8s
Device D โ 10.0s
This is better for both the device and the backend.
24. ๐จ Transient vs Permanent Failures
Retrying everything is dangerous. Consider:
HTTP 503
Likely transient.
Retry.
But:
HTTP 400
may indicate an invalid payload.
Retrying the exact same invalid request 500 times won't fix it.
We need failure classification.
public enum OutboxFailureType
{
Transient,
Permanent,
Authentication,
Conflict,
Unknown
}
A classifier might inspect:
Timeout
Network unavailable
HTTP status
Authentication failure
Validation failure
Serialization failure
Then processing policy can differ.
25. ๐งฏ Example Failure Policy
A practical policy might look like:
| Failure | Classification | Action |
|---|---|---|
| No connectivity | Transient | Wait |
| Timeout | Transient | Retry |
| HTTP 429 | Transient | Backoff |
| HTTP 500 | Transient | Retry |
| HTTP 503 | Transient | Retry |
| HTTP 400 | Permanent | Dead-letter |
| HTTP 401 | Authentication | Refresh/auth flow |
| HTTP 403 | Permanent/authorization | Stop |
| HTTP 409 | Conflict | Conflict policy |
| Invalid payload | Permanent | Dead-letter |
The exact policy depends on the API contract.
26. โ ๏ธ Poison Messages
A poison message is an operation that repeatedly fails and prevents useful progress. For example:
Message A โ invalid payload
Message B โ valid
Message C โ valid
If processing always stops at A:
A fails
โ
stop
then B and C may never execute.
After a defined number of attempts, we may move A to:
DeadLetter
Example:
if (message.AttemptCount >= 10)
{
await _repository.MarkDeadLetterAsync(
message.Id,
"Maximum retry count exceeded.",
cancellationToken);
return MessageProcessingResult.DeadLetter;
}
But don't blindly dead-letter operations simply because a server was unavailable for a long period.
Retry count alone isn't always enough.
Consider:
Failure type
Elapsed age
Connectivity state
Server response
Business criticality
when defining the policy.
27. ๐งญ Message Ordering
Suppose the user performs:
1. Create customer
2. Update customer
3. Delete customer
If the Outbox sends them as:
3
1
2
the final server state may be incorrect.
Some operations require ordering.
A simple strategy is:
ORDER BY CreatedAt ASC
But global ordering can unnecessarily serialize unrelated work.
28. ๐ Per-Aggregate Ordering
Suppose we have:
Customer A:
Create
Update
Update
Customer B:
Create
Update
Operations for A may require ordering relative to each other.
Operations for B may require ordering relative to B.
But A and B may potentially execute independently.
This is why AggregateId can be useful.
Aggregate A
โโโ Message 1
โโโ Message 2
โโโ Message 3
Aggregate B
โโโ Message 1
โโโ Message 2
A more advanced Outbox processor can preserve ordering per aggregate while allowing bounded parallelism across aggregates.
29. โก Should We Process in Parallel?
Parallel processing improves throughput. But this:
await Task.WhenAll(
messages.Select(ProcessMessageAsync));
can be dangerous.
Potential problems:
Ordering violations
Database contention
API rate limits
Battery usage
Network saturation
Race conditions
For many mobile applications, sequential or low-concurrency processing is preferable.
If concurrency is needed, make it explicit and bounded.
For example:
Maximum parallelism = 2 or 3
rather than launching hundreds of requests.
Mobile synchronization is not a data-center batch processor.
30. ๐ถ Connectivity-Aware Processing
.NET MAUI exposes connectivity information that can be used as a signal. Conceptually:
if (Connectivity.Current.NetworkAccess
!= NetworkAccess.Internet)
{
return;
}
But remember:
NetworkAccess.Internet
does not guarantee:
Your API is reachable.
Connectivity is useful for deciding:
Is attempting remote delivery reasonable right now?
The HTTP result still determines actual delivery success.
31. ๐ Reacting to Connectivity Changes
Instead of constantly polling:
Is internet available?
Is internet available?
Is internet available?
we can react to connectivity changes.
Conceptually:
Connectivity.Current.ConnectivityChanged +=
OnConnectivityChanged;
Then:
private async void OnConnectivityChanged(
object? sender,
ConnectivityChangedEventArgs e)
{
if (e.NetworkAccess != NetworkAccess.Internet)
return;
try
{
await _outboxProcessor.ProcessAsync();
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Outbox processing failed after connectivity change.");
}
}
In production, lifecycle ownership and event unsubscription must be handled carefully.
Also remember that async void should generally be limited to event-handler boundaries.
32. ๐ฑ App Lifecycle Integration
The Outbox processor can have several triggers:
App startup
โ
โผ
Process pending messages
App resumes
โ
โผ
Process pending messages
Connectivity restored
โ
โผ
Process pending messages
User requests sync
โ
โผ
Process pending messages
The important point is that these are triggers, not separate processors.
All should converge on the same concurrency-safe Outbox service.
Startup โโโโโโโโโโโโโ
Resume โโโโโโโโโโโโโโค
Connectivity โโโโโโโโผโโโบ OutboxProcessor
Manual Sync โโโโโโโโโ
This is why duplicate execution protection matters.
33. ๐ Recovering After Application Restart
Suppose the application terminates while a message is:
Processing
On restart, that record cannot remain permanently stuck.
We need recovery semantics.
One strategy is a processing lease.
Add:
public DateTimeOffset? ProcessingStartedAt { get; set; }
When processing begins:
Status = Processing
ProcessingStartedAt = now
At startup, inspect stale records:
Status = Processing
AND
ProcessingStartedAt < now - timeout
Those records can be returned to:
Pending
because the previous process no longer owns them.
34. ๐ Processing Leases
A more explicit model uses:
public string? LeaseId { get; set; }
public DateTimeOffset? LeaseExpiresAt { get; set; }
Processor:
Acquire message
โ
โผ
LeaseId = processor-instance
LeaseExpiresAt = now + 2 minutes
If the process dies, the lease eventually expires.
Another processor can reclaim the operation.
For a single-device local database, this may be more machinery than many applications need, but the concept becomes valuable as synchronization complexity increases.
35. ๐ Authentication and the Outbox
Queued operations can survive longer than access tokens. Example:
08:00 User creates operation offline
08:30 Access token expires
10:00 Connectivity returns
The Outbox processor shouldn't assume the token captured at 08:00 remains valid.
More importantly:
Never persist access tokens inside Outbox payloads.
Persist business intent:
{
"orderId": "...",
"customerId": "...",
"total": 125.50
}
not:
{
"accessToken": "eyJ...",
"orderId": "..."
}
Authentication should be resolved at execution time by the HTTP/authentication layer.
36. ๐ What Happens on 401?
Suppose delivery returns:
401 Unauthorized
A reasonable architecture is:
Outbox Handler
โ
โผ
HTTP pipeline
โ
โผ
401
โ
โผ
Authentication Service
โ
โผ
Refresh token
โ โ
Success Failure
โ โ
โผ โผ
Retry Pause Outbox
+
Require login
Avoid having every Outbox handler implement its own authentication logic.
Authentication belongs in a shared HTTP/authentication layer.
37. โ๏ธ Conflict Resolution
Offline operation introduces another problem. Suppose:
Device:
Customer.Name = "Alice Smith"
while offline.
Meanwhile another client changes the server:
Server:
Customer.Name = "Alice Johnson"
When the device reconnects, which value wins?
The Outbox guarantees delivery.
It does not automatically resolve business conflicts.
These are separate concerns.
Possible policies include:
Client wins
Server wins
Last-write-wins
Version-based concurrency
ETags
Manual merge
Field-level merge
Domain-specific conflict resolution
For important business data, explicit versioning is usually preferable to silently overwriting newer state.
38. ๐งฌ Optimistic Concurrency
Suppose the client downloaded:
Order version = 12
The update message can include:
public sealed record UpdateOrderMessage(
Guid OrderId,
int ExpectedVersion,
string Status);
The server receives:
ExpectedVersion = 12
but current server state is:
Version = 14
Instead of silently overwriting data, the server returns a conflict.
409 Conflict
The Outbox can classify this as:
Conflict
and route it through a separate resolution policy.
39. ๐ Attachments and Large Payloads
Do not automatically store huge binary payloads directly in the Outbox table. Imagine:
10 MB image
ร
100 pending uploads
Now your Outbox contains roughly:
1 GB
of serialized payloads.
A better model is often:
Outbox Message
โ
โผ
Metadata
โ
โโโ File path
โโโ Content type
โโโ Size
โโโ Hash
For example:
public sealed record UploadPhotoMessage(
Guid InspectionId,
string LocalFilePath,
string ContentType,
string Sha256);
The actual file remains in application-managed storage.
The Outbox contains the durable instruction to upload it.
40. โ ๏ธ File Lifetime Matters
If an Outbox message references:
/cache/photo.jpg
the operating system may delete it before synchronization.
Durable Outbox operations require durable resources. For pending uploads, use application-owned persistent storage appropriate for the platform and your data model.
Then clean up the file only after:
Upload succeeded
AND
Outbox message completed
41. ๐งน Outbox Cleanup
Completed messages shouldn't remain forever unless there is a specific audit requirement. Otherwise:
10 operations/day
โ 3,650/year
1,000 operations/day
โ 365,000/year
A cleanup policy might delete:
Completed messages older than 7 days
or:
Completed messages older than 30 days
depending on diagnostic and auditing requirements.
For example:
public interface IOutboxMaintenanceService
{
Task<int> DeleteCompletedBeforeAsync(
DateTimeOffset threshold,
CancellationToken cancellationToken);
}
Do not automatically delete:
Failed
DeadLetter
Conflict
records before they have been appropriately handled or diagnosed.
42. ๐ Outbox Diagnostics
Expose operational state.
public sealed record OutboxDiagnostics(
int PendingCount,
int ProcessingCount,
int FailedCount,
int DeadLetterCount,
DateTimeOffset? OldestPendingMessage,
DateTimeOffset? LastSuccessfulDelivery);
Now the application can understand:
Pending: 17
Processing: 1
Failed: 2
Dead Letter: 0
Oldest Pending: 8 minutes
Last Delivery: 24 seconds ago
This becomes valuable for both support and automated health monitoring.
43. ๐ฉบ Integrating with Application Health Monitoring
The Outbox is a perfect health signal. Suppose:
Pending = 3
Oldest = 2 minutes
That's probably healthy.
But:
Pending = 2,714
Oldest = 31 hours
Last successful delivery = 29 hours
Something is wrong.
A health check might classify:
Healthy:
queue progressing normally
Degraded:
queue growing / delayed
Unhealthy:
no successful delivery for excessive period
Unknown:
no synchronization attempt yet
This integrates naturally with a production health-monitor architecture.
44. ๐ Structured Logging
Outbox events should be logged structurally. Instead of:
_logger.LogInformation(
"Message processed.");
prefer:
_logger.LogInformation(
"Outbox message {MessageId} of type {MessageType} completed after {AttemptCount} attempts",
message.Id,
message.Type,
message.AttemptCount);
For failures:
_logger.LogWarning(
ex,
"Outbox message {MessageId} failed on attempt {AttemptCount}",
message.Id,
message.AttemptCount);
Useful dimensions include:
MessageId
MessageType
AggregateId
AttemptCount
FailureType
Duration
QueueAge
Status
Avoid logging the complete payload by default.
It may contain sensitive business information.
45. ๐ Useful Metrics
Useful Outbox metrics include:
outbox_pending_count
outbox_failed_count
outbox_deadletter_count
outbox_delivery_duration
outbox_message_age
outbox_retry_count
outbox_delivery_success_count
One particularly important metric is:
age of oldest pending message
Why? Because:
PendingCount = 500
might be completely normal during a large synchronization. But:
OldestPendingAge = 72 hours
strongly suggests stalled delivery.
46. ๐ค User Experience
The Outbox should improve UX rather than expose infrastructure terminology. Don't show:
Outbox message 9f3a... is Pending.
Show:
Saved offline
or:
3 changes waiting to sync
or:
All changes synchronized
A useful model might be:
public enum SynchronizationState
{
Synchronized,
Pending,
Synchronizing,
Failed
}
Then map internal Outbox state into user-facing state.
47. โก Optimistic UI
The Outbox works particularly well with optimistic UI. When the user creates an order:
Tap Save
โ
โผ
Local transaction
โ
โโโ Save Order
โโโ Save Outbox
โ
โผ
COMMIT
โ
โผ
UI immediately shows order
โ
โผ
Background delivery later
The user doesn't need to wait for the network before continuing.
This can make offline-first applications feel dramatically faster.
But the UI should still represent synchronization status when relevant.
48. ๐งช Testing the Outbox
This architecture deserves serious tests. At minimum, test:
Message persisted
Message delivered
Failed message retained
Retry scheduling
Permanent failure
Dead-letter behavior
Cancellation
Unknown message type
Malformed payload
Concurrent processor invocation
Ordering
Idempotency key stability
Application restart recovery
49. ๐งช Testing Atomic Persistence
One of the most important tests verifies:
Business entity
+
Outbox message
are atomic.
Failure scenario:
Insert business entity
โ
โผ
Inject failure
โ
X
Insert Outbox
Expected:
Transaction rolls back
Business entity = absent
Outbox message = absent
The inverse should also be impossible. You should never observe:
Business entity exists
Outbox missing
for an operation that requires synchronization.
50. ๐งช Testing Duplicate Delivery
Simulate:
Server processes request
โ
โผ
Client receives timeout
Then retry the same Outbox message.
Expected server state:
1 business operation
not:
2 business operations
This verifies the complete reliability contract:
Outbox
+
Retry
+
Idempotency
Testing only the client isn't enough to validate this property.
51. ๐ฃ Failure Injection
Production reliability patterns become much easier to trust when failure is deliberately injected during tests. Test failures at:
Before local transaction
During local transaction
After local COMMIT
Before HTTP request
During HTTP request
After server commit
Before client receives response
Before MarkCompleted
During cleanup
During application restart
The most interesting reliability bugs usually live between steps, not inside the happy path.
52. ๐ Security
An Outbox is persistent storage. That means its contents may survive:
Application restart
Device reboot
Long offline periods
Application upgrades
Don't store unnecessary secrets.
Avoid:
Passwords
Access tokens
Refresh tokens
Raw authorization headers
Sensitive credentials
If queued business data itself is sensitive, consider the security guarantees required for local storage and whether additional encryption is appropriate for your threat model.
Also sanitize diagnostic output. This:
MessageType = payment.submit.v1
Status = Failed
HTTP = 503
may be appropriate.
Dumping the complete payment payload into logs probably isn't.
53. ๐ Performance
An Outbox should improve reliability without making every operation expensive. Important considerations include:
Database indexes
Batch sizes
Serialization cost
Transaction duration
Queue scanning
Cleanup frequency
Parallelism
Payload size
Useful indexes may include:
Status
NextAttemptAt
CreatedAt
AggregateId
because the processor frequently needs queries conceptually similar to:
WHERE Status = 'Pending'
AND NextAttemptAt <= @Now
ORDER BY CreatedAt
LIMIT @BatchSize
Without appropriate indexing, a large Outbox can become increasingly expensive to scan.
54. ๐ฆ Batch Processing
Don't load every pending operation into memory. Instead:
10,000 pending messages
should become:
Batch 1 โ 25
Batch 2 โ 25
Batch 3 โ 25
...
This bounds:
Memory
Database reads
Network activity
Processing time
and gives the lifecycle an opportunity to interrupt processing cleanly.
55. ๐ Battery Awareness
Reliable doesn't mean aggressive. A mobile Outbox shouldn't constantly wake the device trying to deliver low-priority work. Potential signals include:
Application foreground state
Connectivity
Battery state
Message priority
Queue age
User action
Platform background constraints
For example, a user-submitted critical operation may justify immediate delivery.
Telemetry might not.
The Outbox processor can eventually support priority:
public enum OutboxPriority
{
Low,
Normal,
High,
Critical
}
But avoid introducing complexity until the application actually needs it.
56. ๐ง Outbox vs Generic Background Queue
A generic in-memory queue might look like:
Channel<Func<Task>>
and can be excellent for process-local work.
But it doesn't automatically provide durable delivery.
Application terminated
โ
โผ
Memory disappears
โ
โผ
Queue disappears
An Outbox is different because the work intent is persisted.
| Capability | In-Memory Queue | Persistent Outbox |
|---|---|---|
| Fast execution | โ | โ |
| Survives app restart | โ | โ |
| Offline delivery | โ | โ |
| Durable retry state | โ | โ |
| Auditable pending work | โ | โ |
| Requires persistence | โ | โ |
Use the right abstraction for the reliability requirement.
57. ๐ Outbox vs Retry Policy
A retry policy answers:
How should this operation retry during this execution?
An Outbox answers:
How do we preserve this operation so it can still execute after the current process, network session, or application lifecycle ends?
They complement each other.
Outbox
โ
โผ
Durable operation
โ
โผ
Processor
โ
โผ
Retry Policy
โ
โผ
HTTP
Retry without persistence is not durable delivery. Outbox without sensible retry can create unnecessary network pressure.
58. ๐๏ธ Outbox vs Offline Database
Having SQLite doesn't automatically make an application offline-first. This:
await database.SaveAsync(order);
solves local persistence.
It doesn't answer:
How does the server eventually receive this?
How are retries tracked?
How are duplicates prevented?
How are conflicts handled?
What happens after restart?
Offline-first architecture requires coordination between:
Local state
Remote state
Synchronization
Conflict policy
Delivery guarantees
The Outbox handles an important part of that problem.
59. ๐๏ธ Production Architecture
Putting everything together:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ .NET MAUI UI โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Application Use Case โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โผ
LOCAL TRANSACTION
โโโโโโโโโโโโโโดโโโโโโโโโโโโโ
โ โ
โผ โผ
Business Tables Outbox Table
โ โ
โโโโโโโโโโโโโโฌโโโโโโโโโโโโโ
โ
COMMIT
โ
โผ
Local operation
complete
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Outbox Processor โ
โ โ
โ Batch selection โ
โ Concurrency gate โ
โ Retry scheduling โ
โ Backoff + jitter โ
โ Failure classification โ
โ Ordering โ
โ Dead-letter policy โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Outbox Dispatcher โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโ
โผ โผ โผ
Order Handler Inspection Handler Upload Handler
โ โ โ
โโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโ
โ
โผ
HTTP Layer
โ
Authentication
โ
โผ
REST API
โ
โผ
Idempotency Handler
โ
โผ
Business Logic
โ
โผ
Database
And surrounding the pipeline:
โโโโโโโโโโโโโโโโโโโโ
โ Connectivity โ
โโโโโโโโโโฌโโโโโโโโโโ
โ
โผ
Lifecycle โโโโโโโโบ Outbox Processor โโโโโ Manual Sync
โ
โผ
Diagnostics
โ
โโโโโโโโโโโโโผโโโโโโโโโโโโ
โผ โผ โผ
Logging Telemetry Health
This is no longer simply a retry mechanism.
It is a durable delivery subsystem.
60. โ ๏ธ Common Mistake: Saving the Outbox Separately
This:
await SaveOrderAsync();
await AddOutboxMessageAsync();
does not provide the central Outbox guarantee.
There is still a failure window.
Both writes must be part of the same local transaction when they represent one atomic business operation.
61. โ ๏ธ Common Mistake: Generating a New Idempotency Key on Retry
Never do:
foreach (var attempt in retries)
{
var key = Guid.NewGuid();
await SendAsync(key);
}
Every attempt now looks like a new business operation.
Generate the key when the Outbox message is created and persist it.
Logical operation
โ
โผ
Stable Idempotency Key
โ
โโโ Attempt 1
โโโ Attempt 2
โโโ Attempt N
62. โ ๏ธ Common Mistake: Infinite Immediate Retry
This is not resilience:
while (true)
{
try
{
await SendAsync();
break;
}
catch
{
}
}
It's a battery-draining denial-of-service loop against your own backend. ๐ Use:
Failure classification
Backoff
Jitter
Maximum policy where appropriate
Connectivity signals
Cancellation
63. โ ๏ธ Common Mistake: Treating Every 4xx as Permanent
Although many client errors indicate permanent problems, semantics matter. For example:
401
may be recoverable through token refresh.
408
may be transient.
409
may require conflict resolution.
429
explicitly asks the client to slow down. HTTP status alone isn't always enough; interpret it according to your API contract.
64. โ ๏ธ Common Mistake: Assuming Connectivity Means Success
This:
Connectivity.Current.NetworkAccess
== NetworkAccess.Internet
is a useful signal.
It doesn't prove:
DNS works
API works
TLS works
Authentication works
Server is healthy
Always treat actual request results as authoritative for delivery.
65. โ ๏ธ Common Mistake: Blocking the UI Until Synchronization
If the operation can safely be performed offline, avoid:
User taps Save
โ
โผ
Wait for network
โ
โผ
Wait for API
โ
โผ
Save complete
The Outbox allows:
User taps Save
โ
โผ
Atomic local persistence
โ
โผ
UI continues
โ
โผ
Remote synchronization later
This is one of the major UX advantages of the pattern.
66. โ ๏ธ Common Mistake: Confusing Delivery with Business Success
Suppose an Outbox message reaches the server successfully, but the server rejects the business operation because of a conflict. Technically:
Transport succeeded.
Business-wise:
Operation did not complete.
Your synchronization model needs to distinguish:
Transport failure
Authentication failure
Validation failure
Concurrency conflict
Business rejection
Successful completion
A simple boolean:
bool Success;
often becomes insufficient.
67. ๐ Best Practices
When implementing an Outbox in .NET MAUI:
- ๐ฌ Persist remote delivery intent durably.
- ๐ Save business data and its Outbox message in the same local transaction.
- ๐งพ Use explicit, versioned message contracts.
- ๐ชช Generate a stable idempotency key per logical operation.
- ๐ฅ๏ธ Implement idempotency on the serverโnot only the client.
- ๐ Assume at-least-once delivery.
- ๐ Use exponential backoff for transient failures.
- ๐ฒ Add jitter to avoid synchronized retry storms.
- ๐จ Distinguish transient, permanent, authentication, and conflict failures.
- โ ๏ธ Provide a strategy for poison messages.
- ๐งญ Preserve ordering where business semantics require it.
- โก Use bounded concurrency rather than unlimited parallelism.
- ๐ถ Treat connectivity as a hint, not proof of API availability.
- ๐ฑ Integrate processing with lifecycle events carefully.
- ๐ Recover stale
Processingmessages after restart. - ๐ Resolve authentication at delivery time.
- ๐ Never persist access tokens inside Outbox payloads.
- โ๏ธ Treat conflict resolution as a separate architectural concern.
- ๐ Store large binary payloads outside the Outbox table when appropriate.
- ๐งน Clean completed records according to a retention policy.
- ๐ Monitor queue depth and, especially, oldest-message age.
- ๐ Use structured logging without dumping sensitive payloads.
- ๐งช Test process termination and ambiguous network failures.
- ๐ฃ Inject failures between architectural steps.
- ๐ Design synchronization for mobile battery and lifecycle constraints.
๐ฏ Conclusion
Offline reliability is not simply:
Detect no internet
+
Try again later
The real problem is maintaining a durable relationship between what the user did locally and what must eventually happen remotely.
Without an explicit architecture, seemingly simple operations create dangerous failure windows:
Save locally
โ
โผ
Send remotely
What happens between those two steps determines whether the system is reliable.
The Outbox Pattern changes the architecture:
Local Transaction
โโโโโโโโโโดโโโโโโโโโ
โผ โผ
Business State Delivery Intent
โ โ
โโโโโโโโโโฌโโโโโโโโโ
โ
COMMIT
โ
โผ
Durable Outbox
โ
โผ
Outbox Processor
โ
Retry + Backoff
โ
โผ
REST API
โ
โผ
Idempotency
Now application termination doesn't automatically lose pending work.
Network outages don't require users to repeat actions manually.
Retries don't have to create duplicate business effects.
Application restarts don't erase synchronization intent.
And synchronization becomes something we can observe, test, reason about, and recover.
The most important concept is that the Outbox does not magically create exactly-once distributed transactions.
Instead, it gives us a practical reliability model:
Atomic local persistence
+
Durable delivery intent
+
At-least-once delivery
+
Server-side idempotency
+
Explicit conflict handling
=
Reliable offline synchronization
For a simple application that always requires connectivity, this architecture may be unnecessary.
But once a .NET MAUI application must survive unreliable networks, process termination, offline operation, delayed synchronization, and ambiguous HTTP failures, an Outbox can become one of the most valuable patterns in the mobile architecture. ๐ฌ๐
๐ References
- Microsoft Learn โ .NET MAUI Connectivity https://learn.microsoft.com/dotnet/maui/platform-integration/communication/networking
- Microsoft Learn โ .NET MAUI App Lifecycle https://learn.microsoft.com/dotnet/maui/fundamentals/app-lifecycle
- Microsoft Learn โ .NET MAUI Dependency Injection https://learn.microsoft.com/dotnet/maui/fundamentals/dependency-injection
- Microsoft Learn โ
HttpClientGuidelines for .NET https://learn.microsoft.com/dotnet/fundamentals/networking/http/httpclient-guidelines - Microsoft Learn โ Cancellation in Managed Threads https://learn.microsoft.com/dotnet/standard/threading/cancellation-in-managed-threads
- Microsoft Learn โ
SemaphoreSlimhttps://learn.microsoft.com/dotnet/api/system.threading.semaphoreslim - Microsoft Learn โ System.Text.Json https://learn.microsoft.com/dotnet/standard/serialization/system-text-json/overview
- Microsoft Learn โ SQLite with .NET MAUI https://learn.microsoft.com/dotnet/maui/data-cloud/database-sqlite
- Microsoft โ Cloud Design Patterns: Retry Pattern https://learn.microsoft.com/azure/architecture/patterns/retry
- Microsoft โ Cloud Design Patterns: Compensating Transaction Pattern https://learn.microsoft.com/azure/architecture/patterns/compensating-transaction
- Microsoft โ Cloud Design Patterns: Queue-Based Load Leveling https://learn.microsoft.com/azure/architecture/patterns/queue-based-load-leveling
- Microsoft โ Microservices Architecture: Transactional Outbox Pattern https://learn.microsoft.com/azure/architecture/databases/guide/transactional-outbox-cosmos
- IETF โ HTTP Semantics: Idempotent Methods https://www.rfc-editor.org/rfc/rfc9110.html#name-idempotent-methods
Was this useful?
Sign in to react. Guest comments are still welcome.




Comments (0)
No approved comments yet.