Web Development

Unit of Work: A Guide to ACID, CQRS & Clean Architecture

Stop writing fragile .NET apps. This guide shows you how to use the Repository pattern, a pure Unit of Work, and CQRS to build reliable, testable, and robust enterprise applications that guarantee ACID transactions.

11 November 2025
6 min read


As a developer with years of experience building clinical systems for the NHS, I learned one lesson very quickly: reliability is not a feature, it's the foundation.

In the world of patient care, there is no "move fast and break things." A simple data error—a missed update or a partial save—isn't an inconvenience; it's a critical safety risk. When a doctor prescribes medication, you can't partially update the patient's record. The operation must be "all or nothing."

This "all or nothing" principle is known as ACID (Atomicity, Consistency, Isolation, Durability).

With modern Entity Framework Core, SaveChangesAsync() is already an atomic transaction by default. When you call it once, it wraps all of your tracked changes (adds, updates, deletes) into a single transaction.

The challenge is building an architecture that leverages this guarantee in a clean, testable, and maintainable way. Injecting your DbContext directly into a controller is a fast path to a bloated, untestable monolith.

The gold-standard, enterprise-grade solution is to combine three patterns:

  1. CQRS (Commands & Queries): To isolate what you're doing.
  2. Specific Repositories: To abstract how you access data.
  3. A Pure Unit of Work: To atomically commit all your changes.


The Real-World Problem: The "Risky Save"

Let's imagine our e-prescribing module. A doctor's single click needs to perform three operations:

  1. Create the Prescription record.
  2. Use the new Prescription.Id to create a MedicationOrder record.
  3. Add an entry to the PatientAuditLog.

If you call SaveChangesAsync() after each step, you violate Atomicity. If step 2 fails, step 1 is already saved, and your database is now in a corrupt, inconsistent state.


The Solution: A Clean, Testable Architecture

We will build a system where the DbContext itself is the Unit of Work, our repositories are specific, and our business logic is isolated in a CQRS handler.


Step 1: Define Your Interfaces (The "Contract")

We'll define specific interfaces for each repository. No generics.

Interfaces/IPrescriptionRepository.cs

// This repository ONLY handles Prescription entities
public interface IPrescriptionRepository
{
void Add(Prescription prescription);
Task<Prescription?> GetByIdAsync(int id);
}

Interfaces/IMedicationOrderRepository.cs

public interface IMedicationOrderRepository
{
void Add(MedicationOrder order);
}

Interfaces/IAuditLogRepository.cs

public interface IAuditLogRepository
{
void Add(PatientAuditLog log);
}

Interfaces/IUnitOfWork.cs

// The most important interface. It has ONE job.
// It does NOT know about repositories.
public interface IUnitOfWork : IDisposable
{
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}


Step 2: Implement Your Data Layer

We use primary constructors (C# 12) for clean dependency injection.

Data/ClinicalDbContext.cs

// Our DbContext *is* our Unit of Work.
public class ClinicalDbContext(DbContextOptions<ClinicalDbContext> options)
: DbContext(options), IUnitOfWork
{
public DbSet<Prescription> Prescriptions { get; set; }
public DbSet<MedicationOrder> MedicationOrders { get; set; }
public DbSet<PatientAuditLog> PatientAuditLogs { get; set; }

// This is the implementation of the IUnitOfWork's method.
// It just calls the base SaveChangesAsync, which EF Core
// automatically wraps in a single, atomic transaction.
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
return await base.SaveChangesAsync(cancellationToken);
}
}

Data/Repositories/PrescriptionRepository.cs

// A specific repository implementation.
// It only knows about the DbContext, not the Unit of Work.
public class PrescriptionRepository(ClinicalDbContext context) : IPrescriptionRepository
{
public void Add(Prescription prescription)
{
// We just add to the context. We DO NOT save.
await context.Prescriptions.Add(prescription);
}

public async Task<Prescription?> GetByIdAsync(int id)
{
return await context.Prescriptions.FindAsync(id);
}
}

(You would create similar repository classes for MedicationOrder and AuditLog)


Step 3: Define Your Command and Handler (CQRS)

This is where all our business logic lives, using MediatR.

Features/Prescriptions/Commands/CreatePrescriptionCommand.cs

// Using MediatR library
using MediatR;

// The "Command" message
public class CreatePrescriptionCommand : IRequest<int>
{
public int PatientId { get; set; }
public int DoctorId { get; set; }
public MedicationRequest Medication { get; set; }
}

// The "Handler" - all our logic lives here.
// We use a primary constructor to inject all our dependencies.
public class CreatePrescriptionCommandHandler(
IPrescriptionRepository prescriptionRepo,
IMedicationOrderRepository orderRepo,
IAuditLogRepository auditRepo,
IUnitOfWork unitOfWork)
: IRequestHandler<CreatePrescriptionCommand, int>
{
public async Task<int> Handle(CreatePrescriptionCommand request, CancellationToken cancellationToken)
{
// Our business logic is now perfectly isolated and testable.
try
{
// 1. Create the prescription
var prescription = new Prescription
{
PatientId = request.PatientId,
DoctorId = request.DoctorId,
Status = "ACTIVE"
};
// This just adds to the ChangeTracker via the repository
prescriptionRepo.Add(prescription);

// 2. Create the medication order
var order = new MedicationOrder
{
Prescription = prescription, // EF Core links the objects
MedicationName = request.Medication.Name,
Dosage = request.Medication.Dosage
};
// This also adds to the ChangeTracker
orderRepo.Add(order);

// 3. Log the audit event
var audit = new PatientAuditLog
{
PatientId = request.PatientId,
ActorId = request.DoctorId,
Action = $"Prescribed {request.Medication.Name}"
};
// This also adds to the ChangeTracker
auditRepo.Add(audit);

// 4. All three operations are now in the context.
// We call SaveChangesAsync() ONCE via the Unit of Work.
// This is our single atomic transaction.
await unitOfWork.SaveChangesAsync(cancellationToken);

// Return the ID of the newly created prescription
return prescription.Id;
}
catch (Exception ex)
{
// If the single SaveChangesAsync() fails,
// EF Core automatically rolls back the entire transaction.
// The database is safe.
throw new ApplicationException("Failed to create prescription.", ex);
}
}
}


Step 4: Register Your Services (The "Setup")

In Program.cs, we wire everything together.

Program.cs

var builder = WebApplication.CreateBuilder(args);

// 1. Register MediatR
builder.Services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));

// 2. Register DbContext
builder.Services.AddDbContext<ClinicalDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));

// 3. Register Repositories
builder.Services.AddScoped<IPrescriptionRepository, PrescriptionRepository>();
builder.Services.AddScoped<IMedicationOrderRepository, MedicationOrderRepository>();
builder.Services.AddScoped<IAuditLogRepository, AuditLogRepository>();

// 4. Register the Unit of Work
// This tells DI: "When someone asks for IUnitOfWork,
// give them the already-created ClinicalDbContext instance for this request."
builder.Services.AddScoped<IUnitOfWork>(provider =>
provider.GetRequiredService<ClinicalDbContext>());

builder.Services.AddControllers();
// ...


Step 5: Your Controller is Now Tiny & Clean

Using a primary constructor, the controller is minimal. Its only job is to handle HTTP and send commands and queries.

Controllers/PrescriptionController.cs

[ApiController]
[Route("api/[controller]")]
public class PrescriptionController(ISender sender) : ControllerBase
{
[HttpPost]
public async Task<IActionResult> CreatePrescription(CreatePrescriptionCommand command)
{
try
{
// 1. Send the command to MediatR.
// MediatR finds the correct handler and executes it.
var prescriptionId = await sender.Send(command);

// 2. Return the result
return Created();
}
catch (ApplicationException ex)
{
// This is the exception we threw from our handler
return Results.Problem(ex.Message, statusCode: 500);
}
catch (Exception ex)
{
// A generic error
return Results.Problem("An unexpected error occurred.", statusCode: 500);
}
}
}


Found this helpful?

Get in touch to discuss your project or learn more about my development services.

Unit of Work: A Guide to ACID, CQRS & Clean Architecture - Max Jeffery Blog