Repositories¶
Repositories abstract the persistence of aggregate roots so the domain and application layers can query and store entities without knowing about EF Core, SQL, Dapper or connection strings. Cargonerds follows ABP's repository conventions: it uses the framework's generic repositories for simple CRUD, and declares custom repository interfaces in the *.Domain layer whenever a query needs hand-written SQL, compiled queries, eager-loading graphs, or org-scoping that the generic API cannot express. The implementations always live in the corresponding *.EntityFrameworkCore layer.
The big picture
There are three EF Core DbContexts in this solution (CargonerdsDbContext / Default, HubDbContext / Hub, PricingDbContext / Pricing). Repositories are bound to a context through IDbContextProvider<TDbContext>. Almost all of the interesting repository code lives in the Hub module, because the Hub owns the business domain. See Entity Framework Core for the context-level details (conventions, interceptors, second-level cache).
Concept: interface in Domain, implementation in EF Core¶
ABP's onion architecture keeps the interface a domain-layer concern and the implementation an infrastructure concern:
flowchart LR
subgraph Domain["*.Domain (no EF dependency)"]
I1["IRepository<TEntity,TKey><br/>(ABP generic)"]
I2["IHubRepository<TEntity,TKey>"]
I3["IShipmentBaseRepository"]
I4["IOrganizationRepository"]
I5["IApiKeyRepository"]
end
subgraph EfCore["*.EntityFrameworkCore"]
B1["EfCoreRepository<TDbContext,TEntity,TId><br/>(ABP base)"]
B2["HubBaseRepository<TDbContext,TEntity,TId>"]
R1["ShipmentBaseRepository"]
R2["OrganizationRepository"]
R3["EfCoreApiKeyRepository"]
end
I2 --> I1
I3 --> I2
I4 --> I1
I5 -.->|IBasicRepository| I1
B2 --> B1
R1 --> B2
R2 --> B2
R3 --> B1
R1 -. implements .-> I3
R2 -. implements .-> I4
R3 -. implements .-> I5
B2 -. implements .-> I2
Application services depend only on the interfaces; ABP wires up the concrete EF Core classes at startup (see Registration).
Generic repositories¶
The simplest services inject ABP's generic IRepository<TEntity, TKey> directly — no custom interface required. ABP supplies the full CRUD + IQueryable surface (GetAsync, InsertAsync, GetQueryableAsync, DeleteAsync, …):
These work because both EF modules register default repositories for every entity (includeAllEntities: true, see below), so any aggregate or sub-entity is injectable as IRepository<T, TKey> even without a hand-written class.
Read-only repositories
ABP also exposes IReadOnlyRepository<T, TKey> and IBasicRepository<T, TKey> (no IQueryable). IApiKeyRepository deliberately extends the narrower IBasicRepository<ApiKey, Guid> rather than the full IRepository, exposing only the curated query methods it needs.
Custom repository interfaces (Hub)¶
Custom interfaces live under modules/hub/src/Hub.Domain/Repositories/ in the Hub.Repositories namespace. The Hub layers a shared base interface (IHubRepository<,>) on top of ABP's IRepository<,> and then specializes per aggregate.
| Interface | File | Base | Highlights |
|---|---|---|---|
IHubRepository<TEntity, TKey> |
Repositories/IHubRepository.cs |
IRepository<TEntity, TKey> |
property-change audit history (two GetPropertyChanges overloads) + AddOrAttach graph upsert |
IShipmentBaseRepository |
Repositories/IShipmentBaseRepository.cs |
IHubRepository<ShipmentBase, Guid> |
detail loading, lookup by house bill / identifier, subscribed-shipment ids, tracking containers |
IOrganizationRepository |
Repositories/IOrganizationRepository.cs |
IRepository<Organization, Guid> |
filtered search, association-tree loading, external identifiers |
ICalculatedShipmentRepository |
Repositories/ICalculatedShipmentRepository.cs |
(custom) | precomputed shipment read-model |
IMarginRepository |
Repositories/IMarginRepository.cs |
(custom) | margin / financial read-model |
IHubRepository<TEntity, TKey>¶
The shared base interface adds change-tracking history and a graph-upsert helper on top of IRepository<,>:
// modules/hub/src/Hub.Domain/Repositories/IHubRepository.cs
namespace Hub.Repositories;
public interface IHubRepository<TEntity, TKey> : IRepository<TEntity, TKey>
where TEntity : class, IEntity<TKey>
{
// Paged audit history (raw SQL + Dapper, org-scoped)
Task<(int TotalCount, IReadOnlyList<EntityPropertyChange> Query)> GetPropertyChanges(
HashSet<string> propertyNames, int skip, int take,
DateTime? fromUtc = null, DateTime? toUtc = null,
bool includeTotalCount = true, CancellationToken cancellationToken = default);
// Per-entity audit history (EF IQueryable)
Task<IQueryable<EntityPropertyChange>> GetPropertyChanges(
HashSet<string> propertyNames, Guid shipmentId, CancellationToken cancellationToken = default);
// Whole-graph add/attach: traverses the object graph and marks each node
// Added or Unchanged based on whether its key is set (assumes no edits to keyed nodes).
Task AddOrAttach(HubBaseGuidEntity entity, CancellationToken ct = default);
}
IShipmentBaseRepository¶
Keyed on the abstract ShipmentBase, which covers both Shipment and Consol/CustomsDeclaration via single-table (TPH) inheritance — see Aggregate Roots:
// modules/hub/src/Hub.Domain/Repositories/IShipmentBaseRepository.cs
public interface IShipmentBaseRepository : IHubRepository<ShipmentBase, Guid>
{
Task<IQueryable<ShipmentBase>> GetQueryableAsync(bool ignoreShipmentCustoms = true);
Task<ShipmentBase?> FindByIdWithDetailsAsync(Guid id, CancellationToken ct = default);
Task<ShipmentBase?> FindByHouseBillWithDetailsAsync(string houseBill, CancellationToken ct = default);
Task<ShipmentBase?> FindByIdentifierAsync(string identifier, CancellationToken ct = default);
Task<List<ShipmentAddress>> LoadAddressesWithDetailsAsync(Guid shipmentBaseId, CancellationToken ct = default);
Task<(int TotalCount, IQueryable<ShipmentBase> Query)> GetShipmentsWithLastEventsAsync(
int skip = 0, int take = 10, CancellationToken cancellationToken = default);
Task<HashSet<Guid>> GetSubscribedShipmentIdsAsync(Guid userId, CancellationToken ct = default);
Task<HashSet<Guid>> GetShipmentsByTagIdsAsync(Guid[] tagIds, CancellationToken ct = default);
Task<List<TrackingContainer>> GetTrackingContainersAsync(Guid shipmentId, CancellationToken ct = default);
}
IOrganizationRepository¶
Note this one extends ABP IRepository<Organization, Guid> directly (not IHubRepository), so it does not pick up AddOrAttach / GetPropertyChanges:
// modules/hub/src/Hub.Domain/Repositories/IOrganizationRepository.cs
public interface IOrganizationRepository : IRepository<Organization, Guid>
{
Task<(int TotalCount, IQueryable<Organization> Query)> FindOrganizationsAsync(
string? filter, Guid[]? exclude = null, int skip = 0, int take = 10,
CancellationToken cancellationToken = default);
Task<IQueryable<Association>> GetAssociationsAsync(CancellationToken ct = default);
Task<List<Association>> GetAssociationTreeWithOrganizationsAsync(CancellationToken ct = default);
Task<List<Association>> GetAllAssociationsWithOrganizationsAsync(CancellationToken ct = default);
Task<List<Association>> GetAssociationTreeAsync(CancellationToken ct = default);
Task LoadAssociationOrganizationsAsync(Association association, CancellationToken ct = default);
Task<Organization?> FindByIdAsync(Guid id, CancellationToken ct = default);
Task<IQueryable<ExternalOrganizationIdentifier>> GetExternalIdentifiersAsync(
IEnumerable<Guid> organizationIds, ServiceType serviceType = ServiceType.CW1,
CancellationToken ct = default);
}
Implementations (Hub EF Core)¶
All Hub repositories derive from the shared base HubBaseRepository, which itself derives from ABP's EfCoreRepository<TDbContext, TEntity, TId> and binds to HubDbContext. They live under modules/hub/src/Hub.EntityFrameworkCore/Repositories/.
HubBaseRepository — the shared base¶
There are two classes in HubBaseRepository.cs: an open-generic convenience overload, and the real three-parameter base. The base injects ABP's IDbContextProvider<TDbContext>, an IServiceProvider (used to lazily resolve an IDbConnectionFactory for Dapper), and the CurrentFilterContextProvider that carries the caller's active organization scope.
// modules/hub/src/Hub.EntityFrameworkCore/Repositories/HubBaseRepository.cs
namespace Hub.Repositories;
// Convenience: IHubRepository<TEntity,TId> bound to HubDbContext
public class HubBaseRepository<TEntity, TId>(
IDbContextProvider<HubDbContext> dbContextProvider,
IServiceProvider serviceProvider,
CurrentFilterContextProvider filterContextProvider)
: HubBaseRepository<HubDbContext, TEntity, TId>(dbContextProvider, serviceProvider, filterContextProvider)
where TEntity : HubBaseGuidEntity, IEntity<TId>;
// Real base: ABP EfCoreRepository + IHubRepository
public class HubBaseRepository<TDbContext, TEntity, TId>(
IDbContextProvider<TDbContext> dbContextProvider,
IServiceProvider serviceProvider,
CurrentFilterContextProvider filterContextProvider)
: EfCoreRepository<TDbContext, TEntity, TId>(dbContextProvider), IHubRepository<TEntity, TId>
where TDbContext : HubDbContext
where TEntity : HubBaseGuidEntity, IEntity<TId>
{
protected CurrentFilterContextProvider FilterContextProvider => filterContextProvider;
protected IDbConnectionFactory ConnectionFactory => serviceProvider.GetRequiredService<IDbConnectionFactory>();
// GetPropertyChanges(...), AddOrAttach(...), ShipmentIdQuery(), ShipmentBaseAccessFilter(...) ...
}
Two responsibilities of this base are worth calling out:
-
AddOrAttachgraph upsert. It uses EF Core'sChangeTracker.TrackGraphto walk an entity graph and mark each nodeAdded(key isGuid.Empty) orUnchanged(key already set), with special handling for owned types (FindOwnership()) andCurrency(which doesn't use itsId). This is how Hub aggregates are persisted as a whole tree without diffing existing nodes. -
Raw SQL + Dapper for org-scoped access. Because some queries need SQL Server constructs EF cannot generate, the base serializes
FilterContextProvider.ActiveOrgIdsto JSON and buildsOPENJSON-basedEXISTS/INTERSECTclauses.GetPropertyChanges(...)runs a window-function query through Dapper (QueryWithCountAsync); helpers likeShipmentIdQuery(),CustomsDeclarationIdQuery()andShipmentBaseAccessFilter(idParamName)return reusable fragments:public (string FilterClause, string ParamaterValue) ShipmentBaseAccessFilter( string idParamName, string filterParameterName = "OrgIds") { var parameterJson = JsonSerializer.Serialize(FilterContextProvider.ActiveOrgIds); var filterClause = $""" Exists (Select 1 From ShipmentAddresses SA Where ShipmentId = {idParamName} and OrganizationId in (SELECT value FROM OPENJSON(@OrgIds))) """; return (filterClause, parameterJson); }
Customer / organization isolation
Every Hub repository receives CurrentFilterContextProvider (an ISingletonDependency wrapping AsyncLocal<IFilterContext?>) so queries can be scoped to the caller's active organizations. The Hub also applies EF global query filters via a custom IQueryFilter mechanism. The two filtering paths (auto-applied global filters vs. manual .FilterAll() / FilteredSet<T>()) are easy to confuse — see OData Filtering and Domain Services for the full story.
ShipmentBaseRepository and OrganizationRepository¶
Concrete repositories add their bespoke loaders, usually delegating hot reads to compiled queries:
// modules/hub/src/Hub.EntityFrameworkCore/Repositories/ShipmentBaseRepository.cs
public class ShipmentBaseRepository(
IDbContextProvider<HubDbContext> dbContextProvider,
CurrentFilterContextProvider filterContextProvider,
IServiceProvider serviceProvider,
ICalculatedShipmentRepository calculatedShipmentRepository) // can depend on other repos
: HubBaseRepository<HubDbContext, ShipmentBase, Guid>(
dbContextProvider, serviceProvider, filterContextProvider),
IShipmentBaseRepository
{
public async Task<ShipmentBase?> FindByIdWithDetailsAsync(Guid id, CancellationToken ct = default)
{
var ctx = await GetDbContextAsync();
return await HubCompiledQueries.ShipmentFindByIdWithDetails(ctx, id); // compiled query
}
// ...
}
OrganizationRepository.FindOrganizationsAsync is a good example of an IQueryable-returning, key-set-then-rehydrate pattern (it pages over a projected id query first, then re-selects full rows) and uses EF.Functions.Like for a name/external-identifier search.
Custom repositories in the host (Cargonerds)¶
The host application core follows the same pattern under src/Cargonerds.EntityFrameworkCore/Repositories/:
| Repository | Interface | Base / registration | Notes |
|---|---|---|---|
EfCoreApiKeyRepository |
Cargonerds.ApiKeys.IApiKeyRepository : IBasicRepository<ApiKey, Guid> |
EfCoreRepository<…>, AddRepository<ApiKey, …>() |
FindByPrefixAsync, FindByNameAsync, paged list |
CargonerdsIdentityUserRepository |
ABP IIdentityUserRepository |
ServiceDescriptor.Transient replace |
applies the country-ISO filter to user listing/count/export |
CommentRepository |
Cargonerds.Comments.ICommentRepository |
[ExposeServices(typeof(ICommentRepository), IncludeDefaults = true)] over EfCoreCommentRepository |
bridges CmsKit comments |
OrganizationUnitOwnerUserRepository |
IOrganizationUnitOwnerUserRepository |
ITransientDependency |
OU-owner lookups via a compiled query |
IdentitySessionRepository |
IIdentitySessionRepository |
bound to IIdentityDbContext |
identity sessions |
EfCoreApiKeyRepository shows the typical ABP repository helpers (GetQueryableAsync, GetDbSetAsync, WhereIf, PageBy, dynamic-LINQ OrderBy, and GetCancellationToken to honor the ambient UoW token):
// src/Cargonerds.EntityFrameworkCore/Repositories/EfCoreApiKeyRepository.cs
public class EfCoreApiKeyRepository(IDbContextProvider<CargonerdsDbContext> dbContextProvider)
: EfCoreRepository<CargonerdsDbContext, ApiKey, Guid>(dbContextProvider), IApiKeyRepository
{
public async Task<ApiKey?> FindByPrefixAsync(string prefix, CancellationToken cancellationToken = default)
{
var query = await GetQueryableAsync();
return await query.FirstOrDefaultAsync(x => x.Prefix == prefix, GetCancellationToken(cancellationToken));
}
protected virtual async Task<IQueryable<ApiKey>> GetFilteredQueryableAsync(string? name, Guid? userId) =>
(await GetDbSetAsync())
.WhereIf(!string.IsNullOrWhiteSpace(name), x => x.Name.Contains(name!))
.WhereIf(userId.HasValue, x => x.UserId == userId);
}
Registration: how repositories are wired up¶
There are four distinct registration mechanisms in play. Knowing which one applies tells you how to find or override a binding.
1. Default repositories for all entities¶
Each EF module registers a generic IRepository<T, TKey> for every entity in its model. ABP's default repository registration is opt-in per context:
// modules/hub/src/Hub.EntityFrameworkCore/EntityFrameworkCore/HubEntityFrameworkCoreModule.cs
context.Services.AddAbpDbContext<HubDbContext>(options =>
{
options.AddDefaultRepositories<IHubDbContext>(includeAllEntities: true);
// ...
});
// src/Cargonerds.EntityFrameworkCore/EntityFrameworkCore/CargonerdsEntityFrameworkCoreModule.cs
context.Services.AddAbpDbContext<CargonerdsDbContext>(options =>
{
options.AddDefaultRepositories(includeAllEntities: true);
options.AddRepository<ApiKey, EfCoreApiKeyRepository>();
});
includeAllEntities: true means even non-aggregate-root entities get a generic repository — convenient, but it means anything is injectable as IRepository<T>.
2. Custom repository overrides via AddRepository<TEntity, TRepository>()¶
When an entity has a hand-written repository, the module overrides the default for that entity. The Hub registers four:
// modules/hub/src/Hub.EntityFrameworkCore/EntityFrameworkCore/HubEntityFrameworkCoreModule.cs
options.AddRepository<Organization, OrganizationRepository>();
options.AddRepository<Margin, MarginRepository>();
options.AddRepository<ShipmentBase, ShipmentBaseRepository>();
options.AddRepository<CalculatedShipment, CalculatedShipmentRepository>();
After this, injecting either IRepository<Organization, Guid> or IOrganizationRepository resolves to OrganizationRepository.
3. The open-generic IHubRepository<,>¶
So that any Hub entity can be injected as IHubRepository<T, TId> (getting AddOrAttach / GetPropertyChanges for free), the module registers the open generic by hand:
This is what lets services depend on, e.g., IHubRepository<Hub.Entities.Pricing.Quotation, Guid> even though the Pricing module defines no repository interface of its own.
4. DI marker interfaces / explicit replacement¶
Some host repositories are registered through ABP's dependency injection conventions rather than AddRepository:
ITransientDependency(auto-registration):OrganizationUnitOwnerUserRepository.[ExposeServices(...)]:CommentRepositoryexposesICommentRepositorywhile subclassingEfCoreCommentRepository.-
Explicit
ServiceDescriptorreplacement to override an ABP-provided repository:
DefaultWithDetailsFunc — default eager-loading¶
While registering repositories the Hub also configures the Include graph that ABP's WithDetailsAsync() uses, so callers get a consistent eager-load without repeating Include chains:
options.Entity<Shipment>(opt => opt.DefaultWithDetailsFunc = e => e.IncludeDefaultDetails());
options.Entity<Consol>(opt => opt.DefaultWithDetailsFunc = e => e.IncludeDefaultDetails());
options.Entity<Quotation>(opt => opt.DefaultWithDetailsFunc = e => e.IncludeDefaultDetails());
// ...also CustomsDeclaration, Order, Address, Tag, Offer
Compiled queries¶
Hot read paths use EF Core compiled queries (EF.CompileAsyncQuery) to skip the per-call expression-tree compilation. They are static delegates kept in two classes:
modules/hub/src/Hub.EntityFrameworkCore/CompiledQueries/HubCompiledQueries.cssrc/Cargonerds.EntityFrameworkCore/CompiledQueries/CargonerdsCompiledQueries.cs
// modules/hub/src/Hub.EntityFrameworkCore/CompiledQueries/HubCompiledQueries.cs
public static readonly ShipmentFindByIdWithDetailsQuery ShipmentFindByIdWithDetails =
EF.CompileAsyncQuery(
(HubDbContext ctx, Guid id) => WithDetails(ctx.ShipmentBases).FirstOrDefault(s => s.Id == id)
).Invoke;
// A shared Include chain ending in .AsSingleQuery()
private static IQueryable<ShipmentBase> WithDetails(IQueryable<ShipmentBase> query) =>
query.Include(s => s.TransportMode)
.Include(s => s.PortOfDischarge!.Country)
/* ... */
.Include(s => ((Shipment)s).ExternalIdentifiers)
.AsSingleQuery();
HubCompiledQueries covers ShipmentFindByIdWithDetails / …ByHouseBill (sharing the WithDetails Include chain), ShipmentAddressesByShipmentId, TrackingContainersByShipmentId, OrganizationFindById, and AssociationsWithOrganizations (using .AsSplitQuery()). CargonerdsCompiledQueries covers CmsKit comment/reply update counts and OwnerUsersByOrgUnitId.
Change-tracker fixup still works
Compiled queries return tracked entities into the same DbContext instance used inside a unit of work, so EF Core's relationship fixup still applies. OrganizationRepository.GetAssociationTreeWithOrganizationsAsync relies on this: it loads a flat list via the compiled AssociationsWithOrganizations, then returns only the roots and lets fixup populate ChildAssociations.
Built-in async LINQ (.NET 10)¶
The compiled queries that return collections are typed as IAsyncEnumerable<T>, and repositories materialize them with .ToListAsync(ct):
// src/Cargonerds.EntityFrameworkCore/Repositories/OrganizationUnitOwnerUserRepository.cs
public async Task<List<IdentityUser>> GetOwnerUsersByOrgUnitIdAsync(Guid orgUnitId, CancellationToken ct = default)
{
var ctx = await dbContextProvider.GetDbContextAsync();
return await CargonerdsCompiledQueries.OwnerUsersByOrgUnitId(ctx, orgUnitId).ToListAsync(ct);
}
Do not add the System.Linq.Async NuGet package
.NET 10 ships a built-in System.Linq.AsyncEnumerable with ToListAsync(), FirstOrDefaultAsync(), etc. Adding the third-party System.Linq.Async package causes ambiguous-call compiler errors (CS0121). Just add using System.Linq; to reach the built-in async LINQ extensions over IAsyncEnumerable<T>. (EF Core's own ToListAsync over IQueryable<T> comes from Microsoft.EntityFrameworkCore.)
Gotchas¶
IOrganizationRepository does not extend IHubRepository
Unlike IShipmentBaseRepository, IOrganizationRepository derives straight from ABP's IRepository<Organization, Guid>. It therefore does not expose AddOrAttach or GetPropertyChanges. If you need the graph upsert for organizations, go through the generic IHubRepository<Organization, Guid> registration instead.
-
Two audit-history overloads, different engines.
GetPropertyChanges(propertyNames, skip, take, …)is raw SQL via Dapper (window function + OPENJSON org filter, paged with total count). TheGetPropertyChanges(propertyNames, entityId, …)overload is a plain EFIQueryable. They are not interchangeable. -
AddOrAttachassumes keyed nodes are unchanged. It traverses the graph and marks any node whose key is already set asUnchanged— it does not diff or update existing rows (documented in the interface remarks). Use the normalUpdateAsyncpath when you need to persist edits to existing entities. -
includeAllEntities: trueexposes everything. Because both contexts register default repositories for all entities (not just aggregate roots), you can injectIRepository<T>for sub-entities. Convenient, but it can encourage bypassing aggregate boundaries; prefer the aggregate's repository where one exists. -
Hub repositories are org-scoped by construction. They take
CurrentFilterContextProvider; raw-SQL paths addOPENJSONaccess filters and EF paths run through global/FilterAllquery filters. Forgetting the manual.FilterAll()on the non-auto path can silently leak cross-organization data — see OData Filtering. -
Second-level cache + pooling. Every Hub EF query is cached for ~60s by default and
HubDbContextis pooled (size 256). Repository reads can therefore return slightly stale data and run on a recycled context. See Caching and Entity Framework Core.
See also¶
- Aggregate Roots —
HubBaseGuidEntity,ShipmentBaseTPH, the entity inheritance spine. - Domain Services —
CurrentFilterContextProvider,ApiKeyManager. - Entity Framework Core — the three
DbContexts, conventions, interceptors, enum-as-string. - Caching — second-level cache,
VersionTrackerinvalidation,DbContextPool. - OData Filtering —
IQueryFilter/FilterAll, OPENJSON access filters. - ABP Patterns —
AddDefaultRepositories,AddRepository,ReplaceDbContext,DefaultWithDetailsFunc. - Service Patterns — how application services consume these repositories.
- ABP reference: Repositories · Dependency injection