Skip to main content

Azure AI Search with C#: Build Smart Search (2026)

Learn Azure AI Search with C# — index data, run vector and hybrid queries, and add RAG to your .NET app. Start building intelligent search today. If your application's search box still runs a LIKE '%term%' query against SQL Server, your users are quietly suffering. They type "cheap laptop for uni" and get zero results because your catalogue says "affordable notebook for students." Azure AI Search with C# fixes exactly this problem: it combines classic keyword search, vector embeddings, and semantic reranking into a single managed service that you can drive from .NET with a few dozen lines of code. In this tutorial you'll build a working search index from scratch, run keyword, vector, and hybrid queries, and finish with a Retrieval Augmented Generation (RAG) pattern that grounds an LLM in your own data. This guide targets .NET 9 and the Azure.Search.Documents v11 SDK. Every snippet is runnable. We'll explain why each design choice matter...

Repository and Unit of Work Pattern in C# with EF Core

Last updated: September 29, 2026 · Tested with .NET 10 (SDK 10.0.201) and EF Core 10.0.12 on SQLite

Short answer: the Unit of Work pattern groups several changes into one commit so they all succeed or all fail, and the Repository pattern hides how entities are loaded and saved behind an interface. In EF Core you already have both: DbContext is the unit of work, DbSet<T> is the repository, and one SaveChangesAsync() call is one database transaction. Wrap them in your own IUnitOfWork and IRepository<T> only when you get something for it: named queries in one place, a business layer that does not reference EF Core, or simpler unit tests. Below is a working implementation on .NET 10, with the real output for commits, rollbacks and the one mistake that silently breaks atomicity.

What the Two Patterns Do

A repository behaves like an in-memory collection of domain objects. Instead of writing _context.Customers.Where(c => c.IsActive).ToList() in ten places, you call customers.GetActiveAsync() and the query lives in one class.

A unit of work tracks everything you change during one business operation and writes it out in a single atomic step. Placing an order means inserting the order and reducing stock. If the second write fails after the first succeeded, your data is wrong. The unit of work makes them one commit.

Note what is missing from the repository interface below: there is no Save method. Saving belongs to the unit of work, not to an individual repository. That separation is the whole point.

The Implementation

public interface IRepository<T> where T : class
{
    Task<T?> GetByIdAsync(int id, CancellationToken ct = default);
    Task<IReadOnlyList<T>> FindAsync(Expression<Func<T, bool>> predicate, CancellationToken ct = default);
    Task AddAsync(T entity, CancellationToken ct = default);
    void Update(T entity);
    void Remove(T entity);
}

public class Repository<T>(AppDbContext context) : IRepository<T> where T : class
{
    private readonly DbSet<T> _set = context.Set<T>();

    public async Task<T?> GetByIdAsync(int id, CancellationToken ct = default)
        => await _set.FindAsync([id], ct);

    public async Task<IReadOnlyList<T>> FindAsync(Expression<Func<T, bool>> predicate, CancellationToken ct = default)
        => await _set.Where(predicate).ToListAsync(ct);

    public async Task AddAsync(T entity, CancellationToken ct = default)
        => await _set.AddAsync(entity, ct);

    public void Update(T entity) => _set.Update(entity);
    public void Remove(T entity) => _set.Remove(entity);
}
public interface IUnitOfWork
{
    IRepository<Customer> Customers { get; }
    IRepository<Order> Orders { get; }
    IRepository<Product> Products { get; }
    Task<int> CompleteAsync(CancellationToken ct = default);
}

public class UnitOfWork(AppDbContext context) : IUnitOfWork
{
    public IRepository<Customer> Customers { get; } = new Repository<Customer>(context);
    public IRepository<Order> Orders { get; } = new Repository<Order>(context);
    public IRepository<Product> Products { get; } = new Repository<Product>(context);

    public Task<int> CompleteAsync(CancellationToken ct = default) => context.SaveChangesAsync(ct);
}

Two deliberate differences from the version you see in most tutorials. The methods return IReadOnlyList<T>, not IEnumerable<T> or IQueryable<T>, so callers cannot accidentally re-run or extend the query. And UnitOfWork does not implement IDisposable: the DI container created the DbContext, so the container disposes it. A unit of work that disposes a context it does not own causes ObjectDisposedException in whatever else shares that scope.

Register both as scoped, so each request gets one context shared by all repositories:

builder.Services.AddDbContext<AppDbContext>(o =>
    o.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();

And the service that uses it:

public class OrderService(IUnitOfWork uow)
{
    public async Task PlaceOrderAsync(int customerId, int productId, int qty, CancellationToken ct = default)
    {
        var product = await uow.Products.GetByIdAsync(productId, ct);
        if (product is null || product.Stock < qty)
            throw new InvalidOperationException("Insufficient stock.");

        product.Stock -= qty;   // tracked entity: no Update() call needed
        await uow.Orders.AddAsync(new Order { CustomerId = customerId, ProductId = productId, Quantity = qty }, ct);
        await uow.CompleteAsync(ct);   // one SaveChanges = one transaction
    }
}

What Actually Happens: Seven Runs Against a Real Database

I ran the code against SQLite with foreign keys enabled, starting with one product (stock 5) and no orders. After every step the program re-reads the state from a fresh context, so what you see is what is in the database, not what is in memory.

1) Unit of Work: stock update + order insert commit together
   state after a successful order: stock=3, orders=1

2) A failure before CompleteAsync writes nothing
   threw: Insufficient stock.
   state after a rejected order: stock=3, orders=1

3) A database error inside ONE SaveChanges rolls back everything in it
   threw: SQLite Error 19: 'FOREIGN KEY constraint failed'.
   state after a failed SaveChanges: stock=3, orders=1

4) The trap: two SaveChanges calls are two transactions
   second SaveChanges failed, first one is already committed
   state after two separate SaveChanges: stock=2, orders=1

5) The fix when you really need two saves: an explicit transaction
   rolled back both saves
   state after an explicit transaction: stock=2, orders=1

6) Generic Update() vs change tracking: the SQL EF Core sends
   tracked change   : UPDATE "Products" SET "Stock" = @p0 WHERE "Id" = @p1
   repository.Update: UPDATE "Products" SET "Name" = @p0, "Price" = @p1, "Stock" = @p2 WHERE "Id" = @p3

7) Same order without the wrappers: DbContext already is a unit of work
   state after plain DbContext: stock=0, orders=2

Runs 1 to 3: atomicity comes from SaveChanges, not from your wrapper

In run 3 the stock change was valid and the order insert violated a foreign key. Both were part of one CompleteAsync(), so EF Core wrapped them in one transaction and the database rolled back both: stock stayed at 3. You did not write any transaction code for that. It is built into SaveChanges.

Run 4: the mistake that breaks it

Call SaveChanges twice and you have two transactions. The stock went from 3 to 2 and stayed there even though the order that justified it failed. This is the most common way a "unit of work" stops being one: a repository method that saves internally, a helper that calls SaveChangesAsync "to get the generated Id", or a generic AddAndSaveAsync convenience method. If any repository can save on its own, you no longer have a unit of work.

Run 5: when you genuinely need two saves

Sometimes you do need an intermediate save, for example to get a database-generated key before building the next row. Then open the transaction yourself:

await using var tx = await db.Database.BeginTransactionAsync();
try
{
    product.Stock -= 1;
    await db.SaveChangesAsync();
    db.Orders.Add(order);
    await db.SaveChangesAsync();
    await tx.CommitAsync();
}
catch (DbUpdateException)
{
    await tx.RollbackAsync();
}

Both saves rolled back and the stock stayed at 2. If you use a retrying execution strategy (EnableRetryOnFailure on SQL Server or Azure SQL), wrap the whole block in db.Database.CreateExecutionStrategy().ExecuteAsync(...), otherwise EF Core throws because it cannot retry a transaction you started by hand.

Run 6: what a generic Update() costs

When you load an entity and change a property, EF Core's change tracker knows exactly what changed and sends SET "Stock" = @p0. When you call repository.Update(entity) on an object EF Core did not load, it has nothing to compare against, marks every property as modified, and sends all columns. Two consequences: a concurrent edit to Price by someone else is overwritten with your stale value, and wide tables pay for writing every column. Inside a unit of work you almost never need Update(). Load, modify, complete.

Run 7: the same thing without the pattern

Four lines with a plain DbContext produced the same atomic result. That is the honest baseline to compare your abstraction against.

Should You Use Repository and Unit of Work With EF Core?

Use the wrappers whenSkip them when
The same queries are repeated across many services and you want them named and in one placeThe app is small or mostly CRUD, and the wrapper would only forward calls
Your domain or application layer must not reference EF Core (clean or hexagonal architecture)You rely on EF features such as Include, projections, split queries or compiled queries in most calls
You want service tests that run with simple fakes and no databaseYou already test against SQLite or Testcontainers, which also verifies the SQL
You may replace the data store for part of the model, for example Dapper for readsYou organise by feature (vertical slices, CQRS handlers) and each handler owns its query

If you do wrap, prefer specific repositories with intention-revealing methods (GetActiveWithOpenOrdersAsync) over a purely generic one. The generic repository is a fine base class and a poor public API, because every interesting query ends up needing something it does not expose. See the repository pattern guide for that side in detail, and CQRS with MediatR or vertical slice architecture for the alternative where handlers talk to the DbContext directly.

Gotchas I Hit While Testing

  • EF Core turns on SQLite foreign keys for you. SQLite ignores foreign keys by default, so I expected to need PRAGMA foreign_keys = ON; on the connection I opened myself. I ran step 3 with and without it: both rejected the order with SQLite Error 19. The pragma only matters for code that talks to the connection without EF Core, such as Dapper or a raw SqliteCommand.
  • The EF Core in-memory provider is not a database. It has no transactions and no constraints, so runs 3 to 5 cannot be reproduced with it. Use SQLite in-memory (Data Source=:memory: with one open connection) for tests that need real behaviour.
  • Do not expose IQueryable. It lets the caller add Where and Include after the fact, which moves query logic back into the business layer and turns a disposed context into a runtime exception far from the cause.
  • AddAsync is rarely needed. It exists for value generators that hit the database, such as HiLo sequences. For ordinary keys Add is synchronous work and either is fine.
  • One context per unit of work, never used in parallel. DbContext is not thread-safe. When EF Core detects overlapping calls it throws "A second operation was started on this context instance before a previous operation completed", but detection is not guaranteed. I ran three repository queries with Task.WhenAll on one unit of work against SQLite and got no exception at all, which is the dangerous case: it works on your machine and fails under load on a real server. Await repository calls one at a time, or give each parallel branch its own scope.
  • Background jobs need their own scope. A scoped unit of work injected into a singleton or a hosted service fails at startup or, worse, lives forever. Create a scope per job with IServiceScopeFactory.

Testing a Service That Uses IUnitOfWork

The payoff of the abstraction is a test with no EF Core in it. A hand-written fake is enough:

public class FakeUnitOfWork : IUnitOfWork
{
    public int Completed { get; private set; }
    public IRepository<Customer> Customers { get; } = new FakeRepository<Customer>();
    public IRepository<Order> Orders { get; } = new FakeRepository<Order>();
    public IRepository<Product> Products { get; } = new FakeRepository<Product>();
    public Task<int> CompleteAsync(CancellationToken ct = default) { Completed++; return Task.FromResult(1); }
}

[Fact]
public async Task Rejects_order_when_stock_is_too_low_and_saves_nothing()
{
    var uow = new FakeUnitOfWork();
    await uow.Products.AddAsync(new Product { Id = 1, Name = "Keyboard", Stock = 1 });

    await Assert.ThrowsAsync<InvalidOperationException>(
        () => new OrderService(uow).PlaceOrderAsync(1, 1, qty: 5));

    Assert.Equal(0, uow.Completed);
}

The fake repository behind it is a List<T> with the same five methods. This test and a second one for the success path both pass under dotnet test in 79 ms, with no database involved.

That test checks your business rule. It does not check that the SQL works, that constraints hold, or that the save is atomic. Keep a smaller set of integration tests on a real provider for those, exactly like the seven runs above.

FAQ

Is DbContext a unit of work? Yes. It tracks changes and commits them in one transaction when you call SaveChanges. DbSet<T> is its repository.

Does SaveChanges use a transaction? Yes. If the provider supports transactions, all changes in one SaveChanges call are applied in one transaction and rolled back together on failure, as run 3 shows.

Where should SaveChanges be called? Once, at the end of the business operation, from the application service or handler. Never inside a repository method.

Should the unit of work be scoped, transient or singleton? Scoped, the same lifetime as the DbContext it wraps, so every repository in a request shares one context.

Do I need the repository pattern with EF Core at all? Not for correctness. Use it for organisation and testability when those benefits are real in your codebase, and skip it for small or query-heavy applications.

How do I span two DbContexts or a database and a message broker? Not with this pattern. Use the transactional outbox, as shown in the .NET microservices guide. For two contexts on the same database, share one connection and one transaction with UseTransaction.

Conclusion

The Unit of Work pattern is one rule: one business operation, one commit. EF Core gives you that for free as long as you call SaveChanges once, and takes it away the moment something saves in the middle. Build the repository and unit-of-work wrappers when they buy you named queries, a persistence-free business layer or fast tests, keep saving out of the repositories, avoid the generic Update() on tracked entities, and test atomicity against a real database rather than assuming it. If you are structuring the rest of the application around this, clean architecture in C# shows where these interfaces belong.

References: EF Core: Using transactions · EF Core: Choosing a testing strategy · DbContext lifetime and configuration

Comments

Popular posts from this blog

.NET MAUI Tutorial 2026: Build Cross-Platform Apps in C#

Learn .NET MAUI in 2026 to build iOS, Android, Windows & Mac apps from one C# codebase. Start this cross-platform tutorial with code examples today. .NET MAUI (Multi-platform App UI) is Microsoft's framework for building native iOS, Android, Windows, and macOS apps from a single C# codebase . If you've ever wanted to ship a mobile app without learning Swift, Kotlin, and Win32 separately, this .NET MAUI tutorial for 2026 is your starting point. In this guide you'll learn what .NET MAUI is, why it matters for cross-platform app development in C#, and how to build your first working app — with runnable code examples and the best practices senior engineers actually use in production. What Is .NET MAUI and Why Use It in 2026? .NET MAUI is the evolution of Xamarin.Forms, fully integrated into the modern .NET runtime. With one project and one language — C# — you target four platforms. The framework compiles to native UI controls on each device, so a button on iOS...

Angular 14 : 404 error during refresh page after deployment

In this article, We will learn how to solve 404 file or directory not found angular error in production.  Refresh browser angular 404 file or directory not found error You have built an Angular app and created a production build with ng build --prod You deploy it to a production server. Everything works fine until you refresh the page. The app throws The requested URL was not found on this server message (Status code 404 not found). It appears that angular routing not working on the production server when you refresh the page. The error appears on the following scenarios When you type the URL directly in the address bar. When you refresh the page The error appears on all the pages except the root page.   Reason for the requested URL was not found on this server error In a Multi-page web application, every time the application needs to display a page it has to send a request to the web server. You can do that by either typing the URL in the address bar, clicking on the Me...

Angular 14 CRUD Operation with Web API .Net 6.0

How to Perform CRUD Operation Using Angular 14 In this article, we will learn the angular crud (create, read, update, delete) tutorial with ASP.NET Core 6 web API. We will use the SQL Server database and responsive user interface for our Web app, we will use the Bootstrap 5. Let's start step by step. Step 1 - Create Database and Web API First we need to create Employee database in SQL Server and web API to communicate with database. so you can use my previous article CRUD operations in web API using net 6.0 to create web API step by step. As you can see, after creating all the required API and database, our API creation part is completed. Now we have to do the angular part like installing angular CLI, creating angular 14 project, command for building and running angular application...etc. Step 2 - Install Angular CLI Now we have to install angular CLI into our system. If you have already installed angular CLI into your system then skip this step.  To install angular CLI ope...