Skip to content

clywell/clywell-data

Repository files navigation

Clywell.Core.Data & Clywell.Core.Data.EntityFramework

License: MIT NuGet: Clywell.Core.Data NuGet: Clywell.Core.Data.EntityFramework

Data access abstractions and EF Core implementation for clean architecture .NET applications.

Overview

This solution provides two NuGet packages that enforce clean architecture by separating data access abstractions from their EF Core implementation:

Package Link Purpose EF Core Dependency
Clywell.Core.Data NuGet Interfaces, specifications, and query abstractions None
Clywell.Core.Data.EntityFramework NuGet EF Core implementations of all abstractions — includes bundled source generator for compile-time DI registration Yes

Your Application layer references only Clywell.Core.Data → zero EF Core dependency.
Your Infrastructure layer references Clywell.Core.Data.EntityFramework → provides the implementations.

Features

  • Repository PatternIReadRepository<T, TId> and IRepository<T, TId> with full CRUD
  • Specification Pattern — Composable, testable, reusable query objects with fluent builder API
  • Projection SpecificationsSpecification<T, TResult> with Select() for read-optimized queries
  • Eager Loading — Strongly-typed Include / ThenInclude builder with collection support
  • Unit of WorkIDataContext with Repository<T, TId>() (like DbContext.Set<T>()), SaveChangesAsync, and BeginTransactionAsync — single injection for command handlers
  • Explicit TransactionsIDataTransaction with CommitAsync / RollbackAsync and IAsyncDisposable
  • DI RegistrationAddDataAccess<TContext>(), AddRepository<TInterface, TImpl>(), and AddRepositoriesFromAssembly() for auto-scanning, or source-generated AddRepositories() for compile-time registration (NativeAOT/trimmer compatible)

Installation

# Application layer (abstractions only)
dotnet add package Clywell.Core.Data

# Infrastructure layer (EF Core implementation + bundled source generator)
dotnet add package Clywell.Core.Data.EntityFramework

Quick Start

1. Define Your Entity

public 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; } = [];
}

2. Define a Repository Interface (Domain Layer)

public interface ITicketRepository : IRepository<Ticket, Guid>
{
    // Add domain-specific query methods if needed
}

3. Create Specifications (Application Layer)

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();
    }
}

4. Use in Command/Query Handlers (Application Layer)

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 like ITicketRepository directly 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);

5. Implement the Repository (Infrastructure Layer)

public sealed class TicketRepository : EfRepository<Ticket, Guid>, ITicketRepository
{
    public TicketRepository(AppDbContext context) : base(context) { }
}

6. Register in DI (Infrastructure Layer)

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 safe

The 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<,> or IReadRepository<,>. 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 inside Clywell.Core.Data.EntityFramework.


Security & Multi-Tenancy

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.

Tenant Isolation via Specifications

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);
    }
}

Verifying Ownership Before Mutation

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);
    }
}

Input Validation in Specifications

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();
    }
}

Principle of Least Privilege

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) { ... }

Explicit Transactions

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;
    }
}

API Reference

Clywell.Core.Data (Abstractions)

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)

IReadRepository<TEntity, TId> Methods

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

IRepository<TEntity, TId> Additional Methods

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.SaveChangesAsync is called.

Specification<T> Builder Methods

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

Clywell.Core.Data.EntityFramework (Implementations)

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.


Architecture

┌─────────────────────────────────────────────┐
│  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           │    │
│  └─────────────────────────────────────┘    │
└─────────────────────────────────────────────┘

Contributing

  1. Fork the repository
  2. Create a feature branch: git checkout -b feature/my-feature
  3. Commit changes: git commit -m 'feat: add my feature'
  4. Push to branch: git push origin feature/my-feature
  5. Create a Pull Request

License

MIT © 2026 Clywell

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors 3

  •  
  •  
  •  

Languages