Skip to content

Entity Framework Core

Cargonerds persists data with Entity Framework Core 10 on SQL Server, following the ABP Framework conventions. The solution defines three DbContext types across two physical databases, wires module repositories together with [ReplaceDbContext], and layers a second-level query cache and a context pool over the large logistics ("Hub") database.

This page explains the persistence stack concept-by-concept and points at the concrete classes, file paths, configuration keys and gotchas that implement it.

DbContexts at a glance

DbContext Project Connection string Role
CargonerdsDbContext src/Cargonerds.EntityFrameworkCore Default (ConnectionStringNames.SparkDb) The ABP host / "Spark" database. Aggregates almost every ABP framework module (Identity Pro, OpenIddict Pro, SaaS, Permission/Setting/Feature management, audit logging, background jobs, BLOB storing, CMS Kit + CMS Kit Pro, Chat, file & text-template management, language management, GDPR) plus app entities (ClientSetting, ApiKey, OrganizationUnitOrgCode, OrganizationUnitOwnerUser, notifications, Excel-export metadata).
HubDbContext modules/hub/src/Hub.EntityFrameworkCore Hub (ConnectionStringNames.HubDb) The large freight/logistics domain: shipments, consols, containers, organizations, invoices/revenue, orders, tracking, customs, pricing, and master-data/code tables (~200 DbSets).
PricingDbContext modules/pricing/src/Pricing.EntityFrameworkCore Pricing (PricingDbProperties.ConnectionStringName) A thin context whose OnModelCreating only calls builder.ConfigurePricing() and declares no DbSets. Pricing entities are physically mapped into HubDbContext instead.

Connection-string names are centralised in src/Cargonerds.Domain.Shared/Configuration/ConnectionStringNames.cs:

public static class ConnectionStringNames
{
    public const string HubServiceBus = "hubServiceBus";
    public const string HubDb = "Hub";
    public const string SparkDb = "Default";
    public const string BlobStorage = nameof(BlobStorage);
}

The Pricing module keeps its own constant in modules/pricing/src/Pricing.Domain/PricingDbProperties.cs (ConnectionStringName = "Pricing", DbTablePrefix = "Pricing", DbSchema = null).

Only Default is checked in

The single connection string present in committed appsettings.json (DbMigrator and HttpApi.Host) is Default, e.g. in src/Cargonerds.DbMigrator/appsettings.json:

"ConnectionStrings": {
  "Default": "Server=(LocalDb)\\MSSQLLocalDB;Database=CargonerdsAppDb;Trusted_Connection=True;TrustServerCertificate=true"
}

The Hub (and nominal Pricing) connection strings are injected at runtime by .NET Aspire or the environment — see .NET Aspire Integration and appsettings reference.

flowchart LR
    subgraph App["Host application (HttpApi.Host)"]
        C[CargonerdsDbContext]
        H[HubDbContext]
        P[PricingDbContext]
    end

    C -- "[ReplaceDbContext]" --> ID[IIdentityProDbContext]
    C -- "[ReplaceDbContext]" --> SA[ISaasDbContext]
    C -- "[ReplaceDbContext]" --> PM[IPermissionManagementDbContext]

    C -- "Default" --> DB1[(SQL Server: Default / Spark DB)]
    H -- "Hub" --> DB2[(SQL Server: Hub DB)]
    P -. "model folded into Hub" .-> H

    H -- pooled 256 + 2nd-level cache --> DB2

Why two databases

CargonerdsDbContext is the ABP host context. It uses [ReplaceDbContext] so that the Identity, SaaS and permission-management module repositories resolve to this context (against the Default database) instead of each module's own context. This makes cross-module JOINs over repositories possible. The Hub database is a separate, pre-existing logistics schema accessed through HubDbContext.

Multi-tenancy / SaaS is disabled

MultiTenancyConsts.IsEnabled = false (src/Cargonerds.Domain.Shared/MultiTenancy/MultiTenancyConsts.cs). The SaaS module is still installed and CargonerdsDbContext still replaces ISaasDbContext, but the per-tenant loop in the migration service is effectively dead code. See Multi-tenancy for the ABP concept.

CargonerdsDbContext (host / Default DB)

Declared in src/Cargonerds.EntityFrameworkCore/EntityFrameworkCore/CargonerdsDbContext.cs:

[ReplaceDbContext(typeof(IIdentityProDbContext))]
[ReplaceDbContext(typeof(ISaasDbContext))]
[ReplaceDbContext(typeof(IPermissionManagementDbContext))]
[ConnectionStringName(ConnectionStringNames.SparkDb)] // "Default"
public class CargonerdsDbContext
    : AbpDbContext<CargonerdsDbContext>,
        ISaasDbContext,
        IIdentityProDbContext,
        IPermissionManagementDbContext
{
    public DbSet<ClientSetting> ClientSettings { get; set; }
    public DbSet<OrganizationUnitOrgCode> OrganizationUnitOrgCodes { get; set; }
    public DbSet<ApiKey> ApiKeys { get; set; }
    // ... Identity, SaaS, Notifications, Permission DbSets ...
}

Model creation

OnModelCreating calls every ABP module's builder.ConfigureXxx() extension (ConfigurePermissionManagement, ConfigureSettingManagement, ConfigureBackgroundJobs, ConfigureAuditLogging, ConfigureFeatureManagement, ConfigureIdentityPro, ConfigureOpenIddictPro, ConfigureLanguageManagement, ConfigureFileManagement, ConfigureSaas, ConfigureChat, ConfigureTextTemplateManagement, ConfigureGdpr, ConfigureCmsKit, ConfigureCmsKitPro, ConfigureBlobStoring) and builder.ConfigurePricing().

ConfigureHub() is deliberately commented out

Inside OnModelCreating the line //builder.ConfigureHub(); is present but disabled. Hub entities are intentionally not mapped into CargonerdsDbContext — they live only in HubDbContext. This is why the Hub schema has no EF migrations (see Database Migrations).

App tables are mapped explicitly with ToTable + ABP's ConfigureByConvention() (which applies audit/soft-delete/multi-tenant base columns): AppClientSettings, AppApiKeys, AppOuOrgCodes, and AppOuOwnerUser. Notification entities are mapped via ApplyConfiguration(new ...TypeConfiguration()).

A SQL Server stored computed column is added to IdentityUser for country filtering:

builder.Entity<IdentityUser>(b =>
{
    b.Property<string>(UserConsts.ComputedColumns.CountryIso)
        .HasComputedColumnSql("LTRIM(RTRIM(JSON_VALUE(ExtraProperties, '$.country')))", stored: true);
    b.HasIndex(UserConsts.ComputedColumns.CountryIso);
});

To make this filter apply uniformly to listing, counting and Excel/CSV export, ABP's IIdentityUserRepository is replaced with CargonerdsIdentityUserRepository (registered as ServiceDescriptor.Transient in the EF module).

Module registration and provider config

CargonerdsEntityFrameworkCoreModule (same project) wires up the DbContext, repositories and the SQL Server provider. It [DependsOn] every Abp*EntityFrameworkCore module plus PricingEntityFrameworkCoreModule and HubEntityFrameworkCoreModule, so all three contexts are available in the host:

context.Services.AddAbpDbContext<CargonerdsDbContext>(options =>
{
    // includeAllEntities -> default repositories for every entity, not just aggregate roots
    options.AddDefaultRepositories(includeAllEntities: true);
    options.AddRepository<ApiKey, EfCoreApiKeyRepository>();
});

// Country filter applies on the shared query level (list/count/export)
context.Services.Replace(
    ServiceDescriptor.Transient<IIdentityUserRepository, CargonerdsIdentityUserRepository>()
);

Configure<AbpDbContextOptions>(options =>
{
    options.Configure(ctx =>
    {
        ctx.UseSqlServer(b => b.UseParameterizedCollectionMode(ParameterTranslationMode.Parameter));
        ctx.DbContextOptions.AddInterceptors(new ArithAbortConnectionInterceptor());
    });
});

Provider + interceptors must share one Configure action

The provider and the interceptor are registered inside a single options.Configure(...) call on purpose. An inline comment in the module explains that a separate Configure call after UseSqlServer overwrites the SQL Server provider configuration and yields a "No database provider configured" error. The Cargonerds context also opts into the .NET 10 / EF 10 toggle UseParameterizedCollectionMode(ParameterTranslationMode.Parameter) — this is not set on the Hub context.

HubDbContext (Hub DB)

modules/hub/src/Hub.EntityFrameworkCore/EntityFrameworkCore/HubDbContext.cs is the workhorse of the logistics domain. It is [ConnectionStringName(ConnectionStringNames.HubDb)] and implements IHubDbContext. The constructor injects AppConfiguration and an IAbpLazyServiceProvider, and sets a 2-minute command timeout:

public HubDbContext(
    DbContextOptions<HubDbContext> options,
    IAbpLazyServiceProvider lazyServiceProvider,
    AppConfiguration appConfig)
    : base(options)
{
    _appConfig = appConfig;
    LazyServiceProvider = lazyServiceProvider;
    this.Database.SetCommandTimeout(TimeSpan.FromMinutes(2));
}

Model building

OnModelCreating relies on custom convention helpers in HubDbContextModelCreatingExtensions:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.ApplyConfigurationsFromTypeAssembly(GetType()).AddInheritedTypes();

    modelBuilder.Owned<Dimension<TemperatureUnit>>();
    modelBuilder.Owned<Dimension<WeightUnit>>();
    modelBuilder.Owned<Dimension<VolumeUnit>>();
    modelBuilder.Owned<Dimension<LengthUnit>>();
    modelBuilder.Owned<OrganizationAddressContactRelation>();
    modelBuilder.Owned<ShipmentLegLocation>();
    modelBuilder.Owned<Coordinates>();
    base.OnModelCreating(modelBuilder);

    modelBuilder.ConfigureIdentity();
    modelBuilder.ApplyMultiTypeConfigurationsFromContextAssembly(GetType());
}
Helper What it does
ApplyConfigurationsFromTypeAssembly(GetType()) Applies every IEntityTypeConfiguration<T> found in the Hub EF assembly (~70 classes under EntityFrameworkCore/TypeConfigurations/{Hub,Pricing,Abp}/).
AddInheritedTypes() For every root entity (no base type), reflects over the assembly and registers every non-abstract subclass — automatic TPH inheritance mapping.
ApplyMultiTypeConfigurationsFromContextAssembly(GetType()) Runs each IMultiTypeConfiguration (e.g. CustomerOwnedTypesConfiguration) across every entity assignable to a base type, via a reflected generic Configure<T>. See EntityFrameworkCore/TypeConfigurations/Utils/MultiTypeConfiguration.cs.

The Owned<> value objects above are an ABP/EF value-object mapping detail — see Value Objects.

TPH example — ShipmentTypeConfiguration

EntityFrameworkCore/TypeConfigurations/Hub/ShipmentTypeConfiguration.cs maps the ShipmentBase hierarchy to a single Shipments table with a discriminator and alternate keys, and it declares an existing SQL trigger and online indexes:

builder.ToTable("Shipments");
builder.HasAlternateKey(s => s.HouseBill);
builder.HasAlternateKey(s => s.CargonerdsNumber);
builder.HasDiscriminator(s => s.Discriminator);          // HubDbContext.DiscriminatorColumnName = "Discriminator"

builder.ToTable(tb => tb.HasTrigger("TR_Shipments-ActualDeparture_Update"));

builder.HasIndex(s => s.CreationTime).IsCreatedOnline();
builder
    .HasIndex(s => new { s.IsActive, s.State, s.TransportModeId, s.ContainerModeId, s.EstimatedArrival })
    .IncludeProperties(s => new { s.ActualArrival });     // composite covering index

The presence of a hand-declared trigger and IsCreatedOnline() / IncludeProperties indexes is a strong signal that the EF model mirrors a hand-managed schema rather than driving it.

Conventions — enums as strings

ConfigureConventions applies model-wide conventions:

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    base.ConfigureConventions(configurationBuilder);

    configurationBuilder.Properties<decimal>().HavePrecision(18, 6);
    configurationBuilder.Properties<Enum>().HaveConversion<string>().HaveMaxLength(128);
    configurationBuilder.Properties<Type>().HaveConversion<TypeValueConverter>().HaveMaxLength(400);
}
  • All decimal columns use precision (18, 6).
  • All enums are persisted as strings in the Hub DB (max length 128).
  • Type properties are persisted as AssemblyQualifiedName via TypeValueConverter (ValueConverter<Type, string>, in EntityFrameworkCore/ValueConverters/TypeValueConverter.cs).
  • EnumDescriptionConverter<T> (ValueConverters/EnumDescriptionConverter.cs) exists for enums that should map to their [Description] text instead of the member name; it throws on an unknown description.

Two different enum representations

Over HTTP, ABP's auto-API serialises enums as integers by default. In the Hub database they are stored as strings. These are independent layers — the API serializer vs. the EF model convention — so the same enum can be an int on the wire and a varchar in the table.

Save-time logic

HubDbContext overrides HandlePropertiesBeforeSave() to enforce domain rules before each save:

  • Freezable entities (IFreezable) are reverted to Unchanged unless code is inside an AllowFreezableEntityModification() scope.
  • Customer-owned entities (ICustomerOwned / IOptionallyCustomerOwned): new rows are stamped with CargonerdsCustomerId = _appConfig.CargonerdsCustomerId (gated by the mutable flags CargonerdsCustomerInjectionEnabled / AllowMasterDataCreation); attempting to save a row that belongs to a different customer throws InvalidOperationException.
  • Cache invalidation: UpdateVersionTrackers() bumps VersionTracker rows for modified cacheable types (see Caching).

Concurrency stamps are disabled on the Hub

UpdateConcurrencyStamp and SetConcurrencyStampIfNull are overridden to no-ops ("the property does not exist in the hub"). There is no optimistic concurrency on Hub entities, even though ABP normally provides it.

Sequences

The Hub exposes raw SQL Server sequence operations rather than EF-managed values:

  • NextValueForSequence(Sequence) runs SELECT @result = (NEXT VALUE FOR [...]).
  • ResetSequence(Sequence, newStartValue) runs ALTER SEQUENCE ... RESTART WITH ... and tracks a daily reset in SequenceDate.

The sequence name is read from a [Description] attribute on the Sequence enum member; a missing attribute throws.

Second-level cache + DbContextPool (Hub only)

The Hub context is pooled and cached. Both are configured in HubEntityFrameworkCoreModule.ConfigureServices:

context.Services.AddEFSecondLevelCache(options =>
{
    options
        .UseMemoryCacheProvider()
        .ConfigureLogging(true)
        .UseCacheKeyPrefix("EF_")
        .UseDbCallsIfCachingProviderIsDown(TimeSpan.FromMinutes(1));

    options.CacheAllQueries(CacheExpirationMode.Absolute, TimeSpan.FromMinutes(1));
});

context.Services.AddDbContextPool<HubDbContext>(
    (sp, opts) =>
    {
        var cs = sp.GetRequiredService<IConfiguration>().GetConnectionString(ConnectionStringNames.HubDb);
        opts.UseSqlServer(cs, sql =>
        {
            sql.EnableRetryOnFailure();
            sql.CommandTimeout((int)TimeSpan.FromMinutes(3).TotalSeconds);
        });

        opts.AddInterceptors(
            sp.GetRequiredService<SecondLevelCacheInterceptor>(),
            sp.GetRequiredService<CommandLoggingInterceptor>(),
            sp.GetRequiredService<FacetJoinInterceptor>(),
            sp.GetRequiredService<ArithAbortConnectionInterceptor>(),
            sp.GetRequiredService<RecompileCommandInterceptor>()
        );
    },
    poolSize: 256
);
  • CacheAllQueries(..., 1 min) means every Hub query is cached for one minute by default via the EFCoreSecondLevelCacheInterceptor with an in-memory provider. Invalidation depends on the custom VersionTracker mechanism.
  • AddDbContextPool<HubDbContext>(poolSize: 256) keeps a pool of 256 reusable contexts; the Hub connection string is read from IConfiguration inside the pool delegate, with EnableRetryOnFailure() and a 3-minute command timeout.
  • Interceptors are also (re)registered in HubDbContext.OnConfiguring (second-level cache, command logging, facet-join, IgnoreFrozenEntityInterceptor), and EnableSensitiveDataLogging() is on.

Cache details, the VersionTracker table and invalidation are documented in Caching. The ABP distributed-cache concept (used elsewhere) is described under ABP caching.

Interceptors

Interceptor File Purpose
ArithAbortConnectionInterceptor EntityFrameworkCore/ArithAbortConnectionInterceptor.cs Re-applies SET ARITHABORT ON on every ConnectionOpened, because sp_reset_connection from pooling clears SET options. Used by both contexts.
RecompileCommandInterceptor EntityFrameworkCore/RecompileCommandInterceptor.cs Appends OPTION (RECOMPILE) only when the SQL contains the literal -- recompile tag (added via EF TagWith("recompile")).
FacetJoinInterceptor EntityFrameworkCore/FacetJoinInterceptor.cs Text-replaces LEFT JOININNER JOIN for commands tagged with the facet prefix.
CommandLoggingInterceptor EntityFrameworkCore/CommandLogging/CommandLoggingInterceptor.cs Command logging (scoped via CommandLoggingScope<HubDbContext>).
IgnoreFrozenEntityInterceptor EntityFrameworkCore/IgnoreFrozenEntityInterceptor.cs Companion to the IFreezable save-time logic.

Interceptor footguns

  • RecompileCommandInterceptor is fragile with OData $apply: a TagWith("recompile") in the OData controller base previously caused a NullReferenceException on groupby/aggregate widgets — see OData filtering.
  • FacetJoinInterceptor is a textual SQL rewrite that assumes the facet builder already filtered nulls; it is brittle if the query shape changes.

Query filters — org isolation via custom IQueryFilter

This is the most non-obvious area of the persistence layer. The Hub does not use ABP's IDataFilter for organization scoping. Instead it defines its own IQueryFilter abstraction (modules/hub/src/Hub.Domain/Filters/IQueryFilter.cs) with two application paths.

public interface IQueryFilter
{
    bool IsAutoActive { get; }   // true -> applied to all queries automatically
    Type EntityType { get; }
    LambdaExpression CreateFilterExpression();
}

public abstract class QueryFilterBase<T> : IQueryFilter<T>, IScopedDependency
{
    public abstract Expression<Func<T, bool>> CreateFilterExpression();
    public virtual bool IsAutoActive => true;       // default: auto-applied
    Type IQueryFilter.EntityType => typeof(T);
}

Path 1 — auto-applied as real EF global query filters

HubDbContext overrides ABP's global-query-filter extension points so that any auto-active IQueryFilter becomes a genuine EF Core global filter:

protected override bool ShouldFilterEntity<TEntity>(IMutableEntityType entityType)
    => GetFilterProviders<TEntity>().Any() || base.ShouldFilterEntity<TEntity>(entityType);

protected override Expression<Func<TEntity, bool>>? CreateFilterExpression<TEntity>(
    ModelBuilder modelBuilder, EntityTypeBuilder<TEntity> entityTypeBuilder)
{
    var expression = base.CreateFilterExpression(modelBuilder, entityTypeBuilder); // soft-delete etc.
    foreach (var provider in GetFilterProviders<TEntity>())
    {
        var typedLambda = provider.CreateFilterExpression().AdaptExpression<TEntity>();
        expression = expression is null
            ? typedLambda
            : QueryFilterExpressionHelper.CombineExpressions(expression, typedLambda);
    }
    return expression;
}

private IEnumerable<IQueryFilter> GetFilterProviders<TEntity>()
    => LazyServiceProvider
        .GetServices<IQueryFilter>()
        .Where(p => p.IsAutoActive && p.EntityType.IsAssignableFrom(typeof(TEntity)));

Providers registered this way (via [ExposeServices(typeof(IQueryFilter))]) include SoftDeleteFilter, CustomerOwnedFilter and OptionallyCustomerOwnedFilter (in Hub.EntityFrameworkCore/Filter/). For example:

[ExposeServices(typeof(IQueryFilter))]
public class SoftDeleteFilter : QueryFilterBase<ISoftDelete>
{
    public override Expression<Func<ISoftDelete, bool>> CreateFilterExpression() => e => !e.IsDeleted;
}

[ExposeServices(typeof(IQueryFilter))]
public class CustomerOwnedFilter(AppConfiguration appConfig) : QueryFilterBase<CustomerOwnedGuidEntity>
{
    public override Expression<Func<CustomerOwnedGuidEntity, bool>> CreateFilterExpression() =>
        e => e.CargonerdsCustomerId == appConfig.CargonerdsCustomerId;
}

Note the auto path matches by IsAssignableFrom, so it covers inheritance.

Path 2 — manually applied via FilterAll

Org-scoping filters that should be applied explicitly set IsAutoActive => false and are invoked through IQueryable<T>.FilterAll(serviceProvider) (Hub.Domain/Extensions/QueryableExtensions.cs). HubDbContext.FilteredSet<T>() is the convenient entry point:

public IQueryable<T> FilteredSet<T>() where T : class => Set<T>().FilterAll(LazyServiceProvider);

// QueryableExtensions
public static IQueryable<T> FilterAll<T>(this IQueryable<T> queryable, IServiceProvider serviceProvider)
    where T : class
{
    var filters = serviceProvider
        .GetServices<IQueryFilter>()
        .Where(f => !f.IsAutoActive && f.EntityType == typeof(T)); // exact type, NOT IsAssignableFrom
    foreach (var f in filters)
        queryable = queryable.Where(f.CreateFilterExpression());
    return queryable;
}

FilterAll is used widely in app services, OData, Excel export and search providers. The org context itself comes from CurrentFilterContextProvider (Hub.Domain/Services/CurrentFilterContextProvider.cs, an ISingletonDependency holding an AsyncLocal<IFilterContext?>), which exposes ActiveOrgIds, ActiveOrgUnitIds, DefaultOrgUnitId, UserId and the user-accessible sets that org filters such as ShipmentOrganizationFilter read to build their expressions. For SQL-level filtering, HubBaseRepository serialises ActiveOrgIds to JSON and builds OPENJSON-based EXISTS clauses for Dapper-backed queries.

Two filter paths, easy to confuse

  • Auto-active (IsAutoActive => true) → a real global filter, always applied; bypass with IgnoreQueryFilters(). Matches by IsAssignableFrom (covers subclasses).
  • Non-auto (IsAutoActive => false) → applied only when you call .FilterAll() / FilteredSet<T>(). Matches by exact EntityType == typeof(T) (no inheritance). Forgetting FilterAll silently leaks cross-org data.

Full org-filtering details are in OData filtering.

Repositories

Both contexts use ABP repositories:

  • Cargonerds: AddDefaultRepositories(includeAllEntities: true) plus a custom AddRepository<ApiKey, EfCoreApiKeyRepository>(). Other custom repos live under src/Cargonerds.EntityFrameworkCore/Repositories/ (CargonerdsIdentityUserRepository, CommentRepository, IdentitySessionRepository, OrganizationUnitOwnerUserRepository).
  • Hub: AddDefaultRepositories<IHubDbContext>(includeAllEntities: true), default WithDetails include graphs via options.Entity<T>(opt => opt.DefaultWithDetailsFunc = e => e.IncludeDefaultDetails()) (Shipment, Consol, CustomsDeclaration, Order, Address, Tag, Quotation, Offer), and custom repos OrganizationRepository, MarginRepository, ShipmentBaseRepository, CalculatedShipmentRepository.

The Hub repository base is generic over the context:

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>
{
    // exposes raw Dapper connections + OPENJSON org filters alongside EF
}

It mixes EF Core with Dapper (raw SQL) for hot/aggregate queries. Compiled queries for hot paths live in Hub.EntityFrameworkCore/CompiledQueries/HubCompiledQueries.cs (e.g. ShipmentFindByIdWithDetails, OrganizationFindById, AssociationsWithOrganizations) and src/Cargonerds.EntityFrameworkCore/CompiledQueries/CargonerdsCompiledQueries.cs. See Repositories for the full repository story.

Design-time factory and migrations

CargonerdsDbContextFactory (src/Cargonerds.EntityFrameworkCore/EntityFrameworkCore/CargonerdsDbContextFactory.cs) implements IDesignTimeDbContextFactory<CargonerdsDbContext> so the EF Core CLI (dotnet ef, Add-Migration, Update-Database) can build the context. It reads the Default connection string from src/Cargonerds.DbMigrator/appsettings.json:

public CargonerdsDbContext CreateDbContext(string[] args)
{
    var configuration = BuildConfiguration(); // base path "../Cargonerds.DbMigrator/"
    CargonerdsEfCoreEntityExtensionMappings.Configure();
    var builder = new DbContextOptionsBuilder<CargonerdsDbContext>()
        .UseSqlServer(configuration.GetConnectionString("Default"));
    return new CargonerdsDbContext(builder.Options);
}

Only the Default database is EF-migrated

CargonerdsDbContext is the only project with a Migrations/ folder. HubDbContext and PricingDbContext ship no migrations, and only EntityFrameworkCoreCargonerdsDbSchemaMigrator is registered, so CargonerdsDbMigrationService.MigrateAsync() migrates the Default DB exclusively. The migration service also enforces no pending model changes before applying (ValidateNoPendingModelChangesAsync()Database.HasPendingModelChanges()), used in CI. The Hub schema is created and evolved outside EF Core (external DB project / scripts). Full details: Database Migrations.

PricingDbContext (effectively empty)

modules/pricing/src/Pricing.EntityFrameworkCore/EntityFrameworkCore/PricingDbContext.cs declares no DbSets and only folds the Pricing model into itself:

[ConnectionStringName(PricingDbProperties.ConnectionStringName)] // "Pricing"
public class PricingDbContext : AbpDbContext<PricingDbContext>, IPricingDbContext
{
    protected override void OnModelCreating(ModelBuilder builder)
    {
        base.OnModelCreating(builder);
        builder.ConfigurePricing();
    }
}

In practice the Pricing entities (Quotation, Offer, Margin, ChargeLine, …) are physically mapped and owned by HubDbContext (their type configs live under Hub.EntityFrameworkCore/.../TypeConfigurations/Pricing/). CargonerdsDbContext also calls builder.ConfigurePricing(). Do not expect a separate Pricing schema to be produced by PricingDbContext. See Pricing module and Hub module.

ABP patterns used here

  • AbpDbContext<T> base for all three contexts, with [ConnectionStringName] per context.
  • [ReplaceDbContext] to fold IIdentityProDbContext, ISaasDbContext and IPermissionManagementDbContext into CargonerdsDbContext.
  • Dependency injection registration markers: IScopedDependency / ISingletonDependency / ITransientDependency and [ExposeServices] for the query-filter providers.
  • Repositories via AddDefaultRepositories / AddRepository<TEntity, TRepo> and EfCoreRepository<...>.
  • ConfigureByConvention() for ABP audit/soft-delete/multi-tenant base columns, and the ABP ObjectExtensionManager EF mapping hook (CargonerdsEfCoreEntityExtensionMappings, run in PreConfigureServices and the design-time factory).
  • ABP global-query-filter extension points (ShouldFilterEntity, CreateFilterExpression) populated with a custom IQueryFilter system rather than ABP's IDataFilter.

Gotchas summary

Things to keep in mind

  • Hub DB has no EF migrations and is not migrated by the app; its schema is managed externally (ConfigureHub() is commented out, no Migrations/ folder, no Hub schema migrator).
  • Everything in the Hub is cached for 60s by default (CacheAllQueries, absolute 1 min). Stale reads are expected unless invalidated; UpdateVersionTrackers throws UnexpectedNullException if a VersionTracker row for a modified cacheable type is missing.
  • Hub concurrency stamps are disabled (no-op overrides) — no optimistic concurrency on Hub entities.
  • DbContextPool(256) + mutable per-request flags. HubDbContext carries mutable state (AllowMasterDataCreation, CargonerdsCustomerInjectionEnabled, _allowFreezableEntityModification). Pooling resets contexts via ABP, but custom mutable state on a pooled context is a known footgun — confirm it is reset between requests.
  • ArithAbortConnectionInterceptor reapplies SET ARITHABORT ON on every connection open (not once) to survive sp_reset_connection; without it the plan-cache slot differs from SSMS/Rider.
  • Two org-filter paths with different matching semantics (assignable vs. exact type) — forgetting .FilterAll() on the manual path leaks cross-org data.
  • UseParameterizedCollectionMode is set only on the Cargonerds context, not the Hub.
  • Pricing context is effectively empty; its entities live in the Hub DB/context.