Few patterns generate more disagreement in .NET interviews than the repository, because most candidates learned it as a default and few have reasoned about what it actually buys once EF Core is already in the picture. Interviewers use these questions to separate developers who apply patterns by habit from those who can explain what problem a repository solves, when DbContext already solves it, and what a specification or a CQRS read side looks like when a generic repository gets in the way. Expect scenario-based follow-ups about testing, aggregate boundaries and query flexibility, because that is where "just add a repository" answers usually fall apart. These ten questions cover the anti-pattern debate, generic repository pitfalls, the specification pattern, unit of work versus DbContext, testing strategy and repository-free CQRS reads.

Q1 Is wrapping EF Core's DbContext in a repository pattern an anti-pattern?#

Short answer: Not inherently, but a generic repository over EF Core usually is, because DbContext is already a unit of work and DbSet<TEntity> already behaves like a repository. A repository earns its place when it is scoped to an aggregate root, has intention-revealing methods, and exists to hide persistence details from a domain layer or to give tests a seam — not when it just re-exposes DbSet through another name.

The strongest argument against a generic wrapper is that EF Core already gives you the two things a repository is meant to provide: change tracking that batches inserts, updates and deletes into one transaction on SaveChanges, and a queryable surface for reads. Adding IRepository<T> with GetAll, Add, Update and Delete on top does not remove a dependency on EF Core's concepts, it just renames them, while making the LINQ capability you actually want — projection, split queries, compiled queries — harder to reach through a thin, forgettable interface. The Entity Framework Core guide covers why this is EF Core's own stated design, not just an opinion.

Where a repository genuinely helps is narrower: a repository per aggregate root, such as IOrderRepository with methods like FindWithLinesAsync and Add, keeps loading rules (which navigation properties, which filters) in one place instead of scattered across handlers, and gives you a seam to fake in unit tests without a database. That is a repository as an aggregate boundary, not a database abstraction.

What interviewers look for: a nuanced answer, not a blanket "always" or "never"; the specific reason DbContext already covers unit of work and DbSet already covers basic repository behavior; a distinction between a generic CRUD wrapper and an aggregate-scoped repository.

Common mistakes: defending a generic repository as "for swapping databases," a scenario that almost never happens and that a generic interface would not survive anyway because query capabilities differ by provider; dismissing repositories entirely without acknowledging the aggregate-boundary and testing-seam cases where they help.

Follow-up questions:

  • What would make you introduce a repository on a project that started without one?
  • How do you explain this trade-off to a team that learned "always use repositories" as a rule?

Q2 What's wrong with a generic IRepository<T> with CRUD methods layered over EF Core?#

Short answer: It re-implements DbSet<T> with a smaller, less capable surface, and every real query need eventually forces you to either bloat the interface with one method per query or add an IQueryable escape hatch that defeats the abstraction entirely. It also flattens every entity to the same shape, ignoring that aggregates have different loading and consistency rules.

C#
public interface IRepository<T> where T : class
{
    Task<T?> GetByIdAsync(int id, CancellationToken ct);
    Task<List<T>> GetAllAsync(CancellationToken ct);
    Task AddAsync(T entity, CancellationToken ct);
    void Update(T entity);
    void Remove(T entity);
}

This looks reusable, but in practice: GetAllAsync on an Orders table with a million rows is a production incident waiting to happen; there is nowhere to express "orders for this customer, paginated, with their lines" without either a new method per query (GetOrdersByCustomerPagedWithLinesAsync, and soon a dozen siblings) or exposing IQueryable<T> so callers can compose their own — at which point you have rebuilt DbSet<T> with extra steps and lost the ability to swap the persistence technology anyway, since callers are now writing LINQ against an EF Core-translatable expression tree. Compiled queries, AsSplitQuery, AsNoTracking and projection to DTOs all become awkward or impossible to express through a one-size-fits-all interface. The practical result is that teams either abandon the generic repository within a year or keep it and route around it with raw DbContext access anyway.

What interviewers look for: naming the concrete failure modes (unbounded GetAll, the IQueryable-escape-hatch trap, lost access to projection and compiled queries) instead of a vague "it's not flexible enough."

Common mistakes: proposing IQueryable<T> as the fix without noticing it just moves the leaky abstraction one layer up; not connecting the pitfalls to specific EF Core capabilities that get lost.

Q3 Explain the specification pattern and how it addresses the generic repository's problems.#

Short answer: A specification encapsulates one reusable, named piece of query logic — filtering, includes, ordering, paging — as an object instead of a repository method or a leaked IQueryable. A repository then exposes one general method, such as ListAsync(ISpecification<T> spec), and callers compose specifications instead of growing the repository interface per query.

Ardalis.Specification (current stable release 9.3.1 on NuGet) is the most widely used implementation of this in .NET: it defines Specification<T> with a fluent builder (Where, Include, OrderBy, Paginate, AsNoTracking and similar), and its companion package, Ardalis.Specification.EntityFrameworkCore, supplies a SpecificationEvaluator that turns a specification into an EF Core query, plus a ready-made RepositoryBase<T> that evaluates specifications for you.

C#
public sealed class OrdersForCustomerSpec : Specification<Order>
{
    public OrdersForCustomerSpec(Guid customerId, int page, int pageSize)
    {
        Query.Where(o => o.CustomerId == customerId)
             .Include(o => o.Lines)
             .OrderByDescending(o => o.PlacedAt)
             .Paginate(page, pageSize)
             .AsNoTracking();
    }
}

// One repository method handles every query shape:
var orders = await repository.ListAsync(new OrdersForCustomerSpec(customerId, page: 1, pageSize: 20), ct);

This solves the generic repository's core problem: the query logic is named, testable and reusable on its own, but the repository interface stays small and stable instead of growing a method per screen. It also keeps query composition out of controllers and handlers, which is where ad hoc IQueryable chains tend to accumulate and duplicate.

What interviewers look for: understanding that specifications solve the "one method per query" problem without exposing IQueryable; concrete familiarity with the pattern's shape, even without naming the exact package; awareness that specifications are themselves testable in isolation.

Common mistakes: describing specifications as just another name for a generic repository, missing that the point is composability of named query fragments; forgetting that a specification's Where clauses are still expression trees, so they inherit the same translation limits as any EF Core LINQ query.

Q4 How does unit of work relate to DbContext? Do you need a separate IUnitOfWork abstraction?#

Short answer: DbContext already is a unit of work: it tracks every change across every DbSet you touch during its lifetime and commits them together, atomically, in one transaction when you call SaveChangesAsync. A separate IUnitOfWork interface is only useful when you need to hide that EF Core is the persistence technology from a layer that should not reference it, and even then it is often implemented as a thin wrapper that just forwards to the context.

C#
public interface IUnitOfWork
{
    Task<int> SaveChangesAsync(CancellationToken ct);
}

// Infrastructure: the DbContext itself fulfills the abstraction
services.AddScoped<IUnitOfWork>(sp => sp.GetRequiredService<ShopDbContext>());

The failure mode to watch for is a second unit of work layered on top of the context that duplicates its job — its own list of pending changes, its own commit method — when DbContext was already doing exactly that. That duplication adds a synchronization bug waiting to happen: two repositories backed by the same DbContext scope share one unit of work by construction, because they share the same change tracker, so introducing a separate IUnitOfWork.Commit() that does something other than call the context's SaveChangesAsync is almost always wrong. The one case worth an explicit interface is coordinating a single commit across several aggregate repositories that must succeed or fail together within one request — which, again, DbContext handles for free as long as they share the same scoped instance.

What interviewers look for: stating plainly that DbContext is a unit of work rather than treating "unit of work" as an unrelated concept that needs its own class; recognizing when an explicit interface is still justified (hiding EF Core from a layer) versus redundant.

Common mistakes: building a custom unit of work that tracks its own list of dirty entities separately from the change tracker, which can drift out of sync with what EF Core actually persists.

Q5 How do you unit test business logic that depends on a repository, without hitting a real database?#

Short answer: Depend on a narrow, aggregate-scoped repository interface and substitute a fake or a mocking library (NSubstitute, Moq or a hand-written in-memory implementation) in unit tests, reserving a real database — via Testcontainers, not the EF Core InMemory provider — for integration tests that verify the repository implementation itself and real query translation.

C#
[Fact]
public async Task PlaceOrder_rejects_when_customer_has_no_credit()
{
    var repository = Substitute.For<IOrderRepository>();
    repository.FindCustomerAsync(customerId, Arg.Any<CancellationToken>())
        .Returns(new Customer(customerId, CreditLimit: 0m));

    var handler = new PlaceOrderHandler(repository);
    var result = await handler.HandleAsync(new PlaceOrderCommand(customerId, Total: 50m), default);

    Assert.False(result.Succeeded);
    await repository.DidNotReceive().AddAsync(Arg.Any<Order>(), Arg.Any<CancellationToken>());
}

This is exactly where an aggregate-scoped repository interface earns its keep over raw DbContext injection: a handler that depends on IOrderRepository is trivial to test with a substitute, while a handler that depends directly on ShopDbContext either needs a real database or the EF Core InMemory provider, which Microsoft's own guidance cautions against for anything beyond the simplest cases, because it does not enforce relational constraints and can translate and behave differently from a real provider. Reserve the InMemory provider, if at all, for the narrowest smoke tests, and use integration tests with a real, disposable database for anything that exercises actual query behavior, constraints or concurrency.

What interviewers look for: a clear split between unit tests (fakes, fast, test logic) and integration tests (real database, slower, test persistence); specific awareness of the EF Core InMemory provider's limitations rather than treating it as a safe default; comfort with a mocking library's syntax.

Common mistakes: unit testing against the InMemory provider and believing it validates real query behavior; testing repository implementations only indirectly through handler tests instead of also testing them directly against a real database.

Q6 How would you design repositories on the CQRS read side, or would you skip them entirely?#

Short answer: Skip them. The read side of CQRS wants the fastest, most direct path from a query to a shaped result, and a repository interface designed around aggregate loading is the wrong tool for that; query handlers should read directly, typically with EF Core projections or Dapper, bypassing repositories built for the write side.

Think of it as a ladder. Most applications should stop at the first rung: separate query handlers using AsNoTracking LINQ projections straight to a DTO, still against the write-side tables, which costs almost nothing extra and avoids loading full aggregates just to read a few fields.

C#
public sealed class GetOrderSummaryHandler(ShopDbContext db)
{
    public Task<OrderSummaryDto?> HandleAsync(Guid orderId, CancellationToken ct) =>
        db.Orders.AsNoTracking()
            .Where(o => o.Id == orderId)
            .Select(o => new OrderSummaryDto(o.Id, o.Status, o.Total, o.Lines.Count))
            .FirstOrDefaultAsync(ct);
}

Only climb further when justified: dedicated read shapes such as views or denormalized tables, often queried with Dapper for speed and SQL control, or a fully separate read store updated asynchronously from domain events or a change feed when reads and writes need to scale independently. CQRS and the mediator pattern covers this ladder in depth, including why Microsoft's own eShopOnContainers reference architecture queried with Dapper on the read side against the same database EF Core wrote to. Wrapping that in a repository interface designed for aggregate consistency just adds an abstraction with no job to do.

What interviewers look for: recognizing that repositories are a write-side, aggregate-boundary concept that does not map cleanly to arbitrary read shapes; naming the incremental ladder rather than jumping straight to a separate read store; comfort mixing EF Core and Dapper by responsibility.

Common mistakes: forcing read queries through the same repository interface as writes, which either under-fetches or over-fetches; introducing a separate read database before proving the single-database projection approach is insufficient.

Q7 How do you handle cross-aggregate queries in a repository-per-aggregate design without violating aggregate boundaries?#

Short answer: Keep repositories scoped to loading and saving one aggregate for command handling, and answer cross-aggregate queries outside the repository layer entirely — through a dedicated query handler, a projection or a read model that joins across tables directly, since a query does not need to respect write-side aggregate boundaries the way a command does.

Aggregate boundaries exist to protect invariants during writes: an Order aggregate enforces that its lines cannot exceed available inventory as it is being modified, so IOrderRepository should load and save exactly what that invariant needs, no more. A query that needs to show a customer their order history alongside loyalty points from a different aggregate has no invariant to protect — it just needs data — so forcing it through two repositories and stitching the results in application code adds ceremony for no consistency benefit. It is more direct, and usually more efficient, to answer it with a purpose-built query that projects from both tables:

C#
var summary = await db.Orders.AsNoTracking()
    .Where(o => o.CustomerId == customerId)
    .Select(o => new { o.Id, o.Total, Points = o.Customer.LoyaltyPoints })
    .ToListAsync(ct);

If cross-aggregate reads become a dominant workload, that is the signal to invest in a dedicated read model rather than to relax write-side aggregate boundaries to make joins convenient — the two problems have different solutions and conflating them tends to erode the invariants the aggregate exists to protect.

What interviewers look for: separating the concerns of write-side consistency from read-side convenience; a specific technique (direct projection, dedicated read model) rather than "just join them in the repository"; recognition that relaxing aggregate boundaries for query convenience is a trade-off, not a free win.

Common mistakes: adding cross-aggregate navigation properties purely to make one query easier, which then makes it easy to accidentally modify a second aggregate through the first one's repository.

Q8 Walk through a repository interface for an Order aggregate that correctly encapsulates persistence.#

Short answer: A well-designed aggregate repository exposes a small number of intention-revealing methods that match how the domain actually loads and saves the aggregate — not generic CRUD — and it returns and accepts the aggregate root only, never its internal entities directly.

C#
public interface IOrderRepository
{
    Task<Order?> FindWithLinesAsync(Guid orderId, CancellationToken ct);
    Task<IReadOnlyList<Order>> FindOpenOrdersForCustomerAsync(Guid customerId, CancellationToken ct);
    void Add(Order order);
}

internal sealed class OrderRepository(ShopDbContext db) : IOrderRepository
{
    public Task<Order?> FindWithLinesAsync(Guid orderId, CancellationToken ct) =>
        db.Orders.Include(o => o.Lines).FirstOrDefaultAsync(o => o.Id == orderId, ct);

    public Task<IReadOnlyList<Order>> FindOpenOrdersForCustomerAsync(Guid customerId, CancellationToken ct) =>
        db.Orders.Where(o => o.CustomerId == customerId && o.Status == OrderStatus.Open)
            .ToListAsync(ct).ContinueWith(t => (IReadOnlyList<Order>)t.Result, ct);

    public void Add(Order order) => db.Orders.Add(order); // SaveChanges happens in the unit of work
}

Notice what is absent: no Update method, because EF Core's change tracker already knows an entity loaded through this repository is dirty once you mutate it, and no Remove exposed generically, because deleting an order is a domain decision (order.Cancel()) that the aggregate itself should model, not a persistence operation a caller invokes directly. FindWithLinesAsync names exactly what it loads, so the caller never wonders whether lines are included. This is the difference between a repository as an aggregate boundary and a repository as a DbSet wrapper: every method here reflects a real use case from the domain, not a CRUD verb.

What interviewers look for: intention-revealing method names tied to actual use cases; omitting generic Update/GetAll and explaining why; keeping SaveChanges out of the repository so multiple repository calls can share one unit of work.

Common mistakes: adding a generic Update(T entity) out of habit when the change tracker already covers it; calling SaveChangesAsync inside the repository itself, which prevents composing several repository operations into one transaction.

Q9 What testing strategy validates repository implementations themselves, not just the code that uses them?#

Short answer: Integration tests against a real, disposable instance of the target database — using Testcontainers to spin one up in CI — that exercise the repository's actual queries, includes, concurrency handling and constraints, since this is precisely the layer where a fake or an in-memory substitute would hide real bugs.

Unit tests with a fake repository validate that your business logic calls the repository correctly; they say nothing about whether FindWithLinesAsync actually returns the lines, whether a unique index correctly rejects a duplicate, or whether an optimistic concurrency check throws when it should. Those questions require a real engine:

C#
public sealed class OrderRepositoryTests : IAsyncLifetime
{
    private readonly MsSqlContainer _sql = new MsSqlBuilder().Build();
    private ShopDbContext _db = null!;

    public async Task InitializeAsync()
    {
        await _sql.StartAsync();
        _db = new ShopDbContext(new DbContextOptionsBuilder<ShopDbContext>()
            .UseSqlServer(_sql.GetConnectionString()).Options);
        await _db.Database.MigrateAsync();
    }

    [Fact]
    public async Task FindWithLinesAsync_includes_order_lines()
    {
        var order = new Order(Guid.NewGuid(), CustomerId: Guid.NewGuid());
        order.AddLine("sku-1", quantity: 2);
        _db.Orders.Add(order);
        await _db.SaveChangesAsync();

        var repository = new OrderRepository(_db);
        var loaded = await repository.FindWithLinesAsync(order.Id, default);

        Assert.NotNull(loaded);
        Assert.Single(loaded!.Lines);
    }

    public Task DisposeAsync() => _sql.DisposeAsync().AsTask();
}

Run this suite against the same database engine and provider used in production, apply real migrations first so the schema matches, and cover the cases a fake cannot: concurrency conflicts, cascade behavior, and query translation for anything beyond trivial Where clauses. Unit Testing in .NET covers the fake and mock side of this split in depth.

What interviewers look for: a clear rationale for why repository implementations need real-database tests specifically, distinct from unit tests of the code that calls them; comfort with Testcontainers or an equivalent disposable database strategy; awareness that migrations must run before assertions.

Common mistakes: considering a repository "tested" because the handler tests pass with a fake; using a different database engine or provider in tests than in production, which hides provider-specific translation differences.

Q10 Should repository interfaces live in the domain layer, or can they expose EF Core-specific types like IQueryable?#

Short answer: Repository interfaces belong in the domain or application layer and should expose only plain domain types and value objects — never IQueryable<T>, DbSet<T> or anything from Microsoft.EntityFrameworkCore — because the whole point of the interface is to let that layer stay ignorant of the persistence technology implementing it.

Returning IQueryable<T> from a repository is the most common way this boundary gets quietly broken: it compiles, it is convenient, and it lets callers "just add a .Where()" — but it also means the domain or application layer is now composing an EF Core-translatable expression tree, which only works if the concrete implementation is EF Core, defeating the interface's purpose. It also makes execution timing implicit: whether a Where clause runs in the database or after materialization depends on where the IQueryable is finally enumerated, which is easy to get wrong. Returning IReadOnlyList<T> or explicit DTOs from concrete, named methods keeps the boundary real: the domain layer states what it needs, and the infrastructure implementation decides how to fetch it efficiently, including with projections the domain layer never sees.

What interviewers look for: identifying IQueryable leakage specifically as the anti-pattern, not just "don't reference EF Core"; understanding why deferred execution makes a leaked IQueryable risky as well as architecturally impure; a working example of returning materialized results instead.

Common mistakes: allowing IQueryable<T> "just for this one screen" and watching it spread to every repository method within a few months; conflating "the interface is in the domain project" with "the interface is actually decoupled from EF Core," when a leaked IQueryable return type undoes the separation either way.

Quick-Fire Round#

QuestionAnswer
What role does DbContext already play that a custom unit of work re-implements?Unit of work
What EF Core type does a generic repository usually re-implement badly?DbSet<T>
What pattern names a reusable, composable piece of query logic?The specification pattern
Current stable Ardalis.Specification NuGet version?9.3.1
What return type from a repository breaks the persistence-ignorance boundary?IQueryable<T>
Should repositories expose a generic Update method with EF Core?No, the change tracker covers it
What replaces repositories on a CQRS read side?Direct projections or a dedicated read model
Where should aggregate invariants be enforced?Inside the aggregate root itself
What should validate a repository implementation, not just its callers?Integration tests, real database
What testing tool spins up a real, disposable database for CI?Testcontainers

How to Prepare#

  • Be ready to argue both sides of "repository over EF Core: yes or no" and land on a scoped, defensible position rather than a rule.
  • Practice sketching an aggregate-scoped repository interface live, and be ready to justify every method name.
  • Know the specification pattern's shape well enough to write a small example from memory, even without the exact package API.
  • Rehearse the unit-test-versus-integration-test split for repositories, including why the EF Core InMemory provider is not a substitute for a real database in tests.
  • Prepare a generic story about a generic repository that grew unmanageable, and what replaced it.