Chapter 11: Service Layer
Clean Architecture Implementation for Multi-Tenant POS
This chapter provides the complete service layer architecture, including interfaces, implementations, unit of work patterns, and transaction handling.
11.1 Clean Architecture Overview
┌─────────────────────────────────────────────────────────────────┐
│ API Controllers │
│ ItemsController, SalesController, InventoryController, etc. │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Application Services │
│ IOrderService, IInventoryService, ICustomerService, etc. │
│ (Business logic, orchestration, validation) │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Domain Layer │
│ Entities, Value Objects, Domain Events, Business Rules │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Infrastructure Layer │
│ Repositories, DbContext, External Services, Messaging │
└─────────────────────────────────────────────────────────────────┘
11.2 Project Structure
src/
├── POS.Api/ # ASP.NET Core Web API
│ ├── Controllers/
│ ├── Middleware/
│ └── Program.cs
│
├── POS.Application/ # Application Services
│ ├── Interfaces/
│ │ ├── IOrderService.cs
│ │ ├── IInventoryService.cs
│ │ ├── ICustomerService.cs
│ │ ├── IItemService.cs
│ │ └── IReportService.cs
│ ├── Services/
│ │ ├── OrderService.cs
│ │ ├── InventoryService.cs
│ │ └── ...
│ ├── DTOs/
│ └── Validators/
│
├── POS.Domain/ # Domain Layer
│ ├── Entities/
│ ├── ValueObjects/
│ ├── Events/
│ └── Exceptions/
│
└── POS.Infrastructure/ # Infrastructure Layer
├── Persistence/
│ ├── PosDbContext.cs
│ ├── Repositories/
│ └── Configurations/
├── External/
└── Messaging/
11.3 Service Interfaces
11.3.1 IOrderService
// File: src/POS.Application/Interfaces/IOrderService.cs
using POS.Application.DTOs;
using POS.Domain.Common;
namespace POS.Application.Interfaces;
public interface IOrderService
{
// Query operations
Task<PagedResult<OrderSummaryDto>> GetOrdersAsync(
OrderQueryParams query,
CancellationToken ct = default);
Task<OrderDto?> GetByIdAsync(string orderId, CancellationToken ct = default);
Task<OrderDto?> GetByReceiptNumberAsync(
string receiptNumber,
CancellationToken ct = default);
// Command operations
Task<Result<OrderDto>> CreateOrderAsync(
CreateOrderRequest request,
CancellationToken ct = default);
Task<Result<OrderDto>> ProcessReturnAsync(
string orderId,
ProcessReturnRequest request,
CancellationToken ct = default);
Task<Result<OrderDto>> VoidOrderAsync(
string orderId,
VoidOrderRequest request,
CancellationToken ct = default);
// Receipt operations
Task<ReceiptDto> GetReceiptAsync(string orderId, CancellationToken ct = default);
Task<Result> PrintReceiptAsync(
string orderId,
PrintReceiptRequest request,
CancellationToken ct = default);
// Held orders (park/recall)
Task<Result<OrderDto>> HoldOrderAsync(
HoldOrderRequest request,
CancellationToken ct = default);
Task<IReadOnlyList<HeldOrderDto>> GetHeldOrdersAsync(
string locationId,
CancellationToken ct = default);
Task<Result<OrderDto>> RecallHeldOrderAsync(
string heldOrderId,
CancellationToken ct = default);
}
11.3.2 IInventoryService
// File: src/POS.Application/Interfaces/IInventoryService.cs
namespace POS.Application.Interfaces;
public interface IInventoryService
{
// Query operations
Task<InventoryLevelDto?> GetInventoryLevelAsync(
string itemId,
string locationId,
CancellationToken ct = default);
Task<IReadOnlyList<InventoryLevelDto>> GetInventoryByItemAsync(
string itemId,
CancellationToken ct = default);
Task<PagedResult<InventoryLevelDto>> GetInventoryByLocationAsync(
string locationId,
InventoryQueryParams query,
CancellationToken ct = default);
// Adjustment operations
Task<Result<AdjustmentDto>> AdjustInventoryAsync(
AdjustInventoryRequest request,
CancellationToken ct = default);
Task<Result<TransferDto>> CreateTransferAsync(
CreateTransferRequest request,
CancellationToken ct = default);
Task<Result<TransferDto>> ReceiveTransferAsync(
string transferId,
ReceiveTransferRequest request,
CancellationToken ct = default);
// Count operations
Task<Result<CountDto>> StartCountAsync(
StartCountRequest request,
CancellationToken ct = default);
Task<Result<CountDto>> UpdateCountAsync(
string countId,
UpdateCountRequest request,
CancellationToken ct = default);
Task<Result<CountDto>> FinalizeCountAsync(
string countId,
CancellationToken ct = default);
// History
Task<PagedResult<InventoryEventDto>> GetAdjustmentHistoryAsync(
InventoryHistoryQuery query,
CancellationToken ct = default);
// Internal (called by other services)
Task<Result> DeductInventoryAsync(
DeductInventoryCommand command,
CancellationToken ct = default);
Task<Result> RestoreInventoryAsync(
RestoreInventoryCommand command,
CancellationToken ct = default);
}
11.3.3 ICustomerService
// File: src/POS.Application/Interfaces/ICustomerService.cs
namespace POS.Application.Interfaces;
public interface ICustomerService
{
Task<PagedResult<CustomerSummaryDto>> GetCustomersAsync(
CustomerQueryParams query,
CancellationToken ct = default);
Task<CustomerDto?> GetByIdAsync(string customerId, CancellationToken ct = default);
Task<IReadOnlyList<CustomerSummaryDto>> SearchAsync(
string searchTerm,
int limit = 10,
CancellationToken ct = default);
Task<Result<CustomerDto>> CreateAsync(
CreateCustomerRequest request,
CancellationToken ct = default);
Task<Result<CustomerDto>> UpdateAsync(
string customerId,
UpdateCustomerRequest request,
CancellationToken ct = default);
Task<PagedResult<OrderSummaryDto>> GetPurchaseHistoryAsync(
string customerId,
PurchaseHistoryQuery query,
CancellationToken ct = default);
Task<LoyaltyInfoDto> GetLoyaltyInfoAsync(
string customerId,
CancellationToken ct = default);
Task<Result<LoyaltyInfoDto>> AddLoyaltyPointsAsync(
string customerId,
int points,
string reason,
CancellationToken ct = default);
Task<Result<LoyaltyInfoDto>> RedeemLoyaltyPointsAsync(
string customerId,
int points,
string orderId,
CancellationToken ct = default);
}
11.3.4 IItemService
// File: src/POS.Application/Interfaces/IItemService.cs
namespace POS.Application.Interfaces;
public interface IItemService
{
Task<PagedResult<ItemSummaryDto>> GetItemsAsync(
ItemQueryParams query,
CancellationToken ct = default);
Task<ItemDto?> GetByIdAsync(string itemId, CancellationToken ct = default);
Task<ItemDto?> GetBySkuAsync(string sku, CancellationToken ct = default);
Task<ItemDto?> GetByBarcodeAsync(string barcode, CancellationToken ct = default);
Task<Result<ItemDto>> CreateAsync(
CreateItemRequest request,
CancellationToken ct = default);
Task<Result<ItemDto>> UpdateAsync(
string itemId,
UpdateItemRequest request,
CancellationToken ct = default);
Task<Result> DeleteAsync(string itemId, CancellationToken ct = default);
Task<BulkImportResult> BulkImportAsync(
BulkImportRequest request,
CancellationToken ct = default);
Task<IReadOnlyList<ItemDto>> GetByIdsAsync(
IEnumerable<string> itemIds,
CancellationToken ct = default);
}
11.4 Unit of Work Pattern
// File: src/POS.Application/Interfaces/IUnitOfWork.cs
namespace POS.Application.Interfaces;
public interface IUnitOfWork : IDisposable
{
IItemRepository Items { get; }
IOrderRepository Orders { get; }
ICustomerRepository Customers { get; }
IInventoryRepository Inventory { get; }
IEmployeeRepository Employees { get; }
ILocationRepository Locations { get; }
Task<int> SaveChangesAsync(CancellationToken ct = default);
Task BeginTransactionAsync(CancellationToken ct = default);
Task CommitTransactionAsync(CancellationToken ct = default);
Task RollbackTransactionAsync(CancellationToken ct = default);
}
// File: src/POS.Infrastructure/Persistence/UnitOfWork.cs
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
namespace POS.Infrastructure.Persistence;
public class UnitOfWork : IUnitOfWork
{
private readonly PosDbContext _context;
private IDbContextTransaction? _transaction;
public IItemRepository Items { get; }
public IOrderRepository Orders { get; }
public ICustomerRepository Customers { get; }
public IInventoryRepository Inventory { get; }
public IEmployeeRepository Employees { get; }
public ILocationRepository Locations { get; }
public UnitOfWork(
PosDbContext context,
IItemRepository items,
IOrderRepository orders,
ICustomerRepository customers,
IInventoryRepository inventory,
IEmployeeRepository employees,
ILocationRepository locations)
{
_context = context;
Items = items;
Orders = orders;
Customers = customers;
Inventory = inventory;
Employees = employees;
Locations = locations;
}
public async Task<int> SaveChangesAsync(CancellationToken ct = default)
{
return await _context.SaveChangesAsync(ct);
}
public async Task BeginTransactionAsync(CancellationToken ct = default)
{
_transaction = await _context.Database.BeginTransactionAsync(ct);
}
public async Task CommitTransactionAsync(CancellationToken ct = default)
{
if (_transaction is not null)
{
await _transaction.CommitAsync(ct);
await _transaction.DisposeAsync();
_transaction = null;
}
}
public async Task RollbackTransactionAsync(CancellationToken ct = default)
{
if (_transaction is not null)
{
await _transaction.RollbackAsync(ct);
await _transaction.DisposeAsync();
_transaction = null;
}
}
public void Dispose()
{
_transaction?.Dispose();
_context.Dispose();
}
}
11.5 Complete OrderService Implementation
// File: src/POS.Application/Services/OrderService.cs
using Microsoft.Extensions.Logging;
using POS.Application.DTOs;
using POS.Application.Interfaces;
using POS.Domain.Common;
using POS.Domain.Entities;
using POS.Domain.Events;
using POS.Domain.Exceptions;
namespace POS.Application.Services;
public class OrderService : IOrderService
{
private readonly IUnitOfWork _unitOfWork;
private readonly IInventoryService _inventoryService;
private readonly ICustomerService _customerService;
private readonly IPaymentService _paymentService;
private readonly IEventPublisher _eventPublisher;
private readonly ITenantContext _tenantContext;
private readonly ILogger<OrderService> _logger;
public OrderService(
IUnitOfWork unitOfWork,
IInventoryService inventoryService,
ICustomerService customerService,
IPaymentService paymentService,
IEventPublisher eventPublisher,
ITenantContext tenantContext,
ILogger<OrderService> logger)
{
_unitOfWork = unitOfWork;
_inventoryService = inventoryService;
_customerService = customerService;
_paymentService = paymentService;
_eventPublisher = eventPublisher;
_tenantContext = tenantContext;
_logger = logger;
}
public async Task<Result<OrderDto>> CreateOrderAsync(
CreateOrderRequest request,
CancellationToken ct = default)
{
_logger.LogInformation(
"Creating order for location {LocationId} with {ItemCount} items",
request.LocationId,
request.LineItems.Count);
try
{
await _unitOfWork.BeginTransactionAsync(ct);
// 1. Validate location and register
var location = await _unitOfWork.Locations.GetByIdAsync(
request.LocationId, ct);
if (location is null)
return Result<OrderDto>.Failure(
DomainError.NotFound("Location", request.LocationId));
// 2. Validate employee
var employee = await _unitOfWork.Employees.GetByIdAsync(
request.EmployeeId, ct);
if (employee is null)
return Result<OrderDto>.Failure(
DomainError.NotFound("Employee", request.EmployeeId));
// 3. Load items and validate inventory
var itemIds = request.LineItems.Select(li => li.ItemId).ToList();
var items = await _unitOfWork.Items.GetByIdsAsync(itemIds, ct);
var itemLookup = items.ToDictionary(i => i.Id);
foreach (var lineItem in request.LineItems)
{
if (!itemLookup.TryGetValue(lineItem.ItemId, out var item))
{
return Result<OrderDto>.Failure(
DomainError.NotFound("Item", lineItem.ItemId));
}
// Check inventory if tracked
if (item.TrackInventory)
{
var inventory = await _inventoryService.GetInventoryLevelAsync(
item.Id, request.LocationId, ct);
if (inventory is null || inventory.QuantityOnHand < lineItem.Quantity)
{
return Result<OrderDto>.Failure(
DomainError.InsufficientInventory(
item.Sku,
lineItem.Quantity,
inventory?.QuantityOnHand ?? 0));
}
}
}
// 4. Create order entity
var order = new Order
{
Id = IdGenerator.NewId("order"),
TenantId = _tenantContext.TenantId,
LocationId = request.LocationId,
RegisterId = request.RegisterId,
EmployeeId = request.EmployeeId,
CustomerId = request.CustomerId,
ReceiptNumber = await GenerateReceiptNumberAsync(
request.LocationId, ct),
Status = OrderStatus.Completed,
CreatedAt = DateTime.UtcNow
};
// 5. Build line items
decimal subtotal = 0;
foreach (var li in request.LineItems)
{
var item = itemLookup[li.ItemId];
var lineItem = new OrderLineItem
{
Id = IdGenerator.NewId("li"),
OrderId = order.Id,
ItemId = item.Id,
Sku = item.Sku,
Name = item.Name,
Quantity = li.Quantity,
UnitPrice = li.UnitPrice ?? item.Price,
DiscountAmount = li.DiscountAmount,
Taxable = item.Taxable
};
lineItem.ExtendedPrice = lineItem.Quantity * lineItem.UnitPrice;
lineItem.NetPrice = lineItem.ExtendedPrice - lineItem.DiscountAmount;
subtotal += lineItem.NetPrice;
order.LineItems.Add(lineItem);
}
// 6. Apply order-level discounts
decimal discountTotal = 0;
foreach (var discount in request.Discounts ?? [])
{
var discountAmount = discount.Type == DiscountType.Percentage
? subtotal * (discount.Value / 100m)
: discount.Value;
discountTotal += discountAmount;
order.Discounts.Add(new OrderDiscount
{
Id = IdGenerator.NewId("disc"),
OrderId = order.Id,
Type = discount.Type,
Value = discount.Value,
Amount = discountAmount,
Reason = discount.Reason
});
}
// 7. Calculate tax
decimal taxableAmount = order.LineItems
.Where(li => li.Taxable)
.Sum(li => li.NetPrice);
// Apply discount proportionally to taxable amount
if (subtotal > 0 && discountTotal > 0)
{
var taxableRatio = taxableAmount / subtotal;
taxableAmount -= discountTotal * taxableRatio;
}
var taxRate = location.TaxRate;
order.TaxAmount = Math.Round(taxableAmount * taxRate, 2);
// 8. Set totals
order.Subtotal = subtotal;
order.DiscountTotal = discountTotal;
order.GrandTotal = subtotal - discountTotal + order.TaxAmount;
// 9. Process payments
decimal paymentTotal = 0;
foreach (var payment in request.Payments)
{
var paymentResult = await _paymentService.ProcessPaymentAsync(
new ProcessPaymentCommand
{
OrderId = order.Id,
Method = payment.Method,
Amount = payment.Amount,
Reference = payment.Reference
}, ct);
if (!paymentResult.IsSuccess)
{
await _unitOfWork.RollbackTransactionAsync(ct);
return Result<OrderDto>.Failure(paymentResult.Error!);
}
order.Payments.Add(new OrderPayment
{
Id = IdGenerator.NewId("pmt"),
OrderId = order.Id,
Method = payment.Method,
Amount = payment.Amount,
Status = PaymentStatus.Captured,
Reference = paymentResult.Value!.TransactionId,
CardLast4 = payment.CardLast4,
CardBrand = payment.CardBrand
});
paymentTotal += payment.Amount;
}
// 10. Validate payment covers total
if (paymentTotal < order.GrandTotal)
{
await _unitOfWork.RollbackTransactionAsync(ct);
return Result<OrderDto>.Failure(
DomainError.InsufficientPayment(order.GrandTotal, paymentTotal));
}
order.ChangeGiven = paymentTotal - order.GrandTotal;
// 11. Deduct inventory
foreach (var lineItem in order.LineItems)
{
var item = itemLookup[lineItem.ItemId];
if (item.TrackInventory)
{
var deductResult = await _inventoryService.DeductInventoryAsync(
new DeductInventoryCommand
{
ItemId = lineItem.ItemId,
LocationId = request.LocationId,
Quantity = lineItem.Quantity,
Reason = InventoryChangeReason.Sale,
ReferenceId = order.Id,
ReferenceType = "Order"
}, ct);
if (!deductResult.IsSuccess)
{
await _unitOfWork.RollbackTransactionAsync(ct);
return Result<OrderDto>.Failure(deductResult.Error!);
}
}
}
// 12. Award loyalty points
if (request.CustomerId is not null)
{
var pointsToAward = CalculateLoyaltyPoints(order.GrandTotal);
await _customerService.AddLoyaltyPointsAsync(
request.CustomerId,
pointsToAward,
$"Purchase: {order.ReceiptNumber}",
ct);
}
// 13. Save order
await _unitOfWork.Orders.AddAsync(order, ct);
await _unitOfWork.SaveChangesAsync(ct);
await _unitOfWork.CommitTransactionAsync(ct);
// 14. Publish domain events
await _eventPublisher.PublishAsync(new OrderCompletedEvent
{
OrderId = order.Id,
TenantId = order.TenantId,
LocationId = order.LocationId,
ReceiptNumber = order.ReceiptNumber,
GrandTotal = order.GrandTotal,
ItemCount = order.LineItems.Count,
CustomerId = order.CustomerId,
EmployeeId = order.EmployeeId,
OccurredAt = DateTime.UtcNow
}, ct);
_logger.LogInformation(
"Order {OrderId} created successfully. Receipt: {ReceiptNumber}, Total: {Total}",
order.Id,
order.ReceiptNumber,
order.GrandTotal);
return Result<OrderDto>.Success(MapToDto(order));
}
catch (Exception ex)
{
await _unitOfWork.RollbackTransactionAsync(ct);
_logger.LogError(ex, "Failed to create order");
throw;
}
}
public async Task<Result<OrderDto>> ProcessReturnAsync(
string orderId,
ProcessReturnRequest request,
CancellationToken ct = default)
{
_logger.LogInformation(
"Processing return for order {OrderId}",
orderId);
try
{
await _unitOfWork.BeginTransactionAsync(ct);
var originalOrder = await _unitOfWork.Orders.GetByIdAsync(orderId, ct);
if (originalOrder is null)
return Result<OrderDto>.Failure(
DomainError.NotFound("Order", orderId));
if (originalOrder.Status == OrderStatus.Voided)
return Result<OrderDto>.Failure(
DomainError.InvalidOperation("Cannot return a voided order"));
// Create return order
var returnOrder = new Order
{
Id = IdGenerator.NewId("order"),
TenantId = _tenantContext.TenantId,
LocationId = originalOrder.LocationId,
RegisterId = request.RegisterId,
EmployeeId = request.EmployeeId,
CustomerId = originalOrder.CustomerId,
ReceiptNumber = await GenerateReceiptNumberAsync(
originalOrder.LocationId, ct),
Status = OrderStatus.Completed,
OrderType = OrderType.Return,
OriginalOrderId = orderId,
CreatedAt = DateTime.UtcNow
};
decimal returnSubtotal = 0;
foreach (var returnItem in request.LineItems)
{
var originalLineItem = originalOrder.LineItems
.FirstOrDefault(li => li.Id == returnItem.OriginalLineItemId);
if (originalLineItem is null)
return Result<OrderDto>.Failure(
DomainError.NotFound("LineItem", returnItem.OriginalLineItemId));
if (returnItem.Quantity > originalLineItem.Quantity)
return Result<OrderDto>.Failure(
DomainError.InvalidOperation(
$"Return quantity exceeds original quantity"));
var returnLineItem = new OrderLineItem
{
Id = IdGenerator.NewId("li"),
OrderId = returnOrder.Id,
ItemId = originalLineItem.ItemId,
Sku = originalLineItem.Sku,
Name = originalLineItem.Name,
Quantity = -returnItem.Quantity,
UnitPrice = originalLineItem.UnitPrice,
DiscountAmount = 0,
Taxable = originalLineItem.Taxable,
ReturnReason = returnItem.Reason
};
returnLineItem.ExtendedPrice = returnLineItem.Quantity *
returnLineItem.UnitPrice;
returnLineItem.NetPrice = returnLineItem.ExtendedPrice;
returnSubtotal += returnLineItem.NetPrice;
returnOrder.LineItems.Add(returnLineItem);
// Restore inventory
var item = await _unitOfWork.Items.GetByIdAsync(
originalLineItem.ItemId, ct);
if (item?.TrackInventory == true)
{
await _inventoryService.RestoreInventoryAsync(
new RestoreInventoryCommand
{
ItemId = originalLineItem.ItemId,
LocationId = originalOrder.LocationId,
Quantity = returnItem.Quantity,
Reason = InventoryChangeReason.Return,
ReferenceId = returnOrder.Id,
ReferenceType = "Return"
}, ct);
}
}
// Calculate return tax
var location = await _unitOfWork.Locations.GetByIdAsync(
originalOrder.LocationId, ct);
decimal taxableReturnAmount = returnOrder.LineItems
.Where(li => li.Taxable)
.Sum(li => li.NetPrice);
returnOrder.TaxAmount = Math.Round(
Math.Abs(taxableReturnAmount) * location!.TaxRate, 2) * -1;
returnOrder.Subtotal = returnSubtotal;
returnOrder.GrandTotal = returnSubtotal + returnOrder.TaxAmount;
// Process refund
var refundResult = await _paymentService.ProcessRefundAsync(
new ProcessRefundCommand
{
OriginalOrderId = orderId,
RefundOrderId = returnOrder.Id,
Amount = Math.Abs(returnOrder.GrandTotal),
Method = request.RefundMethod
}, ct);
if (!refundResult.IsSuccess)
{
await _unitOfWork.RollbackTransactionAsync(ct);
return Result<OrderDto>.Failure(refundResult.Error!);
}
returnOrder.Payments.Add(new OrderPayment
{
Id = IdGenerator.NewId("pmt"),
OrderId = returnOrder.Id,
Method = request.RefundMethod,
Amount = returnOrder.GrandTotal,
Status = PaymentStatus.Refunded,
Reference = refundResult.Value!.TransactionId
});
await _unitOfWork.Orders.AddAsync(returnOrder, ct);
await _unitOfWork.SaveChangesAsync(ct);
await _unitOfWork.CommitTransactionAsync(ct);
await _eventPublisher.PublishAsync(new OrderReturnedEvent
{
OrderId = returnOrder.Id,
OriginalOrderId = orderId,
TenantId = returnOrder.TenantId,
RefundAmount = Math.Abs(returnOrder.GrandTotal),
OccurredAt = DateTime.UtcNow
}, ct);
return Result<OrderDto>.Success(MapToDto(returnOrder));
}
catch (Exception ex)
{
await _unitOfWork.RollbackTransactionAsync(ct);
_logger.LogError(ex, "Failed to process return for order {OrderId}", orderId);
throw;
}
}
public async Task<Result<OrderDto>> VoidOrderAsync(
string orderId,
VoidOrderRequest request,
CancellationToken ct = default)
{
var order = await _unitOfWork.Orders.GetByIdAsync(orderId, ct);
if (order is null)
return Result<OrderDto>.Failure(DomainError.NotFound("Order", orderId));
if (order.Status == OrderStatus.Voided)
return Result<OrderDto>.Failure(
DomainError.InvalidOperation("Order is already voided"));
// Check void window (typically same day only)
if (order.CreatedAt.Date != DateTime.UtcNow.Date)
return Result<OrderDto>.Failure(
DomainError.InvalidOperation("Orders can only be voided on the same day"));
try
{
await _unitOfWork.BeginTransactionAsync(ct);
// Void all payments
foreach (var payment in order.Payments.Where(p =>
p.Status == PaymentStatus.Captured))
{
var voidResult = await _paymentService.VoidPaymentAsync(
payment.Reference!, ct);
if (!voidResult.IsSuccess)
{
await _unitOfWork.RollbackTransactionAsync(ct);
return Result<OrderDto>.Failure(voidResult.Error!);
}
payment.Status = PaymentStatus.Voided;
}
// Restore inventory
foreach (var lineItem in order.LineItems)
{
var item = await _unitOfWork.Items.GetByIdAsync(lineItem.ItemId, ct);
if (item?.TrackInventory == true)
{
await _inventoryService.RestoreInventoryAsync(
new RestoreInventoryCommand
{
ItemId = lineItem.ItemId,
LocationId = order.LocationId,
Quantity = lineItem.Quantity,
Reason = InventoryChangeReason.Void,
ReferenceId = order.Id,
ReferenceType = "VoidedOrder"
}, ct);
}
}
// Reverse loyalty points
if (order.CustomerId is not null)
{
var pointsToDeduct = CalculateLoyaltyPoints(order.GrandTotal);
await _customerService.AddLoyaltyPointsAsync(
order.CustomerId,
-pointsToDeduct,
$"Voided: {order.ReceiptNumber}",
ct);
}
order.Status = OrderStatus.Voided;
order.VoidedAt = DateTime.UtcNow;
order.VoidedBy = request.EmployeeId;
order.VoidReason = request.Reason;
await _unitOfWork.SaveChangesAsync(ct);
await _unitOfWork.CommitTransactionAsync(ct);
await _eventPublisher.PublishAsync(new OrderVoidedEvent
{
OrderId = order.Id,
TenantId = order.TenantId,
Reason = request.Reason,
VoidedBy = request.EmployeeId,
OccurredAt = DateTime.UtcNow
}, ct);
return Result<OrderDto>.Success(MapToDto(order));
}
catch (Exception ex)
{
await _unitOfWork.RollbackTransactionAsync(ct);
_logger.LogError(ex, "Failed to void order {OrderId}", orderId);
throw;
}
}
private async Task<string> GenerateReceiptNumberAsync(
string locationId,
CancellationToken ct)
{
var location = await _unitOfWork.Locations.GetByIdAsync(locationId, ct);
var prefix = location?.Code ?? "XX";
var date = DateTime.UtcNow.ToString("yyyyMMdd");
var sequence = await _unitOfWork.Orders.GetNextSequenceAsync(locationId, ct);
return $"{prefix}-{date}-{sequence:D4}";
}
private static int CalculateLoyaltyPoints(decimal amount)
{
return (int)Math.Floor(amount);
}
private static OrderDto MapToDto(Order order)
{
return new OrderDto
{
Id = order.Id,
ReceiptNumber = order.ReceiptNumber,
Status = order.Status.ToString(),
// ... map all properties
};
}
// ... other interface methods
}
11.6 Transactional Outbox Pattern
Domain events must be published reliably. Direct publishing (e.g., calling a message bus after SaveChanges) risks events being lost if the application crashes between the database commit and the publish call. The Transactional Outbox pattern solves this by writing events to an event_outbox table in the same database transaction as the business data.
Outbox Entity
// File: src/POS.Domain/Events/OutboxMessage.cs
namespace POS.Domain.Events;
public class OutboxMessage
{
public Guid Id { get; set; }
public string TenantId { get; set; } = null!;
public string EventType { get; set; } = null!;
public string Payload { get; set; } = null!; // JSON-serialized event
public DateTime CreatedAt { get; set; }
public DateTime? ProcessedAt { get; set; }
public int RetryCount { get; set; }
public string? Error { get; set; }
}
Writing Events to the Outbox
The IEventPublisher writes to the outbox table within the current transaction instead of publishing directly to a message bus:
// File: src/POS.Infrastructure/Messaging/OutboxEventPublisher.cs
using System.Text.Json;
namespace POS.Infrastructure.Messaging;
public class OutboxEventPublisher : IEventPublisher
{
private readonly PosDbContext _dbContext;
private readonly ILogger<OutboxEventPublisher> _logger;
public OutboxEventPublisher(
PosDbContext dbContext,
ILogger<OutboxEventPublisher> logger)
{
_dbContext = dbContext;
_logger = logger;
}
public async Task PublishAsync<TEvent>(TEvent @event, CancellationToken ct = default)
where TEvent : IDomainEvent
{
var outboxMessage = new OutboxMessage
{
Id = Guid.NewGuid(),
TenantId = @event.TenantId,
EventType = typeof(TEvent).AssemblyQualifiedName!,
Payload = JsonSerializer.Serialize(@event),
CreatedAt = DateTime.UtcNow
};
_dbContext.OutboxMessages.Add(outboxMessage);
// SaveChanges is called by the UnitOfWork in the same transaction
_logger.LogDebug(
"Queued event {EventType} to outbox for tenant {TenantId}",
typeof(TEvent).Name,
@event.TenantId);
}
public async Task PublishManyAsync<TEvent>(
IEnumerable<TEvent> events, CancellationToken ct = default)
where TEvent : IDomainEvent
{
foreach (var @event in events)
await PublishAsync(@event, ct);
}
}
Outbox Background Worker
A BackgroundService polls the outbox table and publishes pending events to the message bus (LISTEN/NOTIFY for v1.0, Kafka for v2.0) and SignalR for real-time UI updates:
// File: src/POS.Infrastructure/Messaging/OutboxWorker.cs
using System.Text.Json;
using MassTransit;
using Microsoft.AspNetCore.SignalR;
using POS.Api.Hubs;
namespace POS.Infrastructure.Messaging;
public class OutboxWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<OutboxWorker> _logger;
public OutboxWorker(
IServiceScopeFactory scopeFactory,
ILogger<OutboxWorker> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken ct)
{
_logger.LogInformation("Outbox worker started");
while (!ct.IsCancellationRequested)
{
try
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<PosDbContext>();
var bus = scope.ServiceProvider.GetRequiredService<IPublishEndpoint>();
var hub = scope.ServiceProvider.GetRequiredService<IHubContext<PosHub, IPosHubClient>>();
var pending = await db.OutboxMessages
.Where(e => e.ProcessedAt == null && e.RetryCount < 5)
.OrderBy(e => e.CreatedAt)
.Take(100)
.ToListAsync(ct);
foreach (var message in pending)
{
try
{
// Deserialize and publish to message bus
var eventType = Type.GetType(message.EventType);
if (eventType is not null)
{
var @event = JsonSerializer.Deserialize(
message.Payload, eventType);
if (@event is not null)
{
await bus.Publish(@event, eventType, ct);
await PublishToSignalRAsync(hub, @event, message.TenantId, ct);
}
}
message.ProcessedAt = DateTime.UtcNow;
}
catch (Exception ex)
{
message.RetryCount++;
message.Error = ex.Message;
_logger.LogWarning(ex,
"Failed to publish outbox message {Id}. Retry {Count}",
message.Id, message.RetryCount);
}
}
await db.SaveChangesAsync(ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Outbox worker error");
}
await Task.Delay(TimeSpan.FromSeconds(5), ct);
}
}
private static async Task PublishToSignalRAsync(
IHubContext<PosHub, IPosHubClient> hub,
object @event,
string tenantId,
CancellationToken ct)
{
var tenantGroup = $"tenant:{tenantId}";
switch (@event)
{
case OrderCompletedEvent order:
await hub.Clients.Group(tenantGroup)
.OrderCompleted(new OrderCompletedNotification
{
OrderId = order.OrderId,
ReceiptNumber = order.ReceiptNumber,
GrandTotal = order.GrandTotal
});
break;
case InventoryUpdatedEvent inv:
await hub.Clients.Group(tenantGroup)
.InventoryUpdated(new InventoryUpdateNotification
{
ItemId = inv.ItemId,
LocationId = inv.LocationId,
NewQuantity = inv.NewQuantity
});
break;
}
}
}
Key guarantee: Events are written to
event_outboxin the same DB transaction as business data. If the transaction rolls back, the events are never published. The background worker provides at-least-once delivery with retry and dead-letter handling.
11.7 CQRS with MediatR (Sales Module)
The Sales module uses CQRS (Command Query Responsibility Segregation) with MediatR for explicit command/query separation. Other modules use the standard service layer pattern (e.g., IItemService, IInventoryService). Sales is the highest-throughput, most complex domain – CQRS gives it clear audit trails, separate read/write optimization, and pipeline behaviours.
MediatR Pipeline Behaviours
Pipeline behaviours wrap every request in a consistent cross-cutting chain: validation first, then logging, then transaction management, then the actual handler.
// File: src/POS.Api/Program.cs (MediatR registration)
services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssembly(typeof(CreateSaleCommand).Assembly);
});
// Pipeline order: Validation -> Logging -> Transaction -> Handler
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(TransactionBehavior<,>));
Validation Behaviour
// File: src/POS.Application/Behaviours/ValidationBehavior.cs
using FluentValidation;
using MediatR;
namespace POS.Application.Behaviours;
public class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly IEnumerable<IValidator<TRequest>> _validators;
public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
{
_validators = validators;
}
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken ct)
{
if (!_validators.Any())
return await next();
var context = new ValidationContext<TRequest>(request);
var failures = (await Task.WhenAll(
_validators.Select(v => v.ValidateAsync(context, ct))))
.SelectMany(r => r.Errors)
.Where(f => f is not null)
.ToList();
if (failures.Count > 0)
throw new ValidationException(failures);
return await next();
}
}
Transaction Behaviour
// File: src/POS.Application/Behaviours/TransactionBehavior.cs
using MediatR;
namespace POS.Application.Behaviours;
public class TransactionBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<TransactionBehavior<TRequest, TResponse>> _logger;
public TransactionBehavior(
IUnitOfWork unitOfWork,
ILogger<TransactionBehavior<TRequest, TResponse>> logger)
{
_unitOfWork = unitOfWork;
_logger = logger;
}
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken ct)
{
// Only wrap commands (not queries) in transactions
if (request is IQuery)
return await next();
await _unitOfWork.BeginTransactionAsync(ct);
try
{
var response = await next();
await _unitOfWork.CommitTransactionAsync(ct);
return response;
}
catch
{
await _unitOfWork.RollbackTransactionAsync(ct);
throw;
}
}
}
Sales Commands
// File: src/POS.Application/Sales/Commands/CreateSaleCommand.cs
using MediatR;
namespace POS.Application.Sales.Commands;
public record CreateSaleCommand(
string LocationId,
string RegisterId,
string EmployeeId,
string? CustomerId,
List<SaleLineItemDto> LineItems,
List<SaleDiscountDto>? Discounts,
List<SalePaymentDto> Payments
) : IRequest<Result<OrderDto>>;
// File: src/POS.Application/Sales/Commands/VoidSaleCommand.cs
public record VoidSaleCommand(
string OrderId,
string EmployeeId,
string Reason
) : IRequest<Result<OrderDto>>;
// File: src/POS.Application/Sales/Commands/ParkSaleCommand.cs
public record ParkSaleCommand(
string LocationId,
string RegisterId,
string EmployeeId,
List<SaleLineItemDto> LineItems,
string? CustomerName,
string? Notes
) : IRequest<Result<HeldOrderDto>>;
Sales Queries
// File: src/POS.Application/Sales/Queries/GetDailySalesReportQuery.cs
using MediatR;
namespace POS.Application.Sales.Queries;
public record GetDailySalesReportQuery(
Guid TenantId,
DateOnly Date,
string? LocationId
) : IRequest<DailySalesReport>, IQuery;
// File: src/POS.Application/Sales/Queries/GetDailySalesReportHandler.cs
public class GetDailySalesReportHandler
: IRequestHandler<GetDailySalesReportQuery, DailySalesReport>
{
private readonly IReadOnlyDbContext _readDb;
public GetDailySalesReportHandler(IReadOnlyDbContext readDb)
{
_readDb = readDb;
}
public async Task<DailySalesReport> Handle(
GetDailySalesReportQuery request,
CancellationToken ct)
{
// Reads from read-optimized materialized view
// No business logic -- pure data retrieval
var report = await _readDb.DailySalesReports
.Where(r => r.ReportDate == request.Date)
.Where(r => request.LocationId == null || r.LocationId == request.LocationId)
.FirstOrDefaultAsync(ct);
return report ?? DailySalesReport.Empty(request.Date);
}
}
// File: src/POS.Application/Sales/Queries/GetSaleByIdQuery.cs
public record GetSaleByIdQuery(string OrderId) : IRequest<OrderDto?>, IQuery;
Controller Using MediatR
// File: src/POS.Api/Controllers/SalesController.cs (MediatR version)
[ApiController]
[Route("api/v1/sales")]
[Authorize]
public class SalesController : ControllerBase
{
private readonly IMediator _mediator;
public SalesController(IMediator mediator)
{
_mediator = mediator;
}
[HttpPost]
[Authorize(Policy = "pos.sale.create")]
public async Task<ActionResult<OrderDto>> CreateSale(
[FromBody] CreateSaleCommand command,
CancellationToken ct)
{
var result = await _mediator.Send(command, ct);
return result.Match<ActionResult<OrderDto>>(
success => CreatedAtAction(nameof(GetSale), new { id = success.Id }, success),
error => BadRequest(ProblemFactory.FromError(error))
);
}
[HttpGet("{id}")]
[Authorize(Policy = "pos.sale.create")]
public async Task<ActionResult<OrderDto>> GetSale(string id, CancellationToken ct)
{
var result = await _mediator.Send(new GetSaleByIdQuery(id), ct);
return result is not null ? Ok(result) : NotFound();
}
[HttpPost("{id}/void")]
[Authorize(Policy = "pos.sale.void")]
public async Task<ActionResult<OrderDto>> VoidSale(
string id,
[FromBody] VoidSaleRequest request,
CancellationToken ct)
{
var result = await _mediator.Send(
new VoidSaleCommand(id, request.EmployeeId, request.Reason), ct);
return result.Match<ActionResult<OrderDto>>(
success => Ok(success),
error => BadRequest(ProblemFactory.FromError(error))
);
}
}
Scope note: Only the Sales module uses CQRS/MediatR. Catalog, Inventory, Customer, and Employee modules use the standard
IXxxServicepattern shown in Sections 11.3-11.5. Adding CQRS to all modules would be over-engineering at this scale.
11.8 DB-Driven State Machines
The POS system has 16 state machines governing entity lifecycles (orders, transfers, inventory counts, registers, employees, etc.). State transitions are DB-driven via a state_transitions table – NOT hardcoded switch/case or if/else chains. This allows tenants to customize workflows and enables audit-friendly transition logging.
State Transition Table
-- File: migrations/state_transitions.sql
CREATE TABLE state_transitions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
entity_type VARCHAR(50) NOT NULL, -- 'order', 'transfer', 'inventory_count', etc.
from_state VARCHAR(50) NOT NULL, -- '*' means "any state"
to_state VARCHAR(50) NOT NULL,
required_role VARCHAR(50), -- NULL = any role can trigger
guard_condition VARCHAR(255), -- Optional: evaluated at runtime
side_effects JSONB, -- Events to publish, actions to trigger
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(tenant_id, entity_type, from_state, to_state)
);
-- RLS policy
ALTER TABLE state_transitions ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON state_transitions
USING (tenant_id = current_setting('app.current_tenant')::UUID);
-- Example: Order state transitions
INSERT INTO state_transitions (tenant_id, entity_type, from_state, to_state, required_role, guard_condition, side_effects) VALUES
('tenant-1', 'order', 'draft', 'held', NULL, NULL, '{"event":"OrderHeld"}'),
('tenant-1', 'order', 'draft', 'completed', NULL, 'payment_total >= grand_total', '{"event":"OrderCompleted"}'),
('tenant-1', 'order', 'held', 'draft', NULL, NULL, '{"event":"OrderRecalled"}'),
('tenant-1', 'order', 'held', 'voided', 'manager', NULL, '{"event":"OrderVoided"}'),
('tenant-1', 'order', 'completed', 'voided', 'manager', 'same_day_only', '{"event":"OrderVoided"}'),
('tenant-1', 'order', 'completed', 'returned', 'manager', NULL, '{"event":"OrderReturned"}');
-- Example: Transfer state transitions
INSERT INTO state_transitions (tenant_id, entity_type, from_state, to_state, required_role, guard_condition, side_effects) VALUES
('tenant-1', 'transfer', 'pending', 'in_transit', 'manager', NULL, '{"event":"TransferShipped"}'),
('tenant-1', 'transfer', 'in_transit', 'received', 'manager', NULL, '{"event":"TransferReceived"}'),
('tenant-1', 'transfer', 'pending', 'cancelled', 'manager', NULL, '{"event":"TransferCancelled"}'),
('tenant-1', 'transfer', 'in_transit', 'cancelled', 'admin', NULL, '{"event":"TransferCancelled"}');
State Machine Engine
// File: src/POS.Application/StateMachine/IStateMachineEngine.cs
namespace POS.Application.StateMachine;
public interface IStateMachineEngine
{
Task<Result> TransitionAsync(
string entityType,
string entityId,
string fromState,
string toState,
string triggeredByRole,
Dictionary<string, object>? context = null,
CancellationToken ct = default);
Task<IReadOnlyList<string>> GetAllowedTransitionsAsync(
string entityType,
string currentState,
string userRole,
CancellationToken ct = default);
}
// File: src/POS.Infrastructure/StateMachine/StateMachineEngine.cs
namespace POS.Infrastructure.StateMachine;
public class StateMachineEngine : IStateMachineEngine
{
private readonly PosDbContext _db;
private readonly IEventPublisher _eventPublisher;
private readonly IGuardEvaluator _guardEvaluator;
private readonly ITenantContext _tenantContext;
private readonly ILogger<StateMachineEngine> _logger;
public StateMachineEngine(
PosDbContext db,
IEventPublisher eventPublisher,
IGuardEvaluator guardEvaluator,
ITenantContext tenantContext,
ILogger<StateMachineEngine> logger)
{
_db = db;
_eventPublisher = eventPublisher;
_guardEvaluator = guardEvaluator;
_tenantContext = tenantContext;
_logger = logger;
}
public async Task<Result> TransitionAsync(
string entityType,
string entityId,
string fromState,
string toState,
string triggeredByRole,
Dictionary<string, object>? context = null,
CancellationToken ct = default)
{
// Find valid transition from DB
var transition = await _db.StateTransitions
.Where(t => t.EntityType == entityType)
.Where(t => t.FromState == fromState || t.FromState == "*")
.Where(t => t.ToState == toState)
.Where(t => t.IsActive)
.FirstOrDefaultAsync(ct);
if (transition is null)
{
_logger.LogWarning(
"No valid transition from {From} to {To} for {EntityType}",
fromState, toState, entityType);
return Result.Failure(
DomainError.InvalidStateTransition(entityType, fromState, toState));
}
// Check role requirement
if (transition.RequiredRole is not null &&
!IsRoleSufficient(triggeredByRole, transition.RequiredRole))
{
return Result.Failure(
DomainError.InsufficientRole(transition.RequiredRole, triggeredByRole));
}
// Evaluate guard condition
if (transition.GuardCondition is not null)
{
var guardResult = await _guardEvaluator.EvaluateAsync(
transition.GuardCondition, context ?? new(), ct);
if (!guardResult)
{
return Result.Failure(
DomainError.GuardConditionFailed(transition.GuardCondition));
}
}
// Log the transition
_db.StateTransitionLogs.Add(new StateTransitionLog
{
Id = Guid.NewGuid(),
TenantId = Guid.Parse(_tenantContext.TenantId!),
EntityType = entityType,
EntityId = entityId,
FromState = fromState,
ToState = toState,
TriggeredBy = triggeredByRole,
TransitionId = transition.Id,
OccurredAt = DateTime.UtcNow
});
// Publish side-effect events via outbox
if (transition.SideEffects is not null)
{
var sideEffects = JsonSerializer.Deserialize<SideEffectConfig>(
transition.SideEffects.ToString()!);
if (sideEffects?.Event is not null)
{
await _eventPublisher.PublishAsync(new StateTransitionEvent
{
TenantId = _tenantContext.TenantId!,
EntityType = entityType,
EntityId = entityId,
FromState = fromState,
ToState = toState,
EventName = sideEffects.Event,
OccurredAt = DateTime.UtcNow
}, ct);
}
}
_logger.LogInformation(
"State transition: {EntityType} {EntityId} [{From}] -> [{To}]",
entityType, entityId, fromState, toState);
return Result.Success();
}
public async Task<IReadOnlyList<string>> GetAllowedTransitionsAsync(
string entityType,
string currentState,
string userRole,
CancellationToken ct = default)
{
var transitions = await _db.StateTransitions
.Where(t => t.EntityType == entityType)
.Where(t => t.FromState == currentState || t.FromState == "*")
.Where(t => t.IsActive)
.ToListAsync(ct);
return transitions
.Where(t => t.RequiredRole is null || IsRoleSufficient(userRole, t.RequiredRole))
.Select(t => t.ToState)
.Distinct()
.ToList();
}
private static bool IsRoleSufficient(string userRole, string requiredRole)
{
var hierarchy = new[] { "staff", "buyer", "manager", "admin", "owner" };
var userLevel = Array.IndexOf(hierarchy, userRole);
var requiredLevel = Array.IndexOf(hierarchy, requiredRole);
return userLevel >= requiredLevel;
}
}
16 State Machines
| # | Entity Type | States | Key Transitions |
|---|---|---|---|
| 1 | order | draft, held, completed, voided, returned | draft->completed (payment), completed->voided (same-day) |
| 2 | transfer | pending, in_transit, received, cancelled | pending->in_transit (ship), in_transit->received (confirm) |
| 3 | inventory_count | open, in_progress, review, finalized, cancelled | open->in_progress (start scanning), review->finalized (approve) |
| 4 | purchase_order | draft, submitted, partial, received, closed | draft->submitted (send to vendor), partial->received |
| 5 | register | active, suspended, retired | active->suspended (issue), suspended->retired (OWNER only) |
| 6 | employee | active, suspended, terminated | active->suspended (disciplinary) |
| 7 | customer | active, inactive, merged | active->merged (dedup) |
| 8 | gift_card | active, redeemed, expired, voided | active->redeemed (zero balance) |
| 9 | discount | active, scheduled, expired, disabled | scheduled->active (start date), active->expired (end date) |
| 10 | payment | pending, captured, refunded, voided, failed | pending->captured (processor confirms) |
| 11 | rfid_session | created, scanning, uploading, completed, cancelled | scanning->uploading (end scan), uploading->completed (variance calc) |
| 12 | integration_sync | queued, processing, completed, failed, retrying | queued->processing (worker picks up) |
| 13 | tenant | trial, active, suspended, churned | trial->active (payment), active->suspended (non-payment) |
| 14 | location | active, temporarily_closed, permanently_closed | active->temporarily_closed (renovation) |
| 15 | price_change | draft, scheduled, active, expired | scheduled->active (effective date) |
| 16 | return_authorization | pending, approved, rejected, completed | pending->approved (manager), approved->completed (items received) |
11.9 Tenant Context at Database Level
Multi-tenancy isolation is enforced at two levels: application middleware (see Chapter 12, Section 12.6) and PostgreSQL session variables for Row-Level Security. Every database connection must set app.current_tenant so RLS policies can filter rows automatically.
DbContext Tenant Interceptor
// File: src/POS.Infrastructure/Persistence/TenantConnectionInterceptor.cs
using Microsoft.EntityFrameworkCore.Diagnostics;
using System.Data.Common;
namespace POS.Infrastructure.Persistence;
public class TenantConnectionInterceptor : DbConnectionInterceptor
{
private readonly ITenantContext _tenantContext;
public TenantConnectionInterceptor(ITenantContext tenantContext)
{
_tenantContext = tenantContext;
}
public override async Task ConnectionOpenedAsync(
DbConnection connection,
ConnectionEndEventData eventData,
CancellationToken ct = default)
{
if (_tenantContext.TenantId is not null)
{
await using var cmd = connection.CreateCommand();
cmd.CommandText = $"SET app.current_tenant = '{_tenantContext.TenantId}'";
await cmd.ExecuteNonQueryAsync(ct);
}
}
public override void ConnectionOpened(
DbConnection connection,
ConnectionEndEventData eventData)
{
if (_tenantContext.TenantId is not null)
{
using var cmd = connection.CreateCommand();
cmd.CommandText = $"SET app.current_tenant = '{_tenantContext.TenantId}'";
cmd.ExecuteNonQuery();
}
}
}
Registration
// File: src/POS.Infrastructure/Persistence/PosDbContext.cs (partial)
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.AddInterceptors(
_serviceProvider.GetRequiredService<TenantConnectionInterceptor>());
}
Important: This
SET app.current_tenantcall is what activates PostgreSQL RLS policies. Without it, queries would either return no rows (ifcurrent_setting('app.current_tenant')is empty) or fail. See Chapter 06 (Database Strategy) and Chapter 07 (Schema Design) for the RLS policy definitions.
11.10 Dependency Injection Configuration
// File: src/POS.Api/Program.cs (partial)
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddApplicationServices(
this IServiceCollection services)
{
// Application services
services.AddScoped<IOrderService, OrderService>();
services.AddScoped<IInventoryService, InventoryService>();
services.AddScoped<ICustomerService, CustomerService>();
services.AddScoped<IItemService, ItemService>();
services.AddScoped<IEmployeeService, EmployeeService>();
services.AddScoped<IReportService, ReportService>();
services.AddScoped<IPaymentService, PaymentService>();
// CQRS / MediatR (Sales module only)
services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(typeof(CreateSaleCommand).Assembly));
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(TransactionBehavior<,>));
// Infrastructure
services.AddScoped<IUnitOfWork, UnitOfWork>();
services.AddScoped<IEventPublisher, OutboxEventPublisher>();
services.AddScoped<IStateMachineEngine, StateMachineEngine>();
services.AddScoped<TenantConnectionInterceptor>();
services.AddHostedService<OutboxWorker>();
// Repositories
services.AddScoped<IItemRepository, ItemRepository>();
services.AddScoped<IOrderRepository, OrderRepository>();
services.AddScoped<ICustomerRepository, CustomerRepository>();
services.AddScoped<IInventoryRepository, InventoryRepository>();
services.AddScoped<IEmployeeRepository, EmployeeRepository>();
services.AddScoped<ILocationRepository, LocationRepository>();
return services;
}
}
Summary
This chapter defined the complete service layer architecture:
- Clean architecture with clear separation of concerns
- Service interfaces for all major domains
- Unit of Work pattern for transaction management
- Complete OrderService implementation with full transaction flow
- Transactional Outbox pattern for reliable at-least-once event delivery
- CQRS with MediatR for Sales module (commands, queries, pipeline behaviours)
- DB-driven state machines (16 entity types) via
state_transitionstable - Tenant context at DB level with
SET app.current_tenantfor RLS activation
Next: Chapter 12: Security & Authentication covers security and authentication patterns.
Document Information
| Attribute | Value |
|---|---|
| Version | 5.0.0 |
| Created | 2025-12-29 |
| Updated | 2026-02-22 |
| Author | Claude Code |
| Status | Active |
| Part | IV - Backend |
| Chapter | 11 of 32 |
This chapter is part of the POS Blueprint Book. All content is self-contained.