The per-domain catalog (framework §2.22.1) of everything you can implement or override in the EF Core persistence layer, plus the events it publishes. Three sections:
- Overridable contracts — interfaces with a default implementation you can replace (
services.Replace(...)/ register-your-own). Bring one implementation and the built-in one steps aside. This is the override axis: "I want my own data access / my own bulk-upsert SQL." - Implementable contributor interfaces — add-don't-replace seams. Register an additional implementation alongside any others; a single aggregating handler runs them all (framework §2.6.1, §2.24.2). This is the extend axis.
- Events — the EF Core persistence-lifecycle seams this assembly publishes (
OnEntitySaving/OnEntityLoading).
This is the repo-wide EXTENSION_POINTS.md index's entry for this domain; the index links here for detail.
| Contract | Default impl | Override when |
|---|---|---|
Named per-aggregate read ports (e.g. IWorkflowDefinitionStore, IWorkflowDefinitionVersionStore, IWorkflowDefinitionDraftStore, IWorkflowDefinitionVersionLayoutStore, IActivityDefinitionStore, IActivityDefinitionVersionStore) (defined per domain in *.Design.Persistence.Core/Stores) |
EFCore<Aggregate>Store adapters over the generic EFCoreReadStore<TDbContext, TEntity> (this assembly) |
You want a different read strategy for one aggregate (caching layer, read replica, a non-EF/Groundwork store) while keeping the rest of the EF Core stack. This is the canonical override example: swap one read port, keep everything else. |
IUpsertCommandGenerator |
UpsertCommandGenerator |
A provider needs different bulk-upsert SQL (dialect-specific MERGE / ON CONFLICT). |
IElsaDbContextSchema |
(none — optional) | A deployment needs the Elsa tables under a custom schema name during migration. |
- Read contract: each aggregate exposes a small, intent-revealing read port (in its domain
*.Design.Persistence.Core/Stores) with closed methods (GetAsync,FindBy…Async,ListBy…Async,ExistsAsync, …). There is noIQueryable/LINQ surface — callers cannot express an arbitrary expression tree, so any provider can satisfy a port. - Shared plumbing: the EF default impls (
EFCore<Aggregate>Store) derive fromEFCoreReadStore<TDbContext, TEntity>(this assembly), which translates the closed, provider-neutralQuery<TEntity>spec (Elsa.Persistence.Core.Queries) to LINQ viaEFCoreQueryTranslator, appliesIgnoreQueryFilters()for tenant-agnostic reads, and publishesOnEntityLoadingfor every materialised entity. Filters project onto the spec through theirToQuery()method. - Replace: register your own implementation of a specific read port (or a decorator) before/after the EF Core feature wires its default; or subclass
EFCoreReadStorefor cross-cutting read behaviour. Mutate-then-save commands that need change tracking do not go through the read ports (they use a trackedDbContextFactorycontext directly), so overriding a read port does not affect the write path.
- Signature:
GeneratedCommand Generate<TDbContext, TEntity>(TDbContext dbContext, IList<TEntity> entities, Expression<Func<TEntity, string>> keySelector) where TDbContext : DbContext where TEntity : Entity; - Default impl:
UpsertCommandGenerator. Consumed byEFCoreBulkUpsertto build the raw upsert SQL.
- Signature:
string Schema { get; }— the schema name applied during migration.
These are registered alongside any others and dispatched by a single aggregating handler — you never register your own IEventHandler.
- Kind: entity Handler (action-named contributor). Lives in:
Elsa.Persistence.EFCore(Contracts/). - Signature:
ValueTask Handle(TDbContext dbContext, TEntity entity, CancellationToken cancellationToken); - Receives the context + typed entity and acts — serialises
[NotMapped]projections into backing*Source/ payload columns before the row is flushed. The typed generics do the entity-type filtering (no inlineischeck). - Register:
services.AddEntitySavingHandler<TDbContext, TEntity, THandler>()or scan withservices.AddEntitySavingHandlersFrom(assembly). - Consumed by: the single
ApplyEntitySavingHandlers : IEventHandler<OnEntitySaving>(this assembly), registered once byEFCorePersistenceShellFeatureBaseviaTryAddEnumerable.
Known implementations (shipped):
Elsa.Activities.Design.Persistence.EFCore—ActivityDefinitionVersionSavingHandler(cross-domain)Elsa.Workflows.Design.Persistence.EFCore—WorkflowDefinitionVersionSavingHandler,WorkflowDefinitionDraftSavingHandler(cross-domain)
- Kind: entity Handler (action-named contributor). Lives in:
Elsa.Persistence.EFCore(Contracts/). - Signature:
ValueTask Handle(TDbContext dbContext, TEntity entity, CancellationToken cancellationToken); - Receives the context + just-materialised entity and acts — hydrates
[NotMapped]projections from the backing columns. - Register:
services.AddEntityLoadingHandler<TDbContext, TEntity, THandler>()or scan withservices.AddEntityLoadingHandlersFrom(assembly). - Consumed by: the single
ApplyEntityLoadingHandlers : IEventHandler<OnEntityLoading>(this assembly), registered once byEFCorePersistenceShellFeatureBaseviaTryAddEnumerable.
Known implementations (shipped):
Elsa.Activities.Design.Persistence.EFCore—ActivityDefinitionVersionLoadingHandler(cross-domain)Elsa.Workflows.Design.Persistence.EFCore—WorkflowDefinitionVersionLoadingHandler,WorkflowDefinitionDraftLoadingHandler(cross-domain)
These contributor interfaces run through their own dispatch mechanism, not through OnEntitySaving / OnEntityLoading — listed here for completeness:
IGlobalEntitySavingHandler(Feature contract —Elsa.Persistence.EFCore) —ValueTask Handle(DbContext dbContext, EntityEntry entity, CancellationToken cancellationToken);. Runs for every modified entity (no per-type fan-in) directly fromElsaDbContextBase.ApplyGlobalSavingHandlers.IEntityModelCreatingHandler(Feature contract —Elsa.Persistence.EFCore) —void Handle(ElsaDbContextBase dbContext, ModelBuilder modelBuilder, IMutableEntityType entityType);. Runs duringOnModelCreating(before any request scope exists), dispatched byElsaDbContextBase.ApplyEntityModelCreatingHandlers.
A domain's persistence feature derives from EFCorePersistenceShellFeatureBase<TDbContext>. The base
registers the two aggregators (ApplyEntitySavingHandlers / ApplyEntityLoadingHandlers) once, then
registers your typed IEntitySavingHandler<,> / IEntityLoadingHandler<,> from a single list — both
directions together, so you can never wire one and forget the other.
protected virtual IEnumerable<Assembly> EntityHandlerAssemblies— override this to return the assembly/assemblies that hold your handlers. It defaults to[GetType().Assembly](the concrete feature's own assembly), but handlers usually live in the intermediate*.EFCoredomain assembly, so a domain base typically returns[GetType().Assembly, typeof(<ThisDomainBase>).Assembly]. The base scans this list for both saving and loading handlers (saving gated byUseCommands, loading byUseQueries). You do not callAddEntitySavingHandlersFrom/AddEntityLoadingHandlersFromyourself.- Entity construction — domain entities are built through their
I<Entity>Factory(in the domain.Design.Core, returning the read interface) + the entity's staticFrom(IInterface)at the persist boundary, not via object-mappers. Register the factory implementations (which live in the domain.Design.Persistence.Core) in the feature'sOnBeforeConfiguring/OnAfterConfigured.
See EFCoreActivitiesPersistenceFeatureBase / EFCoreWorkflowsPersistenceFeatureBase for worked
examples (both override EntityHandlerAssemblies and register their factories).
Both events are IEvent (framework §2.6.1). They are the EF Core persistence-lifecycle seams: a row is about to be flushed (OnEntitySaving) or has just been materialised (OnEntityLoading). Both are Sequential / contribution events — the publisher needs the contributors to have run (columns serialised / projections hydrated) before it proceeds.
Each event has exactly one subscriber: a single aggregating IEventHandler that closes the typed contributor interface over the runtime DbContext + entity types, resolves every registered implementation, and invokes it. This is the same contributor-interface + single-aggregating-handler shape as IDraftValidator + ExecuteValidations (framework §2.24.2). Features never subscribe their own IEventHandler<OnEntitySaving> / IEventHandler<OnEntityLoading>; they register a typed IEntitySavingHandler<,> / IEntityLoadingHandler<,> (see Implementable contributor interfaces above) and let the aggregator dispatch it.
Heading convention per research item R4: ### <EventClassName>.
Semantic. A modified Entity (Added or Modified) is about to be flushed. Contributors serialise rich, [NotMapped] projections into their backing *Source / payload columns and derive any computed columns BEFORE the row is written. The publisher awaits the dispatch so the columns are populated by the time the underlying write runs.
Payload.
DbContext : DbContext— the context performing the save (its runtime type selects the closed contributor interface).Entry : EntityEntry— the change-tracker entry for the entity being saved;Entry.Entityis the row (its runtime type selects the closed contributor interface).
Contributor interface. IEntitySavingHandler<TDbContext, TEntity> — see the Implementable contributor interfaces section above for signature + registration.
Delivery strategy. Sequential (the default) — the save must not proceed until the source columns are written.
Publication sites.
ElsaDbContextBase.DispatchEntitySavingEvents— published for every modifiedEntityinsideBeforeSavingChanges, i.e. on everySaveChangesAsync.EFCoreBulkUpsert.PublishEntitySavingEvents— the bulk-upsert path bypassesSaveChanges(it executes raw upsert SQL), so it publishesOnEntitySavingitself before generating the SQL so source columns are populated.
Expected handler.
- Exactly one
IEventHandler<OnEntitySaving>:ApplyEntitySavingHandlers(this assembly). Registered once per process byEFCorePersistenceShellFeatureBase.ConfigureServicesviaTryAddEnumerable(dedupes by implementation type even with several EF Core persistence features enabled).
Contributing handlers (IEntitySavingHandler<,> impls).
ActivityDefinitionVersionSavingHandler(Elsa.Activities.Design.Persistence.EFCore) — serialisesInputs/Outputs/DesignFacetsand the opaque descriptor payload intoDescriptorPayloadSource;DescriptorTypeis set by the producer, not derived.WorkflowDefinitionVersionSavingHandler/WorkflowDefinitionDraftSavingHandler(Elsa.Workflows.Design.Persistence.EFCore) — serialise the workflowState/ version payloads into their*Sourcecolumns.
Ordering guarantees.
- Fires for each modified entity BEFORE the underlying write (
SaveChangesAsync/ raw upsert SQL). - Contributors for a given (DbContext, entity) run in DI-resolution order (no guaranteed inter-handler ordering — independent per framework §2.6.1).
- The unrelated
IGlobalEntitySavingHandler(runs for every entity, no per-type fan-in) andIEntityModelCreatingHandler(runs duringOnModelCreating) are separate mechanisms — not dispatched through this event. - The Sequential path ships no exception-shielding (framework §2.6.6): a contributor that throws fails the save.
Semantic. An Entity has just been materialised from the store and needs hydrating: contributors deserialise the *Source / payload columns back into the rich, [NotMapped] projections. The publisher awaits the dispatch so the entity is fully hydrated before it is read or returned.
Payload.
DbContext : DbContext— the context that loaded the entity (its runtime type selects the closed contributor interface).Entity : Entity— the materialised entity to hydrate (its runtime type selects the closed contributor interface).
Contributor interface. IEntityLoadingHandler<TDbContext, TEntity> — see the Implementable contributor interfaces section above for signature + registration.
Delivery strategy. Sequential (the default) — the caller must see a hydrated entity.
Publication sites.
EFCoreReadStore.QueryAsync/FirstOrDefaultAsync(this assembly) — the read path behind every named read port. Published for every entity returned by a port read (per-item and per-list fan-out). These results areAsNoTracking; hydration is in-memory.UpdateDraft.LoadAndHydrate(Elsa.Workflows.Design.Persistence.EFCore) — the mutate-then-save path. The command loads the Draft through its own trackedDbContextFactorycontext (NOT a named read store, which returns a detachedAsNoTrackingentity it could not save), then publishesOnEntityLoadingSequential so the aggregator hydrates the already-tracked instance via the same context that willSaveChangesAsync.
Expected handler.
- Exactly one
IEventHandler<OnEntityLoading>:ApplyEntityLoadingHandlers(this assembly). Registered once per process byEFCorePersistenceShellFeatureBase.ConfigureServicesviaTryAddEnumerable.
Contributing handlers (IEntityLoadingHandler<,> impls).
ActivityDefinitionVersionLoadingHandler(Elsa.Activities.Design.Persistence.EFCore) — deserialises*Source+ the implementation-descriptor payload (via the descriptor-kind registry) back into rich projections.WorkflowDefinitionVersionLoadingHandler/WorkflowDefinitionDraftLoadingHandler(Elsa.Workflows.Design.Persistence.EFCore) — hydrate the workflowStatefrom itsStateSourcecolumn.
Ordering guarantees.
- Fires AFTER materialisation, BEFORE the entity is read/returned by the caller.
- Contributors for a given (DbContext, entity) run in DI-resolution order.
- The Sequential path ships no exception-shielding — a contributor that throws fails the load.
- The contributor interfaces, both aggregators, and the out-of-band hooks are also catalogued in the repo-root
EXTENSION_POINTS.mdindex. - Constitutional basis: §2.6.1 (the single
IEventconcept + contribution sub-pattern; action-named contributor suffixes) + §2.6.6 (delivery strategies) + §2.22.1 (per-domain extension-points catalog) + §2.24.2 (contributor interface + single aggregating handler).