# Azure Table Persistence Usage with Transactions This sample demonstrates a client/server scenario using sagas and outbox persistences to store records atomically by leveraging transactions. ## Projects ### SharedMessages The shared message contracts used by all endpoints. ### Client * Sends the `StartOrder` message to `Server`. * Receives and handles the `OrderCompleted` event. ### Server projects * Receive the `StartOrder` message and initiate an `OrderSaga`. * `OrderSaga` requests a timeout with an instance of `CompleteOrder` with the saga data. * Receive the `OrderShipped` message with a custom header. * `OrderSaga` publishes an `OrderCompleted` event when the `CompleteOrder` timeout is triggered. ### Persistence config Configure the endpoint to use Azure Table Persistence. ```cs var endpointConfiguration = new EndpointConfiguration("Samples.AzureTable.Transactions.Server"); endpointConfiguration.EnableOutbox(); var useStorageTable = true; var persistence = endpointConfiguration.UsePersistence(); var connection = useStorageTable ? "UseDevelopmentStorage=true" : "TableEndpoint=https://localhost:8081/;AccountName=AzureTableSamples;AccountKey=C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="; var tableServiceClient = new TableServiceClient(connection); persistence.UseTableServiceClient(tableServiceClient); persistence.DefaultTable("Server"); ``` The OrderId is used as the partition key. ## Using Behaviors The following shows two different ways to provide OrderIDs to the saga using [behaviors](/nservicebus/pipeline/manipulate-with-behaviors.md). 1. Most messages implement `IProvideOrderId` allowing the OrderId to be used as the partition key. ```cs class OrderIdAsPartitionKeyBehavior : Behavior { public OrderIdAsPartitionKeyBehavior(IProvidePartitionKeyFromSagaId partitionKeyFromSagaId, ILogger logger) { partitionKeyFromSagaId1 = partitionKeyFromSagaId; this.logger = logger; } public override async Task Invoke(IIncomingLogicalMessageContext context, Func next) { var correlationProperty = SagaCorrelationProperty.None; if (context.Message.Instance is IProvideOrderId provideOrderId) { var partitionKeyValue = provideOrderId.OrderId; correlationProperty = new SagaCorrelationProperty("OrderId", partitionKeyValue); } await partitionKeyFromSagaId1.SetPartitionKey(context, correlationProperty); if (context.Headers.TryGetValue(Headers.SagaId, out var sagaIdHeader)) { logger.LogInformation("Saga Id Header: {SagaIdHeader}", sagaIdHeader); } if (context.Extensions.TryGet(out var tableInformation)) { logger.LogInformation("Table Information: {TableName}", tableInformation.TableName); } logger.LogInformation("Found partition key '{PartitionKey}' from '{Provider}'", context.Extensions.Get().PartitionKey, nameof(IProvideOrderId)); await next(); } public class Registration : RegisterStep { public Registration() : base(nameof(OrderIdAsPartitionKeyBehavior), typeof(OrderIdAsPartitionKeyBehavior), "Determines the PartitionKey from the logical message", provider => new OrderIdAsPartitionKeyBehavior( provider.GetRequiredService(), provider.GetRequiredService>() )) { InsertBefore(nameof(LogicalOutboxBehavior)); } } IProvidePartitionKeyFromSagaId partitionKeyFromSagaId1; private readonly ILogger logger; } ``` 2. One handler publishes an event that does not implement `IProvideOrderId` but adds a custom header containing the OrderId. The handler also creates `OrderShippingInformation` as part of the transactional batch provided by NServiceBus. ```cs public class ShipOrderHandler (ILogger logger): IHandleMessages { public Task Handle(ShipOrder message, IMessageHandlerContext context) { var orderShippingInformation = new OrderShippingInformation { Id = Guid.NewGuid(), OrderId = message.OrderId, ShippedAt = DateTimeOffset.UtcNow, }; Store(orderShippingInformation, context); logger.LogInformation("Order Shipped. OrderId {OrderId}", message.OrderId); var options = new PublishOptions(); options.SetHeader("Sample.AzureTable.Transaction.OrderId", message.OrderId.ToString()); return context.Publish(new OrderShipped { OrderId = orderShippingInformation.OrderId, ShippingDate = orderShippingInformation.ShippedAt }, options); } private static void Store(OrderShippingInformation orderShippingInformation, IMessageHandlerContext context) { var session = context.SynchronizedStorageSession.AzureTablePersistenceSession(); orderShippingInformation.PartitionKey = session.PartitionKey; session.Batch.Add(new TableTransactionAction(TableTransactionActionType.Add, orderShippingInformation)); } } ``` The custom header added then allows the partition key to be determined within `OrderIdHeaderAsPartitionKeyBehavior`. ```cs class OrderIdHeaderAsPartitionKeyBehavior(ILogger logger) : Behavior { public override Task Invoke(ITransportReceiveContext context, Func next) { if (context.Message.Headers.TryGetValue("Sample.AzureTable.Transaction.OrderId", out var orderId)) { logger.LogInformation("Found partition key '{PartitionKey}' from header 'Sample.AzureTable.Transaction'", orderId); context.Extensions.Set(new TableEntityPartitionKey(orderId)); } return next(); } } ``` Finally, the above behaviors are registered in the pipeline. ```cs var serviceProvider = builder.Services.BuildServiceProvider(); var logger = serviceProvider.GetRequiredService>(); endpointConfiguration.Pipeline.Register(new OrderIdHeaderAsPartitionKeyBehavior(logger), "Extracts a partition key from a header"); endpointConfiguration.Pipeline.Register(new OrderIdAsPartitionKeyBehavior.Registration()); ``` ## Order saga data ```cs public class OrderSagaData : ContainSagaData { public Guid OrderId { get; set; } public string OrderDescription { get; set; } } ``` ## Order saga ```cs public class OrderSaga(ILogger logger) : Saga, IAmStartedByMessages, IHandleMessages, IHandleTimeouts { protected override void ConfigureHowToFindSaga(SagaPropertyMapper mapper) { mapper.MapSaga(saga => saga.OrderId) .ToMessage(msg => msg.OrderId) .ToMessageHeader("Sample.AzureTable.Transaction.OrderId"); } public Task Handle(StartOrder message, IMessageHandlerContext context) { var orderDescription = $"The saga for order {message.OrderId}"; Data.OrderDescription = orderDescription; logger.LogInformation("Received StartOrder message {OrderId}. Starting Saga", Data.OrderId); var shipOrder = new ShipOrder { OrderId = message.OrderId }; logger.LogInformation("Order will complete in 5 seconds"); var timeoutData = new CompleteOrder { OrderDescription = orderDescription, OrderId = Data.OrderId, }; return Task.WhenAll( context.SendLocal(shipOrder), RequestTimeout(context, TimeSpan.FromSeconds(5), timeoutData) ); } public Task Handle(OrderShipped message, IMessageHandlerContext context) { logger.LogInformation("Order with OrderId {OrderId} shipped on {ShippingDate}", Data.OrderId, message.ShippingDate); return Task.CompletedTask; } public Task Timeout(CompleteOrder state, IMessageHandlerContext context) { logger.LogInformation("Saga with OrderId {OrderId} completed", Data.OrderId); MarkAsComplete(); var orderCompleted = new OrderCompleted { OrderId = Data.OrderId }; return context.Publish(orderCompleted); } } ```