Skip to ContentSkip to Content
Unit of Work

IUnitOfWork

IUnitOfWork is the entry point for all data operations in Forge.Repository. It coordinates multiple repositories that share a single DbContext instance, ensuring that changes across any number of entity types are committed atomically in one database transaction.

namespace Forge.Interfaces; public interface IUnitOfWork { IRepositoryAsync<TSet> GetRepositoryAsync<TSet>() where TSet : class, IBaseModel; Task<int> SaveChangesAsync(CancellationToken cancellationToken = default); int SaveChanges(); void DetachAllAsync<TSet>() where TSet : class, IBaseModel; }

Registration

IUnitOfWork is automatically registered as scoped when you call any of the AddForgeRepository* extension methods. You never register it manually.

// This registers IUnitOfWork → DBUnitOfWork (scoped) automatically builder.Services.AddForgeRepositorySqlServer<AppDbContext>(...);

GetRepositoryAsync<T>

Resolves a typed repository for the given entity type. All repositories resolved from the same IUnitOfWork instance share the same DbContext — this is what makes atomic commits across entity types possible.

IRepositoryAsync<TSet> GetRepositoryAsync<TSet>() where TSet : class, IBaseModel;

Single repository

public class ProductService(IUnitOfWork uow) { private readonly IRepositoryAsync<Product> _products = uow.GetRepositoryAsync<Product>(); }

Multiple repositories — same transaction

public class OrderService(IUnitOfWork uow) { private readonly IRepositoryAsync<Order> _orders = uow.GetRepositoryAsync<Order>(); private readonly IRepositoryAsync<OrderItem> _items = uow.GetRepositoryAsync<OrderItem>(); private readonly IRepositoryAsync<Stock> _stock = uow.GetRepositoryAsync<Stock>(); public async Task PlaceOrderAsync( Order order, ICollection<OrderItem> items, CancellationToken ct) { // Stage all writes across three entity types await _orders.AddAsync(order, ct); await _items.AddRangeAsync(items, ct); foreach (var item in items) { var stockEntry = await _stock.GetByIdAsync(item.ProductId, ct); stockEntry.Quantity -= item.Quantity; await _stock.UpdateAsync(stockEntry, ct); } // One SaveChangesAsync commits ALL staged changes atomically await uow.SaveChangesAsync(ct); } }

All three entity types — Order, OrderItem, and Stock — are committed in a single round-trip to the database. If any write fails, none of the changes are persisted.


SaveChangesAsync

Commits all staged changes (inserts, updates, deletes) to the database. Returns the number of rows written.

Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
await _products.AddAsync(product, ct); await _categories.UpdateAsync(category, ct); // Both writes committed in one transaction int rowsAffected = await uow.SaveChangesAsync(ct);

If you forget to call SaveChangesAsync, your staged changes are silently discarded at the end of the request scope. Always call it after your writes.


SaveChanges

Synchronous variant. Prefer SaveChangesAsync in web and API applications — SaveChanges is provided for console tools, background workers, and scenarios where async context is unavailable.

int SaveChanges();
_products.AddAsync(product, CancellationToken.None).GetAwaiter().GetResult(); uow.SaveChanges();

DetachAllAsync<T>

Detaches all tracked entities of a given type from the DbContext change tracker. After detachment, any changes to those entities will not be persisted on the next SaveChangesAsync call.

void DetachAllAsync<TSet>() where TSet : class, IBaseModel;

Despite the Async suffix in the name, this method is synchronous. The name reflects intent (clearing async context) rather than execution model.

When to use

ScenarioWhy detach
After a large batch write followed by a fresh readPrevents stale tracked entities from being returned
Long-running background jobs that reuse a contextPrevents change tracker memory from growing unbounded
Read-then-write with stale data riskClear tracking to force a fresh database read
// Write a large batch await _products.AddRangeAsync(importedProducts, ct); await uow.SaveChangesAsync(ct); // Clear tracking so the next read hits the database fresh uow.DetachAllAsync<Product>(); // Fresh read — no stale tracked entities var saved = await _products.GetAllAsync(ct);

Patterns

Repository per service

The most common pattern — inject IUnitOfWork once per service and resolve repositories in the constructor.

public class CatalogService(IUnitOfWork uow) { private readonly IRepositoryAsync<Product> _products = uow.GetRepositoryAsync<Product>(); private readonly IRepositoryAsync<Category> _categories = uow.GetRepositoryAsync<Category>(); public async Task<Product> CreateProductAsync( CreateProductDto dto, CancellationToken ct) { var category = await _categories.GetByIdAsync(dto.CategoryId, ct) ?? throw new NotFoundException("Category not found"); var product = new Product { Name = dto.Name, Price = dto.Price, CategoryId = category.Id, }; await _products.AddAsync(product, ct); await uow.SaveChangesAsync(ct); return product; } }

Cross-aggregate transaction

When a single operation must span multiple aggregates:

public class TransferService(IUnitOfWork uow) { private readonly IRepositoryAsync<Account> _accounts = uow.GetRepositoryAsync<Account>(); private readonly IRepositoryAsync<Transaction> _transactions = uow.GetRepositoryAsync<Transaction>(); public async Task TransferAsync( string fromId, string toId, decimal amount, CancellationToken ct) { var from = await _accounts.GetByIdAsync(fromId, ct) ?? throw new NotFoundException(); var to = await _accounts.GetByIdAsync(toId, ct) ?? throw new NotFoundException(); if (from.Balance < amount) throw new InsufficientFundsException(); from.Balance -= amount; to.Balance += amount; var transaction = new Transaction { FromAccountId = fromId, ToAccountId = toId, Amount = amount, }; await _accounts.UpdateAsync(from, ct); await _accounts.UpdateAsync(to, ct); await _transactions.AddAsync(transaction, ct); // Atomic — all three writes or none await uow.SaveChangesAsync(ct); } }

Conditional save

You can stage changes from multiple operations and decide whether to commit:

public async Task<bool> TryApproveOrderAsync(string orderId, CancellationToken ct) { var order = await _orders.GetByIdAsync(orderId, ct); if (order is null || order.Status != OrderStatus.Pending) return false; order.Status = OrderStatus.Approved; await _orders.UpdateAsync(order, ct); // Only commit if we reach this line — validation passed await uow.SaveChangesAsync(ct); return true; }