Data access abstractions and EF Core implementation for clean architecture .NET applications.
This solution provides two NuGet packages that enforce clean architecture by separating data access abstractions from their EF Core implementation:
Your Application layer references only Clywell.Core.Data → zero EF Core dependency.
Your Infrastructure layer references Clywell.Core.Data.EntityFramework → provides the implementations.
- Repository Pattern —
IReadRepository<T, TId>andIRepository<T, TId>with full CRUD - Specification Pattern — Composable, testable, reusable query objects with fluent builder API
- Projection Specifications —
Specification<T, TResult>withSelect()for read-optimized queries - Eager Loading — Strongly-typed
Include/ThenIncludebuilder with collection support - Unit of Work —
IDataContextwithRepository<T, TId>()(likeDbContext.Set<T>()),SaveChangesAsync, andBeginTransactionAsync— single injection for command handlers - Explicit Transactions —
IDataTransactionwithCommitAsync/RollbackAsyncandIAsyncDisposable - DI Registration —
AddDataAccess<TContext>(),AddRepository<TInterface, TImpl>(), andAddRepositoriesFromAssembly()for auto-scanning, or source-generatedAddRepositories()for compile-time registration (NativeAOT/trimmer compatible)
# Application layer (abstractions only)
dotnet add package Clywell.Core.Data
# Infrastructure layer (EF Core implementation + bundled source generator)
dotnet add package Clywell.Core.Data.EntityFrameworkpublic sealed class Ticket : IEntity<Guid>
{
public Guid Id { get; private set; }
public Guid TenantId { get; private set; }
public string Title { get; private set; } = string.Empty;
public string Status { get; private set; } = "Open";
public DateTime CreatedAtUtc { get; private set; }
public IReadOnlyList<Comment> Comments { get; private set; } = [];
}public interface ITicketRepository : IRepository<Ticket, Guid>
{
// Add domain-specific query methods if needed
}Specifications encapsulate all query logic — filters, ordering, paging, and eager loading — in a
single reusable class. Multiple Where calls are AND'd together.
public sealed class ActiveTicketsByTenantSpec : Specification<Ticket>
{
public ActiveTicketsByTenantSpec(Guid tenantId, int page, int pageSize)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(pageSize);
Where(t => t.TenantId == tenantId);
Where(t => t.Status == "Open");
OrderByDescending(t => t.CreatedAtUtc);
ApplyPaging((page - 1) * pageSize, pageSize);
AsReadOnly();
}
}
// Projection specification — maps to a DTO directly in the SQL query
public sealed class TicketSummarySpec : Specification<Ticket, TicketSummaryDto>
{
public TicketSummarySpec(Guid tenantId)
{
Where(t => t.TenantId == tenantId);
OrderByDescending(t => t.CreatedAtUtc);
Select(t => new TicketSummaryDto(t.Id, t.Title, t.Status));
AsReadOnly();
}
}
// Eager loading — load related navigation properties
public sealed class TicketWithCommentsSpec : Specification<Ticket>
{
public TicketWithCommentsSpec(Guid tenantId, Guid ticketId)
{
Where(t => t.TenantId == tenantId);
Where(t => t.Id == ticketId);
IncludeCollection(t => t.Comments)
.ThenInclude(c => c.Author);
AsReadOnly();
}
}Query handler using a specification:
public sealed class GetActiveTicketsHandler
{
private readonly IReadRepository<Ticket, Guid> _repository;
public GetActiveTicketsHandler(IReadRepository<Ticket, Guid> repository)
=> _repository = repository;
public async Task<IReadOnlyList<Ticket>> HandleAsync(
Guid tenantId, int page, int pageSize, CancellationToken ct)
{
var spec = new ActiveTicketsByTenantSpec(tenantId, page, pageSize);
return await _repository.ListAsync(spec, ct);
}
}Projection query — let the database do the column selection:
public async Task<IReadOnlyList<TicketSummaryDto>> GetSummariesAsync(
Guid tenantId, CancellationToken ct)
{
var spec = new TicketSummarySpec(tenantId);
return await _repository.ListAsync(spec, ct);
}Existence and count checks:
// Check whether any ticket matches without loading data
bool hasOpen = await _repository.AnyAsync(
new ActiveTicketsByTenantSpec(tenantId, 1, 1), ct);
// Count without paging (spec paging/ordering is ignored for count queries)
int totalOpen = await _repository.CountAsync(
new ActiveTicketsByTenantSpec(tenantId, 1, int.MaxValue), ct);
// Retrieve single known entity
Ticket? ticket = await _repository.GetByIdAsync(ticketId, ct);
// Retrieve first match
Ticket? first = await _repository.FirstOrDefaultAsync(
new ActiveTicketsByTenantSpec(tenantId, 1, 1), ct);Command handler — inject IDataContext and access repositories through it (like DbContext.Set<T>()):
public sealed class CreateTicketHandler(IDataContext unitOfWork)
{
public async Task<Ticket> HandleAsync(CreateTicketCommand command, CancellationToken ct)
{
var repo = unitOfWork.Repository<Ticket, Guid>();
var ticket = Ticket.Create(command.TenantId, command.Title);
await repo.AddAsync(ticket, ct);
await unitOfWork.SaveChangesAsync(ct);
return ticket;
}
}Cross-entity operations — all repos share the same underlying context:
public sealed class TransferOwnershipHandler(IDataContext unitOfWork)
{
public async Task HandleAsync(TransferCommand command, CancellationToken ct)
{
var tickets = unitOfWork.Repository<Ticket, Guid>();
var auditLogs = unitOfWork.Repository<AuditLog, Guid>();
var ticket = await tickets.GetByIdAsync(command.TicketId, ct)
?? throw new NotFoundException();
ticket.TransferTo(command.NewOwnerId);
tickets.Update(ticket);
await auditLogs.AddAsync(
AuditLog.Create("OwnershipTransferred", ticket.Id), ct);
// Single SaveChanges persists both the ticket update and audit log
await unitOfWork.SaveChangesAsync(ct);
}
}Tip: You can still inject
IRepository<T, TId>or custom interfaces likeITicketRepositorydirectly when you prefer explicit constructor dependencies or need domain-specific repository methods.
Bulk write operations:
var repo = unitOfWork.Repository<Ticket, Guid>();
// Add multiple entities
await repo.AddRangeAsync(tickets, ct);
// Mark entities as modified (update)
repo.Update(ticket);
repo.UpdateRange(tickets);
// Remove entities
repo.Remove(ticket);
repo.RemoveRange(tickets);
// Persist all pending changes
await unitOfWork.SaveChangesAsync(ct);public sealed class TicketRepository : EfRepository<Ticket, Guid>, ITicketRepository
{
public TicketRepository(AppDbContext context) : base(context) { }
}Option A — Auto-scan an assembly (recommended for projects with many repositories):
services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(connectionString));
services.AddDataAccess<AppDbContext>();
// Scans the assembly containing TicketRepository and registers every
// concrete repository against its repository interfaces (scoped).
services.AddRepositoriesFromAssemblyContaining<TicketRepository>();You can also pass an Assembly directly:
services.AddRepositoriesFromAssembly(typeof(TicketRepository).Assembly);Option B — Register individually:
services.AddDataAccess<AppDbContext>();
services.AddRepository<ITicketRepository, TicketRepository>();
services.AddRepository<IOrderRepository, OrderRepository>();Option C — Source-generated registration (automatic; recommended for NativeAOT / trimmer compatibility):
When you reference Clywell.Core.Data.EntityFramework, the bundled Roslyn generator automatically detects every concrete class that implements a repository interface derived from IRepository<,> or IReadRepository<,> at build time and emits a single AddRepositories() extension method — zero reflection, zero assembly scanning.
services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(connectionString));
services.AddDataAccess<AppDbContext>();
services.AddRepositories(); // generated automatically — zero reflection, NativeAOT safeThe generated file RepositoryRegistrationExtensions.g.cs is placed in your project's root namespace so no extra using directive is required. Each registration uses TryAddScoped, so you can still override individual registrations before or after calling AddRepositories().
How it works: The generator detects any non-abstract, non-generic class whose interface chain includes a user-defined sub-interface of
IRepository<,>orIReadRepository<,>. The base interfaces themselves are not registered directly — only your domain-specific interfaces (e.g.ITicketRepository) are wired up.What is NOT required: You do not need
AddRepositoriesFromAssembly()or any reflection-based scan when using the generator. Both approaches are mutually exclusive — pick one per project. No separate package install is needed; the generator is bundled insideClywell.Core.Data.EntityFramework.
Correct use of the specification pattern is critical for enforcing data isolation. Every query that accesses tenant-owned data must include a tenant filter inside the specification. This ensures the database always sees a parameterized predicate and prevents cross-tenant data leakage.
Always scope specs to the authenticated tenant's ID — never query without a tenant boundary:
public sealed class TenantTicketsSpec : Specification<Ticket>
{
// TenantId comes from a trusted source (e.g., ICurrentTenant service),
// never directly from raw user-supplied input.
public TenantTicketsSpec(Guid tenantId)
{
Where(t => t.TenantId == tenantId);
AsReadOnly();
}
}Resolve the current tenant from a trusted identity service rather than request parameters:
public sealed class GetTicketsHandler
{
private readonly IReadRepository<Ticket, Guid> _repository;
private readonly ICurrentTenant _currentTenant; // e.g., from your auth middleware
public GetTicketsHandler(
IReadRepository<Ticket, Guid> repository,
ICurrentTenant currentTenant)
{
_repository = repository;
_currentTenant = currentTenant;
}
public async Task<IReadOnlyList<Ticket>> HandleAsync(CancellationToken ct)
{
// Tenant ID is resolved from the authenticated principal, not a query string.
var spec = new TenantTicketsSpec(_currentTenant.TenantId);
return await _repository.ListAsync(spec, ct);
}
}Before updating or deleting an entity, confirm it belongs to the current tenant:
public async Task HandleAsync(UpdateTicketCommand command, CancellationToken ct)
{
var repo = _unitOfWork.Repository<Ticket, Guid>();
// Fetch using a spec that combines tenant + entity ID — both must match.
var spec = new TicketByIdForTenantSpec(_currentTenant.TenantId, command.TicketId);
var ticket = await repo.FirstOrDefaultAsync(spec, ct)
?? throw new NotFoundException($"Ticket {command.TicketId} was not found.");
ticket.Update(command.Title, command.Status);
repo.Update(ticket);
await _unitOfWork.SaveChangesAsync(ct);
}public sealed class TicketByIdForTenantSpec : Specification<Ticket>
{
public TicketByIdForTenantSpec(Guid tenantId, Guid ticketId)
{
Where(t => t.TenantId == tenantId);
Where(t => t.Id == ticketId);
}
}Validate inputs at the specification boundary to avoid unexpected query behaviour:
public sealed class PagedTicketsSpec : Specification<Ticket>
{
public PagedTicketsSpec(Guid tenantId, int page, int pageSize)
{
ArgumentOutOfRangeException.ThrowIfLessThan(page, 1);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(pageSize);
ArgumentOutOfRangeException.ThrowIfGreaterThan(pageSize, 100); // enforce maximum
Where(t => t.TenantId == tenantId);
OrderByDescending(t => t.CreatedAtUtc);
ApplyPaging((page - 1) * pageSize, pageSize);
AsReadOnly();
}
}Inject IReadRepository<T, TId> in query handlers — not IRepository<T, TId>. This makes the
intent explicit and prevents accidental writes from read-only code paths:
// Correct — read-only handler receives read-only repository
public sealed class GetTicketsHandler(IReadRepository<Ticket, Guid> repository) { ... }
// Correct — command handler receives unit of work (single injection)
public sealed class CreateTicketHandler(IDataContext unitOfWork) { ... }Use await using to guarantee the transaction is disposed (and rolled back if uncommitted) even
when an exception is thrown:
public async Task HandleAsync(TransferTicketsCommand command, CancellationToken ct)
{
var ticketRepo = _unitOfWork.Repository<Ticket, Guid>();
await using var transaction = await _unitOfWork.BeginTransactionAsync(ct);
try
{
var source = await ticketRepo.FirstOrDefaultAsync(
new TicketByIdForTenantSpec(_currentTenant.TenantId, command.SourceId), ct)
?? throw new NotFoundException("Source ticket not found.");
var target = await ticketRepo.FirstOrDefaultAsync(
new TicketByIdForTenantSpec(_currentTenant.TenantId, command.TargetId), ct)
?? throw new NotFoundException("Target ticket not found.");
source.Transfer(target);
ticketRepo.Update(source);
ticketRepo.Update(target);
await _unitOfWork.SaveChangesAsync(ct);
await transaction.CommitAsync(ct);
}
catch
{
await transaction.RollbackAsync(ct);
throw;
}
}| Type | Purpose |
|---|---|
IEntity<TId> |
Base entity identity contract |
IReadRepository<TEntity, TId> |
Read-only: GetByIdAsync, ListAsync, FirstOrDefaultAsync, CountAsync, AnyAsync |
IRepository<TEntity, TId> |
Full CRUD: extends IReadRepository + AddAsync, AddRangeAsync, Update, UpdateRange, Remove, RemoveRange |
ISpecification<T> |
Query specification interface |
Specification<T> |
Fluent spec builder: Where, OrderBy, OrderByDescending, Include, IncludeCollection, ApplyPaging, AsReadOnly |
Specification<T, TResult> |
Projection spec builder: extends Specification<T> with Select() |
IIncludeBuilder<T, TProperty> |
Fluent builder for chaining ThenInclude / ThenIncludeCollection |
ISpecificationEvaluator |
Pluggable spec-to-query translation |
IDataContext |
Repository<T, TId>() + SaveChangesAsync + BeginTransactionAsync |
IDataTransaction |
CommitAsync + RollbackAsync (IAsyncDisposable) |
| Method | Description |
|---|---|
GetByIdAsync(TId, ct) |
Returns the entity with the given ID, or null |
ListAsync(ISpecification<T>, ct) |
Returns all entities matching the specification |
ListAsync<TResult>(ISpecification<T, TResult>, ct) |
Returns projected results matching the specification |
ListAsync(ct) |
Returns all entities (no filter) |
FirstOrDefaultAsync(ISpecification<T>, ct) |
Returns the first matching entity, or null |
CountAsync(ISpecification<T>, ct) |
Returns the count of matching entities (ignores paging/ordering) |
AnyAsync(ISpecification<T>, ct) |
Returns true if any entity matches the specification |
| Method | Description |
|---|---|
AddAsync(TEntity, ct) |
Adds a single entity; returns the tracked entity |
AddRangeAsync(IEnumerable<TEntity>, ct) |
Adds multiple entities |
Update(TEntity) |
Marks entity as modified |
UpdateRange(IEnumerable<TEntity>) |
Marks multiple entities as modified |
Remove(TEntity) |
Marks entity for deletion |
RemoveRange(IEnumerable<TEntity>) |
Marks multiple entities for deletion |
Note: Write operations are not persisted until
IDataContext.SaveChangesAsyncis called.
| Method | Description |
|---|---|
Where(predicate) |
Adds a filter criterion — multiple calls are AND'd |
OrderBy(keySelector) |
Adds an ascending ordering expression |
OrderByDescending(keySelector) |
Adds a descending ordering expression |
Include<TProperty>(expression) |
Eagerly loads a reference navigation property |
IncludeCollection<TProperty>(expression) |
Eagerly loads a collection navigation property |
Include(string path) |
String-based include path (e.g., "Orders.Items") |
ApplyPaging(skip, take) |
Applies offset pagination |
AsReadOnly() |
Hints the infrastructure to use AsNoTracking |
After Include or IncludeCollection, you can chain deeper loads:
IncludeCollection(t => t.Comments) // Include comments collection
.ThenInclude(c => c.Author) // Then include each comment's Author
.ThenInclude(a => a.ProfileImage); // Then include Author's ProfileImage| Type | Purpose |
|---|---|
EfReadRepository<TEntity, TId> |
Read-only EF Core repository; applies AsNoTracking |
EfRepository<TEntity, TId> |
Full CRUD; GetByIdAsync uses FindAsync (tracked) |
EfDataContext |
Wraps DbContext; exposes repos via Repository<>() + SaveChangesAsync |
EfDataTransaction |
Wraps IDbContextTransaction; rolls back on dispose |
EfSpecificationEvaluator |
Translates ISpecification to EF Core LINQ |
ServiceCollectionExtensions |
AddDataAccess<TContext>, AddRepository<,>, AddRepositoriesFromAssembly, AddRepositoriesFromAssemblyContaining<T> |
Source Generator (bundled in EntityFramework):
The Roslyn source generator (RepositoryRegistrationGenerator) is bundled inside Clywell.Core.Data.EntityFramework and automatically emits a compile-time RepositoryRegistrationExtensions class at build time. It detects all repository implementations in the consuming project and emits an AddRepositories() extension method that registers them as scoped services — zero reflection, NativeAOT and trimmer compatible.
┌─────────────────────────────────────────────┐
│ Application Layer │
│ ┌───────────────────────────────────────┐ │
│ │ References: Clywell.Core.Data │ │
│ │ Uses: IRepository, Specification, │ │
│ │ IDataContext, IDataTransaction │ │
│ │ NO EF Core dependency │ │
│ └───────────────────────────────────────┘ │
├─────────────────────────────────────────────┤
│ Infrastructure Layer │
│ ┌─────────────────────────────────────┐ │
│ │ References: Clywell.Core.Data.EF │ │
│ │ Uses: EfRepository, EfDataContext, │ │
│ │ DbContext, EF Core │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
- Fork the repository
- Create a feature branch:
git checkout -b feature/my-feature - Commit changes:
git commit -m 'feat: add my feature' - Push to branch:
git push origin feature/my-feature - Create a Pull Request
MIT © 2026 Clywell