Table of Contents

Unit of work pattern

Overview

The unit of work pattern uses a single transaction or a single unit of work for multiple insert, update, and delete operations. These operations either succeed or fail as an entire unit. In other words, all of the operations will be committed as one transaction or rolled back as a single unit.

The Neos implementation of the Unit Of Work pattern is based on EntityFramework's DbContext, with the ability to start explicit database transactions.

You can find the details of the IUnitOfWork interface on this page.

Key Concepts

Transaction management

The unit of work pattern in Neos provides explicit control over database transactions:

  • Automatic transactions: Changes are automatically committed when calling SaveAsync()
  • Explicit transactions: Manual control using BeginTransactionAsync(), CommitTransactionAsync(), and RollbackTransactionAsync()

Change tracking

The unit of work tracks all modifications made to entities during the current context:

  • Added entities: New entities to be inserted
  • Modified entities: Existing entities with changes
  • Deleted entities: Entities marked for removal
  • Unchanged entities: Entities loaded but not modified

Deferred execution

Database operations are deferred until explicitly committed:

  • Changes are kept in memory until SaveAsync() or CommitTransactionAsync() is called
  • This allows for bulk operations and optimized SQL generation
  • Rollback is possible without affecting the database

Save modes

The SaveAsync method supports different save modes through the SaveMode enum, allowing fine-grained control over the save operation behavior.

Available modes

Mode Description Persists to DB Executes Saved rules Throws exceptions Use case
Default Standard save operation returning a Result Yes (DB errors) Normal CRUD operations
ThrowOnError Same as Default but throws BusinessException on failure Yes (all errors) Simplified error handling
NeverThrow Catches all exceptions and returns them as failed Results No Robust error handling
ValidateOnly Executes Saving and ValidationRules only Yes (DB errors) Pre-validation before save
Simulate Saves within an auto-rolled back transaction ✅ (rolled back) Yes (unexpected) Testing database constraints

SaveMode.Default

The default mode executes the complete save pipeline and returns a Result object indicating success or failure.

Result result = await _unitOfWork.SaveAsync(cancellationToken); // Uses SaveMode.Default
// or explicitly:
Result result = await _unitOfWork.SaveAsync(SaveMode.Default, cancellationToken);

if (result.IsFailed)
{
    // Handle errors
    string errors = string.Join(Environment.NewLine, result.Errors.Select(e => e.Message));
}

SaveMode.ThrowOnError

This mode simplifies error handling by throwing a BusinessException instead of returning a failed Result. A convenience method SaveOrThrowAsync() is also available.

// Using SaveMode.ThrowOnError
await _unitOfWork.SaveAsync(SaveMode.ThrowOnError, cancellationToken);

// Or using the convenience method
await _unitOfWork.SaveOrThrowAsync(cancellationToken);

This is particularly useful when you want exceptions to propagate up the call stack and be handled by a global exception handler.

SaveMode.NeverThrow

This mode provides the most robust error handling by catching all exceptions (business rules, validation rules, database errors, and unexpected exceptions) and converting them into failed Result objects. A convenience method TrySaveAsync() is also available.

// Using SaveMode.NeverThrow
Result result = await _unitOfWork.SaveAsync(SaveMode.NeverThrow, cancellationToken);

// Or using the convenience method
Result result = await _unitOfWork.TrySaveAsync(cancellationToken);

if (result.IsFailed)
{
    // All errors are captured in the Result without any exception being thrown
    foreach (IError error in result.Errors)
    {
        _logger.LogError("Save failed: {Error}", error.Message);
    }
}

Key characteristics:

  • No exceptions thrown: All errors are caught and returned as failed Result objects
  • Complete error handling: Catches business rule exceptions, validation rule exceptions, database errors, and unexpected system exceptions
  • Safe execution: Guarantees no unhandled exceptions will escape from the save operation
  • Executes full pipeline: Runs validation rules, saving rules, database persistence, and saved rules (if successful)

For details about the error types returned by the save pipeline and how generated APIs expose them, see Handling save errors in backend and API.

Comparison with other modes:

Error Type Default ThrowOnError NeverThrow
Validation rule error Failed Result BusinessException Failed Result
Saving rule error Failed Result BusinessException Failed Result
Database constraint Exception BusinessException Failed Result
Unexpected exception Exception Exception Failed Result

Use cases:

  • Background jobs or batch processing where exceptions should not interrupt the workflow
  • Importing data where you want to collect all errors without stopping
  • API endpoints that must always return a response rather than throwing exceptions
  • Scenarios where you need guaranteed error handling without try-catch blocks
Tip

Use TrySaveAsync() instead of SaveAsync(SaveMode.NeverThrow) for cleaner code. Both methods have identical behavior.

// These are equivalent:
Result result1 = await _unitOfWork.SaveAsync(SaveMode.NeverThrow, cancellationToken);
Result result2 = await _unitOfWork.TrySaveAsync(cancellationToken);

SaveMode.ValidateOnly

This mode executes the Saving event rules and validation rules without actually persisting changes to the database. The Saved event rules are not executed.

// Validate without saving
Result result = await _unitOfWork.SaveAsync(SaveMode.ValidateOnly, cancellationToken);

Use cases:

  • Pre-validating data before showing a confirmation dialog
  • Checking business rules without committing changes
  • Dry-run validation in import scenarios
Warning

After calling SaveAsync(SaveMode.ValidateOnly), the context is left in an inconsistent state. You cannot call SaveAsync() again on the same scope. To perform an actual save after validation, you must create a new scope.

SaveMode.Simulate

This mode performs a complete save operation within a transaction that is automatically rolled back at the end. It validates both business rules and database constraints (unique keys, foreign keys, etc.) without modifying data.

// Simulate save to validate database constraints
Result result = await _unitOfWork.SaveAsync(SaveMode.Simulate, cancellationToken);

if (result.IsFailed)
{
    // Handle constraint violations
    foreach (IError error in result.Errors)
    {
        _logger.LogWarning("Simulation failed: {Error}", error.Message);
    }
}

Key characteristics:

  • Starts a transaction if none exists
  • Executes actual database INSERT/UPDATE/DELETE operations
  • Always rolls back the transaction
  • Does not execute Saved event rules
  • Returns database constraint errors as Result failures
Warning

After calling SaveAsync(SaveMode.Simulate), the context is left in an inconsistent state due to the transaction rollback. You cannot call SaveAsync() again on the same scope. To perform an actual save after simulation, you must create a new scope.

Note

SaveMode.Simulate is not supported for YAML persistence and will throw a NotSupportedException.

Context state after SaveAsync

Important

After a failed SaveAsync() call (regardless of mode), or after using ValidateOnly or Simulate modes, the Unit of Work context is left in an inconsistent state. Do not attempt to call SaveAsync() again on the same scope. You must create a new scope to retry the operation.

// ❌ WRONG: Retrying on the same scope
Result result = await _unitOfWork.SaveAsync(cancellationToken);
if (result.IsFailed)
{
    // Fix the issue...
    await _unitOfWork.SaveAsync(cancellationToken); // This will NOT work correctly!
}

// ✅ CORRECT: Create a new scope to retry
// The retry logic should be handled at a higher level by creating a new request/scope

Save options

Starting with version 3.1, you can provide SaveOptions to configure which save pipeline parts are executed.

Available options:

  • EnableValidationRules (default: true)
  • EnableEventRules (default: true)
  • EnableQueryLogging (default: true)

Basic usage:

SaveOptions options = new()
{
    EnableValidationRules = true,
    EnableEventRules = false,
    EnableQueryLogging = false,
};

Result result = await _unitOfWork.SaveAsync(options, cancellationToken);

You can also combine save mode and options:

Result result = await _unitOfWork.SaveAsync(SaveMode.NeverThrow, options, cancellationToken);

Convenience methods also support options:

await _unitOfWork.SaveOrThrowAsync(options, cancellationToken);
Result result = await _unitOfWork.TrySaveAsync(options, cancellationToken);
Warning

Disabling validation or event rules is dangerous and can bypass core business safeguards. In particular, disabling event rules skips Saving and Saved side effects, which can leave data incomplete or inconsistent. Use this only for controlled technical operations with explicit checks and recovery plans.

One valid advanced case is post-migration data adjustment, where technical treatments must update existing records without being blocked by business rules built for interactive workflows. Even in this case, bypasses must be temporary, traceable, and followed by consistency verification.

Benefits and advantages

Data consistency

  • ACID compliance: Ensures atomicity, consistency, isolation, and durability
  • All-or-nothing: Either all operations succeed or none do
  • Referential integrity: Maintains database constraints across multiple operations

Performance optimization

  • Bulk operations: Multiple changes are batched together
  • Reduced database round-trips: Fewer calls to the database
  • Optimized SQL generation: EntityFramework can optimize generated queries

Error handling

  • Centralized error management: Single point for handling transaction failures
  • Automatic rollback: Failed operations don't leave the database in an inconsistent state
  • Exception safety: Proper cleanup of resources in case of errors

Best practices

When to use implicit transactions

Use implicit transactions (just SaveAsync()) when:

  • Performing simple CRUD operations
  • The default EntityFramework behavior is sufficient
Note

Implicit transactions are suitable for most common scenarios and help keep the code clean and simple.

When to use explicit transactions

Use explicit transactions when:

  • You need to coordinate multiple operations across different repositories
  • You want to control the exact timing of when changes are committed
  • You're implementing complex business logic that requires transactional boundaries
  • You need to handle errors at a specific granular level

Transaction scope guidelines

  • Keep transactions short: Minimize the time between begin and commit
  • Handle exceptions properly: Always include rollback logic in exception handlers
  • Use using statements: Ensure proper disposal of resources

Examples

Local transaction (server method)

    /// <inheritdoc/>
    public async Task ExecuteAsync(int orderId, CancellationToken cancellationToken)
    {
        await _unitOfWork.BeginTransactionAsync(cancellationToken);
        try
        {
            // Batch delete operations
            _orderDetailRepository.GetQuery().Where(o => o.OrderId == orderId).ExecuteDelete();
            _orderRepository.GetQuery().Where(od => od.OrderId == orderId).ExecuteDelete();

            await _unitOfWork.CommitTransactionAsync(cancellationToken);
        }
        catch
        {
            await _unitOfWork.RollbackTransactionAsync(cancellationToken);
            throw;
        }
    }

Global transaction (event rules)

In this case, we want to start a transaction at the Saving event of an entity view and validate it in the Saved event, so as to transact all the database operations performed by the API call.

    public async Task OnSavingAsync(ISavingRuleArguments<IOrderDetailView> args)
    {
        // Starts of global transaction
        await _unitOfWork.BeginTransactionAsync(args.CancellationToken);

        ...
    }
    public async Task OnSavedAsync(ISavedRuleArguments<IOrderDetailView> args)
    {
        // NOTE: This is a simplified example for demonstration purposes.
        // For production scenarios like stock updates, consider using asynchronous processing
        // (e.g., message queues, background services) to avoid blocking the main transaction
        // and improve performance and reliability.

        // Update product stock for created and modified order details
        foreach (IOrderDetailView orderDetail in args.CreatedAndModifiedItems)
        {
            Product product = await _productRepository.GetAsync(orderDetail.ProductId, args.CancellationToken);
            product.Stock -= orderDetail.Quantity;
        }

        Result result = await _unitOfWork.SaveAsync(args.CancellationToken);

        if (result.IsFailed)
        {
            // Rollback the global transaction in case of error
            await _unitOfWork.RollbackTransactionAsync(args.CancellationToken);
            throw new BusinessException(string.Join(Environment.NewLine, result.Errors.Select(e => e.Message)));
        }

        // Commits the global transaction
        await _unitOfWork.CommitTransactionAsync(args.CancellationToken);
    }

Complex business operation

public async Task ProcessOrderAsync(int orderId, List<Order> items, CancellationToken cancellationToken)
{
    // Step 1: Update order status
    Order order = await _orderRepository.GetAsync(orderId, cancellationToken);
    order.Status = OrderStatus.Processing;

    // Step 2: Update inventory
    foreach (Order item in items)
    {
        Product product = await _productRepository.GetAsync(item.ProductId, cancellationToken);
        if (product.Stock < item.Quantity)
        {
            throw new BusinessException($"Not enough stock for product {product.Name}");
        }
        product.Stock -= item.Quantity;
    }

    // Step 3: Create audit log
    AuditLog auditLog = new AuditLog
    {
        Action = "ORDER_PROCESSED",
        OrderId = orderId,
        Timestamp = DateTime.UtcNow
    };
    _auditRepository.Add(auditLog);

    // Step 4: Save all changes with a single transaction
    Result result = await _unitOfWork.SaveAsync(cancellationToken);
    if (result.IsFailed)
    {
        throw new BusinessException(string.Join(Environment.NewLine, result.Errors.Select(e => e.Message)));
    }
}

Common patterns and anti-patterns

✅ Good patterns

Repository integration

public class OrderService
{
    private readonly IUnitOfWork _unitOfWork;
    private readonly IOrderRepository _orderRepository;
    private readonly ICustomerRepository _customerRepository;

    public OrderService(IUnitOfWork unitOfWork,
                       IOrderRepository orderRepository,
                       ICustomerRepository customerRepository)
    {
        _unitOfWork = unitOfWork;
        _orderRepository = orderRepository;
        _customerRepository = customerRepository;
    }

    public async Task CreateOrderAsync(CreateOrderRequest request, CancellationToken cancellationToken)
    {
        Order customer = await _customerRepository.GetAsync(request.CustomerId, cancellationToken);
        Order order = new Order();
        order.CustomerId = request.CustomerId;
        order.AddressDelivery = customer.AddressDelivery;
        order.Date = DateTime.UtcNow;
        order.Items = request.Items;
        _orderRepository.Add(order);

        Result result = await _unitOfWork.SaveAsync(cancellationToken);
        if (result.IsFailed)
        {
            throw new BusinessException(string.Join(Environment.NewLine, result.Errors.Select(e => e.Message)));
        }
    }
}

❌ Anti-patterns to avoid

Long-running transactions

public async Task BadExample(CancellationToken cancellationToken)
{
    await _unitOfWork.BeginTransactionAsync(cancellationToken);

    // This could take minutes!
    List<Data> data = await _externalApi.GetLargeDatasetAsync(cancellationToken);

    // Process data...
    await ProcessDataAsync(data, cancellationToken);

    await _unitOfWork.CommitTransactionAsync(cancellationToken); // Too late!
}

Ignoring the result of SaveAsync

public async Task ImportOrdersAsync(CancellationToken cancellationToken)
{
    // Some operations...

    await _unitOfWork.SaveAsync(cancellationToken); // Ignoring potential errors
}

Ignoring exception handling

public async Task VeryBadExample(CancellationToken cancellationToken)
{
    await _unitOfWork.BeginTransactionAsync(cancellationToken);

    // Some operations...

    await _unitOfWork.CommitTransactionAsync(cancellationToken);
    // No rollback on exception - leaves transaction hanging!
}

Troubleshooting

Common issues

  • Memory issues: Use pagination for large datasets instead of loading everything at once or use batching techniques (to see more details about batching, refer to this documentation)
  • Result handling: Always check the result of SaveAsync() for errors and handle them appropriately

Debugging tips

  • Use SQL profiling to monitor generated queries and transaction behavior with Neos Manager
  • Consult server logs
  • Use step by step debugging to trace your code execution