AnchorFlow.EntityFrameworkCore
0.1.0-alpha.2
dotnet add package AnchorFlow.EntityFrameworkCore --version 0.1.0-alpha.2
NuGet\Install-Package AnchorFlow.EntityFrameworkCore -Version 0.1.0-alpha.2
<PackageReference Include="AnchorFlow.EntityFrameworkCore" Version="0.1.0-alpha.2" />
<PackageVersion Include="AnchorFlow.EntityFrameworkCore" Version="0.1.0-alpha.2" />
<PackageReference Include="AnchorFlow.EntityFrameworkCore" />
paket add AnchorFlow.EntityFrameworkCore --version 0.1.0-alpha.2
#r "nuget: AnchorFlow.EntityFrameworkCore, 0.1.0-alpha.2"
#:package AnchorFlow.EntityFrameworkCore@0.1.0-alpha.2
#addin nuget:?package=AnchorFlow.EntityFrameworkCore&version=0.1.0-alpha.2&prerelease
#tool nuget:?package=AnchorFlow.EntityFrameworkCore&version=0.1.0-alpha.2&prerelease
AnchorFlow
AnchorFlow is a modular .NET framework for tenant-scoped CRUD/application-service systems. It provides command and consumer pipelines, idempotency, durable outbox/inbox storage, provider-backed projection checkpoints, view-notification dispatch, a custom typed process manager, storage providers, Wolverine transport adapters, health/readiness checks, and reusable test harnesses.
This is the only README in the workspace. It is the canonical implementation map for agents and is also packed as the NuGet package readme for every AnchorFlow package. Read it before changing code, then inspect the named files and tests that match the task.
Quick Start
The normal integration path is one direct code-first registration:
services.AddAnchorFlow(options =>
{
options.UseSqlite("Data Source=app.db");
options.EnsureWriteTablesCreated();
options.EnsureProjectionTablesCreated();
options.UseWriteDbContexts(write => write.Add<AppDbContext>());
options.AddSubscriber<CustomerCreated, CustomerWelcomeSubscriber>();
options.UseLocalTransport();
options.RunApiWorkerAndNotificationDispatcher(runtime =>
{
runtime.Worker.Id = "local-dev-runtime";
runtime.Worker.MessagePublishing.MaxConcurrent = 1;
runtime.Worker.AsyncProcessing.MaxConcurrent = 4;
runtime.NotificationDispatcher.Id = "local-dev-runtime";
runtime.NotificationDispatcher.NotificationDelivery.MaxConcurrent = 4;
});
});
AddAnchorFlow(options => { ... }) is the beginner and normal application path. For SQLite examples and local single-database hosts, UseSqlite(connectionString) selects the provider, shared storage topology, and EF store configuration in one call. Register write-side application DbContexts through AnchorFlow with UseWriteDbContexts(...); do not also register those same DbContexts with services.AddDbContext<TDbContext>(). Register normal event subscribers with AddSubscriber<TEvent,TSubscriber>(). Command handlers may inject app-owned services, repositories, or managed DbContexts directly. AnchorFlow tracks resolved DbContext instances, not repositories, and owns SaveChanges, commit, rollback, idempotency, and outbox staging.
AnchorFlow does not read appsettings.json, IConfiguration, environment variables, command-line arguments, or secret stores. Host applications own configuration loading and may read any source they want; they pass direct strings, values, records, structs, and delegates into AnchorFlow.
Command and projection execution retries use one shared retry-delay profile. Command and projection retry attempts default to MaxAttempts = 3. Retry delays default to Mode = Exponential, BaseDelay = 50 ms, and MaxDelay = 2 s for both optimistic concurrency and classifier-approved transient failures. Exponential retries run the first retry immediately, then grow from BaseDelay up to MaxDelay. Hosts may also provide conservative transient-failure classifiers for non-concurrency DbUpdateException failures that the active provider reports as transient infrastructure faults:
services.AddAnchorFlow(options =>
{
options.UseSqlite("Data Source=app.db");
options.UseWriteDbContexts(write => write.Add<AppDbContext>());
options.ConfigureRetryDelays(retries =>
{
retries.OptimisticConcurrency.Mode = AnchorFlowRetryDelayMode.Exponential;
retries.OptimisticConcurrency.BaseDelay = TimeSpan.FromMilliseconds(50);
retries.OptimisticConcurrency.MaxDelay = TimeSpan.FromSeconds(2);
retries.TransientFailure.Mode = AnchorFlowRetryDelayMode.Exponential;
retries.TransientFailure.BaseDelay = TimeSpan.FromMilliseconds(50);
retries.TransientFailure.MaxDelay = TimeSpan.FromSeconds(2);
});
options.ConfigureCommandExecution(command =>
{
command.OptimisticConcurrency.MaxAttempts = 3;
command.Retry.TransientFailureClassifier = context =>
IsProviderTransient(context.Exception)
? AnchorFlowRetryDecision.Retry()
: AnchorFlowRetryDecision.DoNotRetry();
});
options.ConfigureProjectionExecution(projection =>
{
projection.OptimisticConcurrency.MaxAttempts = 3;
projection.Retry.TransientFailureClassifier = context =>
IsProviderTransient(context.Exception)
? AnchorFlowRetryDecision.Retry()
: AnchorFlowRetryDecision.DoNotRetry();
});
});
Every command retry reruns the full command attempt in a fresh scope with fresh managed DbContext instances. Every projection retry reruns the projection work item or projection group in a fresh projection execution scope before a failed checkpoint is recorded. Exhausting optimistic-concurrency attempts or a classifier-approved transient retry is a technical failure; for process-owned commands that terminal path is reported through ProcessManager failure feedback rather than as a business result reply.
Runtime role ids and work limits are also host-provided. In Kubernetes, applications commonly read a pod-unique value such as the Downward API metadata.uid through their own configuration layer, then pass that value into RunWorker(...), RunNotificationDispatcher(...), or RunApiWorkerAndNotificationDispatcher(...). AnchorFlow itself does not read Kubernetes metadata, environment variables, machine names, or pod names to choose a role or identity.
Shared database setup can use provider shortcuts when a provider package supplies them:
services.AddScoped<AppCardProjection>();
services.AddAnchorFlow(options =>
{
options.UseSqlite(writeConnectionString, projectionConnectionString);
options.EnsureWriteTablesCreated();
options.EnsureProjectionTablesCreated();
options.UseWriteDbContexts(write => write.Add<AppDbContext>());
options.UseProjectionDbContexts(projection => projection.Add<AppReadDbContext>());
options.UseLocalTransport();
options.RunApiWorkerAndNotificationDispatcher("local-dev-runtime");
});
Database-per-tenant setup uses connection-string templates and a tenant source. The placeholder is host-selected; this example uses [ResortId] because the host wants technical database names based on resort keys, not raw business tenant ids:
services.AddAnchorFlow(options =>
{
options.UseSqlite();
options.UseDatabasePerTenant(
"Data Source=stores/[ResortId]-write.db",
"Data Source=stores/[ResortId]-projection.db",
"[ResortId]");
options.UseTenants([
new AnchorFlowTenant("0f8fad5b-d9cb-469f-a165-70867728950e", "resort_a"),
new AnchorFlowTenant("7c9e6679-7425-40de-944b-e07fc1f90ae7", "resort_b")
]);
options.UseWriteStoreEfCore(write =>
{
write.UseConnection(
(serviceProvider, store) => new SqliteConnection(store.ConnectionInfo),
(serviceProvider, connection, builder) =>
builder.UseSqlite((SqliteConnection)connection));
});
options.UseWriteDbContexts(write => write.Add<AppDbContext>());
options.UseLocalTransport();
options.RunWorker("resort-worker-01");
});
Use a runtime-refreshable async source when the tenant catalog can change while the process is running. By default, AnchorFlow calls the delegate during store resolution/enumeration so the host can expose catalog changes without restarting:
services.AddAnchorFlow(options =>
{
options.UsePostgreSql();
options.UseDatabasePerTenant(
"Host=db;Database=write_[TenantKey]",
"Host=db;Database=projection_[TenantKey]");
options.UseTenants(async cancellationToken =>
await tenantCatalog.LoadAnchorFlowTenantsAsync(cancellationToken));
options.UseWriteStoreEfCore(write =>
{
write.UseConnection(
(serviceProvider, store) => new NpgsqlConnection(store.ConnectionInfo),
(serviceProvider, connection, builder) =>
builder.UseNpgsql((NpgsqlConnection)connection));
});
options.UseWriteDbContexts(write => write.Add<AppDbContext>());
options.UseAzureServiceBus(serviceBusConnectionString);
options.RunWorker("projection-worker-01");
});
If loading the tenant catalog is expensive and short-lived staleness is acceptable for the host, opt into the built-in catalog cache:
options.UseTenants(
async cancellationToken => await tenantCatalog.LoadAnchorFlowTenantsAsync(cancellationToken),
TimeSpan.FromMinutes(1));
The cached overload reuses a successfully loaded tenant catalog for the requested positive duration. It reduces repeated catalog lookups during store enumeration, but it does not change tenant isolation: commands, events, projections, notifications, outbox/inbox records, and process state still run under the tenant captured for each work item. Use the uncached overload when every store resolution must observe the latest catalog immediately.
Providers:
UseSqlite(connectionString)uses one shared SQLite database and configures the SQLite provider, topology, write-store EF options, and projection-store EF options.UseSqlite(writeConnectionString, projectionConnectionString)uses separate SQLite write and projection databases with the same provider shortcut.UseSqlite()only selects the SQLite provider; use it with explicit topology/store configuration for advanced cases such as database-per-tenant.UsePostgreSql()usesAnchorFlow.PostgreSqland is the production-like provider with provider contract tests.UseSqlServer()is blocked during startup validation until a realAnchorFlow.SqlServerprovider and SQL Server provider contract tests exist.
Transports and roles:
options.UseLocalTransport(); // local in-process transport
options.UseRabbitMq(rabbitMqConnectionString);
options.UseAzureServiceBus(serviceBusConnectionString);
options.RunApi(); // API-side command entry points
options.RunWorker("worker-01"); // Worker listeners, sweepers, projections
options.RunApiAndWorker("local-dev-worker"); // combined local/dev API+Worker host
options.RunNotificationDispatcher("dispatcher-01"); // app-owned view notification delivery
options.RunApiWorkerAndNotificationDispatcher("local-dev-runtime"); // all capabilities in one local host
Production pods usually use the same Docker image and select behavior from host configuration:
options.RunApi();
options.RunWorker(worker =>
{
worker.Id = runtimeId;
worker.MessagePublishing.MaxConcurrent = 1;
worker.AsyncProcessing.MaxConcurrent = 4;
});
options.RunNotificationDispatcher(dispatcher =>
{
dispatcher.Id = runtimeId;
dispatcher.NotificationDelivery.MaxConcurrent = 4;
});
MessagePublishing, AsyncProcessing, and NotificationDelivery concurrency values are per process/pod. Total effective concurrency scales with replicas: three Worker pods with AsyncProcessing.MaxConcurrent = 4 can run up to twelve asynchronous work items across the deployment, subject to tenant, subscription, storage, and transport limits. A conservative starting point for a 2 vCPU / 2 GB Worker pod is MessagePublishing.MaxConcurrent = 1 and AsyncProcessing.MaxConcurrent = 4; for a 2 vCPU / 2 GB NotificationDispatcher pod, start with NotificationDelivery.MaxConcurrent = 4 and raise it only after observing delivery latency, downstream throttling, and database claim contention.
Projection processing has one additional tuning value on the shared runtime batching options. Advanced hosts that configure the Wolverine adapter directly can set it on AnchorFlowWolverineOptions.RuntimeBatching:
services.AddAnchorFlowWolverine(options =>
{
options.UseCapabilities(AnchorFlowRuntimeCapabilities.Worker);
options.WorkerId = runtimeId;
options.RuntimeBatching.MaxConcurrentUngroupedProjectionWorkItems = 2;
});
MaxConcurrentUngroupedProjectionWorkItems controls how many independent, ungrouped projection work items for one event may run at the same time in one Worker process. The default is 1, which keeps projection processing sequential. Increase it only for projection handlers that do not depend on each other's in-memory side effects and after watching database pressure. Grouped projection batches remain sequential and atomic, projection checkpoints still advance through the runtime, and notification delivery still goes through the notification outbox after projection changes are persisted. Normal AddAnchorFlow(...) role options remain the recommended setup path unless a host already owns low-level Wolverine adapter configuration.
Developer-facing work names:
MessagePublishing: publishes committed messages from durable storage to the configured transport.AsyncProcessing: runs durable asynchronous work such as subscribers, projections, and ProcessManagers.NotificationDelivery: delivers staged view notifications through the registered app-ownedIAnchorFlowViewNotificationDelivery.
Beginner setup does not require internal table names, UseWolverine, AnchorFlowWolverineOptions, low-level store descriptors, provider SPI types, or direct Wolverine Saga APIs.
Repository-Friendly Write DbContexts
One AddAnchorFlow(options => { ... }) call can register one or more AnchorFlow-managed write DbContexts. Provider shortcuts such as UseSqlite(...) define the current write-store connection and provider options for normal setup. UseWriteDbContexts(...) lists the app DbContext types that may participate in command or consumer invocations without declaring command routes:
services.AddAnchorFlow(options =>
{
options.UseSqlite(writeConnectionString, projectionConnectionString);
options.UseWriteDbContexts(write =>
{
write.Add<TicketingDbContext>();
write.Add<TrainManagementDbContext>();
});
options.UseLocalTransport();
options.RunApiAndWorker("operations-local");
});
AnchorFlow-managed write DbContexts map application write entities only. Do not map AnchorFlow technical tables into normal application write DbContexts or app migrations. The command pipeline owns the unit-of-work boundary: command handlers mutate their application DbContext and emit messages through ICommandContext, but they do not call SaveChanges, open or commit transactions, send broker messages, or perform transport delivery directly.
Repository patterns are optional and app-owned:
- Inject the managed
TDbContextdirectly when the handler or app service is the right place for persistence code. - Inject an app repository that itself depends on the managed
TDbContext. - Inject an app service that coordinates multiple repositories or DbContexts.
- Inject
IAnchorFlowDbContextProvider<TDbContext>for lazy/provider-based repository patterns. - Derive a repository from
AnchorFlowRepository<TDbContext>when the protectedDbContextandSet<TEntity>()helpers reduce boilerplate.
AnchorFlow tracks DbContexts resolved in the invocation scope. It does not track repository instances, and repositories should not call SaveChangesAsync in the normal managed command path.
CRUD Bulk Chunks
CRUD bulk commands should process a chunk as one technical commit unit. A command may update, delete, or insert many application entities in one AnchorFlow-managed transaction, but the handler still follows the normal command pipeline rules: mutate the managed DbContext, emit facts through ICommandContext, and let AnchorFlow own SaveChanges, commit, rollback, idempotency, and outbox staging. Normal tracked EF updates are not hidden n-of-m best-effort operations: the command transaction commits as a whole or rolls back as a whole.
Bulk handlers should prefer set-based writes and database constraints. Use EF Core fluent operations such as ExecuteUpdateAsync and ExecuteDeleteAsync when they express the change cleanly. Their affected-row counts are database facts about that set-based statement, not automatic AnchorFlow business success/failure semantics. Do not introduce per-item ExistsAsync or AnyAsync loops to guess conflicts before each item write; rely on set filters, unique constraints, foreign keys, optimistic concurrency, and database conflict behavior, then let application code decide the business meaning in its typed result.
The chunk is the smallest reliable technical result. If the chunk transaction rolls back, AnchorFlow cannot infer per-item success or partial commit state from normal tracked updates. The default generic failure mapping is AnchorFlowBulkChunkFailurePolicy.MarkWholeChunkFailed; SplitAndRetry and StopBulkAction are vocabulary for future coordinators and guidance, not automatic retry-splitting behavior in this rollout.
Provider conflict behavior is described with AnchorFlowBulkConflictCapability. The enum is technical capability vocabulary only: for example, a provider may be able to ignore, report, or update on conflicts, but AnchorFlow does not decide whether an ignored row means already exists, no-op, rejected, merged, skipped, or updated. Application code owns that interpretation.
Bulk item terminal outcomes reuse AnchorFlowOperationOutcome. AnchorFlow intentionally does not define a separate AnchorFlowBulkItemOutcome enum for the same state set.
Generic CRUD helper APIs such as IAnchorFlowBulkWriter<TEntity,TKey,TItem>, AnchorFlowBulkWriteResult<TKey>, and AnchorFlowBulkWriteItemResult<TKey> are deferred. They should be designed only after provider SPI shape, capability reporting, chunk rollback semantics, and result semantics are proven clean without app-specific tables or business-semantics shortcuts.
Operation Results And Progress
Expected business outcomes should be result values or replies, not technical exceptions. Use IAnchorFlowOperationResult or an app-defined result record with AnchorFlowOperationOutcome, an optional app-owned AnchorFlowOperationResultCode, and a sanitized message when generic outcome vocabulary helps the caller or a ProcessManager. A process-owned command's business failure result arrives through the normal expected reply and is handled by the matching When(TReply, ...) transition. Technical terminal failure arrives through ProcessCommandFailed, and missing expected replies arrive through ProcessCommandTimedOut. Reserve exceptions for unexpected technical faults so retry, dead-letter, failure-feedback, and timeout paths remain meaningful.
Long-running workflows should report UI state through generic progress primitives. ProcessContext.Progress stages language-neutral operations in the same transition as process state, durable collection changes, outbox rows, expectations, and inbox completion. Progress uses app-defined AnchorFlowProgressKind, AnchorFlowProgressSectionKey, AnchorFlowProgressTextKey, AnchorFlowProgressIssueCode, and optional AnchorFlowProgressItemKey values. Store text tokens and stable argument names, not translated strings.
Applications can read progress with IAnchorFlowProgressReader and materialize AnchorFlowProgressSnapshot instances. Localization remains app-owned: implement IAnchorFlowProgressLocalizer or localize snapshots client-side from the stored text keys and arguments. The optional AnchorFlowProgressSnapshotLocalizer is a helper over app-provided localization rules; AnchorFlow does not choose culture, authorization, HTTP shape, or DTO shape.
Progress view notifications are generic view notifications. Projection code can stage language-neutral progress snapshots through AddProgressNotification(...); dispatch still goes through the notification outbox and app-owned IAnchorFlowViewNotificationDelivery. Do not send progress directly from command handlers, ProcessManagers, projections, or SignalR hubs, and do not create app-specific framework notification types for progress.
Imports are an application-owned composition pattern, not a framework feature. Model an import as application state such as:
ImportSession -> TypeRuns -> Chunks -> Items
Use ProcessManager state for execution, DurableList<T>/DurableDictionary<TKey,TValue>/DurableBlob<T> for large process-owned data, app commands and replies for type runs or chunks, AnchorFlowOperationOutcome for expected business results, and generic progress sections for user-visible status. For grouped imports, count every expected reply, ProcessCommandFailed, and ProcessCommandTimedOut as terminal for its application-level item key, then compensate only already committed successful steps when the workflow needs all-or-none business behavior. The framework storage remains generic and tenant-scoped; do not add import-specific framework tables, endpoints, controllers, hubs, or special progress concepts.
DDD Aggregate Roots
Use AggregateRoot<TAggregateRoot,TId> when an application entity is a DDD aggregate root: it owns invariants, changes child entities through root methods, and emits domain facts that should be committed with the same command or consumer transaction as the aggregate state. Aggregate roots should not save changes, publish to brokers, stage view notifications, call repositories, or run long workflows. Command handlers load the aggregate through an AnchorFlow-managed write DbContext or repository, call aggregate methods, and return the command result:
public async ValueTask<OrderResult> HandleAsync(
SubmitOrderCommand command,
ICommandContext context,
CancellationToken cancellationToken = default)
{
var order = await this.orders.GetAsync(command.OrderId, cancellationToken);
order.Submit(command.SubmittedAtUtc);
return OrderResult.From(order);
}
No manual context.Emit(...) call is needed for aggregate domain events. AnchorFlow collects uncommitted aggregate events from tracked aggregate roots in AnchorFlow-managed write DbContexts and stages them in the outbox as part of the same transaction.
Aggregate Events
Aggregate events derive from AggregateEvent<TAggregateRoot,TId>. The primary resource id is always the aggregate root id, so subscribers, projections, and process managers see the changed aggregate as the affected resource:
public readonly record struct OrderId(string Value);
public sealed record OrderSubmitted(
OrderId PrimaryResourceId,
DateTimeOffset SubmittedAtUtc) : AggregateEvent<Order, OrderId>(PrimaryResourceId);
public sealed class Order : AggregateRoot<Order, OrderId>,
IEmit<Order, OrderId, OrderSubmitted>
{
private bool submitted;
public override OrderId Id { get; }
public void Submit(DateTimeOffset submittedAtUtc)
{
if (this.submitted)
{
throw new InvalidOperationException("The order has already been submitted.");
}
this.Emit(new OrderSubmitted(this.Id, submittedAtUtc));
}
public void Apply(OrderSubmitted @event)
{
this.submitted = true;
}
}
Emit(...) is the aggregate API name because it records a domain fact from aggregate behavior. There is no aggregate Raise(...) API. Emit(...) validates the event belongs to the current aggregate id, verifies the aggregate implements the matching IEmit<TAggregateRoot,TId,TEvent>, calls the public Apply(TEvent) method, and then records the event as uncommitted. If Apply(...) throws, the event is not recorded.
IEmit<TAggregateRoot,TId,TEvent> is intentionally explicit in the aggregate type list. It states which event types the aggregate can apply and keeps each Apply(...) method strongly typed for future replay support. Apply(...) must be a public instance method; explicit-interface-only implementations are rejected. Calling Apply(...) directly is replay semantics: it mutates aggregate state but does not record a new uncommitted event.
Child Entity Changes
Child entities do not emit independent AnchorFlow aggregate events. Route child changes through the aggregate root and model the event as a root-scoped event with child ids in the payload:
public sealed record OrderLineQuantityChanged(
OrderId PrimaryResourceId,
OrderLineId LineId,
int Quantity) : AggregateEvent<Order, OrderId>(PrimaryResourceId);
Do not model the same fact as AggregateEvent<OrderLine,OrderLineId> from inside Order. The aggregate root is the transactional consistency boundary, and AnchorFlow collection follows tracked aggregate roots.
Automatic Outbox Collection
Automatic aggregate event collection works for aggregate roots tracked by AnchorFlow-managed write DbContexts registered through UseWriteDbContexts(...). It does not apply to unmanaged services.AddDbContext<TDbContext>() registrations. Register application write DbContexts through AnchorFlow when aggregate events should be collected:
services.AddAnchorFlow(options =>
{
options.UseSqlite(writeConnectionString, projectionConnectionString);
options.UseWriteDbContexts(write => write.Add<OrdersDbContext>());
options.UseLocalTransport();
options.RunApiAndWorker("orders-local");
});
During command and consumer invocation, AnchorFlow tracks aggregate candidates when EF tracks entities, deduplicates aggregate root instances by reference, preserves aggregate-local emission order, stages uncommitted events before outbox records are persisted, and clears those events only after the transaction commits. It does not promise global ordering across multiple aggregate roots changed by one command. The collector is designed for the hot path: it does not scan ChangeTracker.Entries() or call DetectChanges() at commit time and does not query the database to discover events.
Event Sourcing Boundary
DDD aggregate support in v1 is not event sourcing. AnchorFlow stores current aggregate state through EF and writes emitted aggregate events to the transactional outbox for subscribers, projections, process managers, and notifications. There are no event streams, snapshots, expected stream versions, historical replay APIs, historical projection rebuilds, historical event schema migration handlers, or dedicated event database integration. Public Apply(...) methods keep a future replay path possible, but current outbox rows are not a canonical event log.
Aggregate domain events are not integration events by default. Use aggregate events for facts inside the application boundary. Map or emit cross-boundary integration contracts outside aggregates, for example from a command handler/application service with EmitIntegration(...) when the integration event belongs to the command use case, or from subscribers/process managers when it belongs to asynchronous workflow.
Legacy Command Routing
The route-based command API still exists for existing hosts and focused tests. UseWriteDbContext<TDbContext>(...) routes commands explicitly. Use Handles<TCommand>() for individual command types, or HandleCommandsFromAssembly(...) when one DbContext owns all command types in an assembly. A command type may be routed only once, and an explicit command route must not overlap an assembly route for the same command assembly.
New applications should use provider shortcuts plus route-free UseWriteDbContexts(...). When API-side command execution is enabled and no managed write DbContext is registered, AnchorFlow requires at least one explicit command route. Runtime-only hosts that do not accept commands should call DisableCommandExecution().
Projection DbContexts
EF-backed projections can register projection DbContexts through the same options delegate:
services.AddScoped<CustomerCardsProjection>();
services.AddAnchorFlow(options =>
{
options.UseSqlite(writeConnectionString, projectionConnectionString);
options.DisableCommandExecution();
options.UseProjectionDbContexts(projection => projection.Add<CustomerCardsDbContext>());
options.UseLocalTransport();
options.RunWorker("projection-worker-01");
});
Register projection services before AddAnchorFlow(...); AnchorFlow discovers concrete IProjection<TEvent> implementations from the service collection during configuration. Projection DbContexts used for AnchorFlow-managed projection writes must be registered through AnchorFlow with UseProjectionDbContexts(...), not only with services.AddDbContext<TDbContext>().
AnchorFlow-managed projection DbContexts map read-model entities only. Projection checkpoints and notification outbox rows live in AnchorFlow-owned projection technical tables, not in normal application projection DbContext migrations.
AnchorFlow Technical Schema Creation
Application EF migrations are responsible for application write tables and read-model tables. AnchorFlow technical tables are created through the configured provider.
For local development, demos, tests, or simple hosted startup paths, opt in explicitly after selecting the provider and storage topology:
services.AddAnchorFlow(options =>
{
options.UseSqlite(writeConnectionString, projectionConnectionString);
options.EnsureWriteTablesCreated();
options.EnsureProjectionTablesCreated();
options.DisableCommandExecution();
options.UseLocalTransport();
options.RunWorker("projection-worker-01");
});
EnsureWriteTablesCreated() creates write-store technical tables such as idempotency, outbox, process state, locks, and dead letters during startup. EnsureProjectionTablesCreated() creates projection-store technical tables such as projection checkpoints, notification outbox, projection locks, and projection dead letters during startup. Use only the targets a host actually needs. A normal host that opts into these startup methods should not also resolve IAnchorFlowSchemaManager and call EnsureConfiguredStoresAsync(...) in the same startup path.
Production release or migration scripts can call the schema manager explicitly instead of creating schemas during every application startup:
await serviceProvider.GetRequiredService<IAnchorFlowSchemaManager>()
.EnsureConfiguredStoresAsync(AnchorFlowSchemaTarget.Write | AnchorFlowSchemaTarget.Projection);
Low-level APIs such as modelBuilder.ApplyAnchorFlowFrameworkStorage() remain available for focused tests and existing prerelease paths, but they are not part of the normal application DbContext guidance.
Projection Transaction Boundary
For managed EF projections, AnchorFlow resolves projection services and any AnchorFlow-managed projection DbContexts from the projection execution scope after the current ProjectionStore is initialized. Projection handlers update read models and stage notifications through ProjectionContext; they do not call SaveChanges in the managed path.
The EF projection transaction commits resolved managed Projection DbContexts, projection checkpoint updates, and notification outbox records together. If projection execution fails before commit, those read model changes, completed checkpoint updates, and staged notifications roll back together. Projection handlers that mutate the same target state should share a projection service/ordering group; all AnchorFlow-managed projection DbContexts resolved during projection execution participate in the same managed transaction boundary. Repository-friendly command invocation does not broaden projection execution to projection DbContext participation.
When several projections handle the same event in the same scope, view notifications staged by any one projection are not delivery-ready just because that projection committed. AnchorFlow persists projection-event barrier metadata with the notification outbox row and releases the row only after every expected projection for that event and scope reaches a terminal successful checkpoint state. Terminal successful states are applied, duplicate/already-applied, and old-version/skipped. Running or failed projections keep the notification blocked until retry processing records a terminal successful state.
Workspace Map
| Path | Purpose |
|---|---|
AnchorFlow.sln |
Main product and test solution. |
examples/AnchorFlow.Examples.sln |
Runnable example applications. Examples must remain runnable without PostgreSQL and must not add example test projects. |
src/AnchorFlow.Abstractions |
Stable public contracts: messaging, tenancy, runtime capability flags, and storage SPIs. It must not depend on EF Core, Wolverine, ASP.NET Core, SignalR, SQLite, PostgreSQL, or provider packages. |
src/AnchorFlow.Core |
Framework-independent runtime foundations: envelopes, tenant context, invocation context, subscription fan-out, runtime shutdown gating, payload/batching guards, retention, dead-letter operations, health contracts, and configured store resolution. |
src/AnchorFlow.EntityFrameworkCore |
EF Core command/consumer pipelines and generic framework-owned EF storage records/model mapping. |
src/AnchorFlow.EntityFrameworkCore.Sqlite |
SQLite EF Core shortcut package for UseSqlite(connectionString) and UseSqlite(writeConnectionString, projectionConnectionString), binding provider selection, shared topology, and managed EF store defaults. |
src/AnchorFlow.Sqlite |
SQLite implementation of the provider SPI for tests, runnable examples, demos, integration tests, and lightweight single-node deployments. |
src/AnchorFlow.PostgreSql |
PostgreSQL implementation of the provider SPI for production-like and realistic local/dev technical storage, leases, SKIP LOCKED claiming, schema creation, and indexes. |
src/AnchorFlow.Wolverine |
Optional Wolverine adapter for local transport, explicit API/Worker/NotificationDispatcher capability plans, dispatch, fan-out, outbox sweepers, worker lifecycle, notification dispatcher and retention workers, startup validation, runtime limits, and health. |
src/AnchorFlow.Wolverine.RabbitMq |
Optional RabbitMQ adapter package on top of AnchorFlow.Wolverine. |
src/AnchorFlow.Wolverine.AzureServiceBus |
Optional Azure Service Bus adapter package on top of AnchorFlow.Wolverine. |
src/AnchorFlow.AspNetCore |
ASP.NET Core tenant resolution plus liveness/API readiness/Worker readiness/NotificationDispatcher readiness health check registration. |
src/AnchorFlow.ProcessManager |
Custom typed process manager runtime, state persistence, expected replies, scheduled messages, and durable collections. It does not use Wolverine Saga APIs. |
src/AnchorFlow.Projections |
Projection registration, work item creation, idempotent checkpointing, projection groups, transaction hooks, and notification staging. |
src/AnchorFlow.ViewNotifications |
View-notification model, projection extension methods, notification outbox adapter, dispatcher, and app-owned delivery contracts. |
src/AnchorFlow.Testing |
Fakes, clocks, deterministic IDs, command/consumer/projection/process harnesses, and provider contract tests. |
tests/* |
Package-local xUnit test projects. Provider behavior is validated through shared provider contract tests. |
examples/* |
Compile-tested and runnable sample applications for core workflows. |
AgentPlans/AnchorFlow |
Baseline architecture contract, validation contract, story state, and guard evidence for planned architecture work. |
AgentPlans/TenantStoreConfiguration |
Tenant-store configuration feature contract, story status, guard findings, validation ledger, and final audit evidence. Read it before changing tenant registry, store resolver, current-store, provider naming, provisioning, or related docs. |
AgentPlans/* |
Feature-specific Goal/Guard plans. Treat the relevant plan package as the operating contract when work targets a planned feature. |
Package Boundaries
Keep package references narrow. Applications should install only the packages needed at their boundary.
| Package | Main public surface |
|---|---|
AnchorFlow.Abstractions |
ICommandBus, Command<TResult>, ICommand<TResult>, ICommandHandler<TCommand,TResult>, IQueryProcessor, IQuery<TResult>, IQueryHandler<TQuery,TResult>, IMessageHandler<TMessage>, AnchorFlowEvent<TEntity,TId>, AnchorFlowManyEvent<TEntity,TId>, AggregateRoot<TAggregateRoot,TId>, AggregateEvent<TAggregateRoot,TId>, IEmit<TAggregateRoot,TId,TEvent>, IAnchorFlowIntegrationEvent, IntegrationOrderingKey, MessageEnvelope, identifier value types, ITenantResolver, ITenantContextAccessor, AnchorFlowRuntimeCapabilities, TenantKey, IAnchorFlowTenantRegistry, IAnchorFlowStoreResolver, IAnchorFlowStorageProvider, provider store interfaces, table name/topology contracts, tenant provisioning contracts. |
AnchorFlow.Core |
CommandBus, QueryProcessor, MessageEnvelopeFactory, CommandContext, ConsumerContext, core ProjectionContext/ProcessContext, AsyncLocalTenantContextAccessor, TenantContext, TenantGuard, ConfiguredAnchorFlowTenantRegistry, ConfiguredAnchorFlowStoreResolver, IAnchorFlowCurrentStore, AnchorFlowTenantProvisioner, AnchorFlowSubscriptionRegistry, AnchorFlowSubscriptionFanOutRouter, AnchorFlowSubscriptionWorkItemDispatcher, IAnchorFlowRuntimeGate, AnchorFlowRuntimeGate, AnchorFlowRuntimeGateApplicationLifetimeRegistration, AnchorFlowRetentionCleanupService, AnchorFlowDeadLetterOperations, AnchorFlowInvocationContextAccessor, payload and batching options. |
AnchorFlow.EntityFrameworkCore |
AddAnchorFlow, AnchorFlowOptions, UseWriteStoreEfCore(...), UseWriteDbContexts(...), UseProjectionDbContexts(...), IAnchorFlowDbContextProvider<TDbContext>, AnchorFlowRepository<TDbContext>, AnchorFlowTenant, AnchorFlowTenantSource, route-based UseWriteDbContext<TDbContext>(...), existing-host AddAnchorFlow<TDbContext>, low-level AddAnchorFlowEntityFrameworkCommandBus<TDbContext>, EntityFrameworkCommandPipeline<TDbContext>, EntityFrameworkConsumerPipeline<TDbContext>, EntityFrameworkCommandInvoker<TDbContext>, EntityFrameworkConsumerInvoker<TDbContext>, AnchorFlowModelBuilderExtensions, EF storage record types. |
AnchorFlow.EntityFrameworkCore.Sqlite |
UseSqlite(connectionString), UseSqlite(writeConnectionString, projectionConnectionString). |
AnchorFlow.Sqlite |
AnchorFlowSqliteStorageProvider, AnchorFlowSqliteOptions, AnchorFlowSqliteTableNameResolver, AnchorFlowSqliteSchema. |
AnchorFlow.PostgreSql |
AnchorFlowPostgreSqlStorageProvider, AnchorFlowPostgreSqlOptions, AnchorFlowPostgreSqlTableNameResolver, AnchorFlowPostgreSqlSchema, PostgreSQL lease support. |
AnchorFlow.Wolverine |
AddAnchorFlowWolverine, AnchorFlowWolverineOptions, AnchorFlowWolverineRuntimePlan, AnchorFlowRuntimeLimiter, AnchorFlowWolverineOutboxDispatcher, AnchorFlowWolverineImmediateOutboxFlusher, AnchorFlowWolverineSubscriptionFanOutDispatcher, AnchorFlowWolverineWorkerRuntime, AnchorFlowWolverineWorkerServiceCatalog, AnchorFlowWolverineWorkerStartupValidator, AnchorFlowWolverineNotificationWorker, AnchorFlowWolverineRetentionWorker, AnchorFlowWorkerLifecycleAdapter, WolverineMessageBusTransport. |
AnchorFlow.Wolverine.RabbitMq |
UseRabbitMq(...), AnchorFlowRabbitMqTransportAdapter, RabbitMQ topology descriptors. |
AnchorFlow.Wolverine.AzureServiceBus |
UseAzureServiceBus(...), AnchorFlowAzureServiceBusTransportAdapter, Azure Service Bus topology descriptors. |
AnchorFlow.AspNetCore |
ASP.NET Core tenant resolver and AnchorFlow health check registration. |
AnchorFlow.ProcessManager |
ProcessManager<TState,TStartEvent>, ProcessManagerRegistry, ProcessManagerRuntime, ProcessStateStore<TState>, ProcessManagerOperations, Transition, ProcessContext, ProcessSendBuilder, DurableList<T>, DurableDictionary<TKey,TValue>, DurableBlob<T>. |
AnchorFlow.Projections |
IProjection<TEvent>, discovered projection definitions, ProjectionProcessor, ProjectionWorkItemFactory, ProviderProjectionCheckpointStore, ProviderProjectionTransactionFactory, IProjectionTransactionFactory, IProjectionNotificationOutbox, projection result and checkpoint types. |
AnchorFlow.ViewNotifications |
ViewNotification, ProjectionViewNotificationExtensions, AnchorFlowViewNotificationOutbox, AnchorFlowViewNotificationDispatcher, AnchorFlowViewNotificationDispatcherOptions, IAnchorFlowViewNotificationDelivery. |
AnchorFlow.Testing |
CommandTestHarness, ConsumerTestHarness, ProjectionTestHarness, ProcessManagerTestHarness, fake tenant/user contexts, fake notification delivery, TestMessageClock, TestScheduler, AnchorFlowStorageProviderContractTests. |
Core Runtime Model
AnchorFlow assumes writes enter through commands, async work moves through durable outbox/inbox records, projections maintain query/view state, view notifications are delivered only after projection changes are persisted, and process managers orchestrate long-running workflows through typed state plus expected replies.
Runtime shutdown and scale-in safety is centralized in IAnchorFlowRuntimeGate / AnchorFlowRuntimeGate. AnchorFlowRuntimeGateApplicationLifetimeRegistration connects host ApplicationStopping to the gate. Internal workers and durable claim paths must check the gate before creating new work claims, run with the gate stopping token, and leave shutdown cancellation retryable or reclaimable instead of recording it as terminal business failure.
Do not introduce event-database storage, historical event schema migration handlers, event-log-based projection rebuilds, framework-managed external HTTP orchestration, direct Wolverine saga usage, or app/process/import-specific framework tables unless the architecture is explicitly changed.
Tenant And Message Metadata
Tenant isolation is a hard invariant. Commands, events, process state, durable collections, projections, notifications, idempotency, outbox, inbox, retention, and dead letters are tenant-scoped unless explicitly system-level.
Important types:
TenantId,MessageId,CorrelationId,CausationId,ProcessId,SagaId, andIdempotencyKeyare value contracts inAnchorFlow.Abstractions.Messaging.MessageEnvelopecarries kind, message type, tenant/system scope, correlation/causation, process/saga ids, idempotency key, timestamp, and headers.MessageEnvelopeFactorycreates command/event/reply envelopes and preserves causation metadata.TenantContext,AsyncLocalTenantContextAccessor, andTenantGuardenforce ambient tenant behavior.AnchorFlowInvocationContextAccessorexposes the active command, subscriber, projection, process manager, or notification invocation to downstream code.
When adding a pipeline path, always initialize or preserve envelope metadata and tenant context before invoking user code.
Ambient Tenant Context
AnchorFlow separates the application tenant value from command payloads. A command may carry its own TenantId property, but it does not have to when the application has already established an ambient tenant context for the current operation.
The ambient tenant is exposed through ITenantContextAccessor.Current. The default implementation, AsyncLocalTenantContextAccessor, is intentionally registered as a singleton by ASP.NET Core integration and stores the current tenant in AsyncLocal. The singleton instance is shared, but the Current value is scoped to the current asynchronous execution flow. Parallel ASP.NET Core requests can therefore use the same accessor instance and still see different tenant contexts.
Use BeginScope(...) when setting the tenant for a request or explicit operation so the previous value is restored:
using var tenantScope = tenantAccessor.BeginScope(
TenantContext.ForTenant(new TenantId(applicationTenantId), "jwt"));
await next();
For applications that use Guid tenant ids, convert the application-specific id to the framework TenantId string value consistently:
var tenantId = new TenantId(applicationTenantGuid.ToString("D"));
Use one format consistently for the whole application, for example "D" for the usual dashed GUID text or "N" for compact technical ids.
ASP.NET Core applications normally resolve the application-specific tenant before publishing commands, for example in middleware that reads a JWT claim, validates it according to application rules, converts it to AnchorFlow.Abstractions.Messaging.TenantId, and sets ITenantContextAccessor.Current for the rest of the request. This middleware belongs to the host application because the framework cannot know the application's JWT schema, tenant claim names, authorization model, or tenant lookup rules.
When ICommandBus.PublishAsync(...) executes a command, the command tenant is resolved from CommandExecutionOptions.TenantId, a public command TenantId property, or the ambient ITenantContextAccessor.Current.TenantId. If both the command/options and the ambient context specify tenant values, they must match. If none is available, the command is treated as explicit system-level work.
Worker paths do not rely on an HTTP request tenant. Command execution persists tenant metadata on the command envelope and outbox rows. Worker-side outbox, consumer, subscription, projection, notification, and process-manager execution reconstruct the tenant from the persisted/brokered envelope metadata and set the ambient tenant context for the duration of that unit of work before user code runs. This is the reason command handlers and event subscribers can use the same tenant-aware services in API and Worker roles.
Tenant Store Configuration
Tenant store configuration separates the business tenant id from technical store routing. The runtime path is:
TenantId -> TenantRegistry -> StoreResolver -> WriteStore / ProjectionStore -> CurrentStoreContext -> DbContext/provider setup
Core contracts:
TenantKeyis the validated technical key for store names, templates, provisioning, and tenant partitions. It is not the same thing as the businessTenantId.AnchorFlowTenantRegistryEntryregisters one tenant with one WriteStore and one ProjectionStore.AnchorFlowTenantStoreRegistrationrecords store id,AnchorFlowStoreKind, topology, provider, optional database name, and connection source.ConfiguredAnchorFlowStoreResolverresolvesAnchorFlowStoreDescriptorinstances for command/consumer/process WriteStore work and projection/notification ProjectionStore work.IAnchorFlowCurrentStoreis a scoped Microsoft DI accessor initialized by AnchorFlow invocation scopes before scoped DbContexts or runtime services are resolved.
Store descriptor semantics:
AnchorFlowStoreDescriptor.ConnectionInfois the resolved provider-specific value used by DbContext/provider setup. Treat it as sensitive and do not print it in diagnostics.AnchorFlowStoreDescriptor.TenantIdidentifies the business tenant for tenant-bound work. Shared worker enumeration may return an unbound shared descriptor withTenantId == null.AnchorFlowStoreDescriptor.TenantPartitionKeyis the value used for tenant-owned rows in a shared physical store. Registry-backed tenant descriptors useTenantKey.Value; legacy direct shared-store fallback uses the tenant id as the partition key.EnumerateWriteStoresAsyncandEnumerateProjectionStoresAsyncare worker-facing APIs.SharedDatabaseenumeration deduplicates shared physical stores;DatabasePerTenantenumeration emits per-tenant descriptors.
Supported topologies:
SharedDatabase: all tenants resolve to the same WriteStore and ProjectionStore descriptors. Tenant rows still carry tenant metadata.DatabasePerTenant: each tenant resolves to its own WriteStore and ProjectionStore descriptors.
Connection-string strategies:
var resolver = new ConfiguredAnchorFlowStoreResolver(
new AnchorFlowStoreResolverOptions()
.AddConnectionString("TenantA:Write", "Data Source=tenant-a-write.db")
.AddConnectionString("TenantA:Projection", "Data Source=tenant-a-projection.db")
.AddTenantRegistration(
new AnchorFlowTenantRegistryEntry(
"tenant-a",
new TenantKey("tenant_a"),
new AnchorFlowTenantStoreRegistration(
"tenant-a-write",
AnchorFlowStoreKind.Write,
AnchorFlowTenancyMode.DatabasePerTenant,
"sqlite",
new AnchorFlowStoreConnectionSource(
AnchorFlowStoreConnectionSourceKind.ConnectionStringName,
"TenantA:Write"),
"tenant_a_write"),
new AnchorFlowTenantStoreRegistration(
"tenant-a-projection",
AnchorFlowStoreKind.Projection,
AnchorFlowTenancyMode.DatabasePerTenant,
"sqlite",
new AnchorFlowStoreConnectionSource(
AnchorFlowStoreConnectionSourceKind.ConnectionStringName,
"TenantA:Projection"),
"tenant_a_projection"))));
Templates are also supported for controlled technical values:
new AnchorFlowStoreConnectionSource(
AnchorFlowStoreConnectionSourceKind.ConnectionStringTemplate,
"Data Source=stores/{WriteDatabaseName}.db");
Allowed placeholders are {TenantKey}, {WriteDatabaseName}, and {ProjectionDatabaseName}. Raw {TenantId} is rejected because it is business input and may not be safe for technical store names. Do not log resolved connection strings when they may contain secrets.
AnchorFlow-managed write DbContext setup is current-store-based and owned by AnchorFlow:
services.AddAnchorFlow(options =>
{
options.UseSqlite(writeConnectionString, projectionConnectionString);
options.UseWriteDbContexts(write => write.Add<AppDbContext>());
options.UseLocalTransport();
options.RunApiAndWorker("local-dev-worker");
});
Handlers, projections, app-owned notification delivery services, and process managers must not receive connection strings directly. They should rely on the scoped DbContext or provider services resolved after AnchorFlow initializes the current store.
Runtime store binding:
- EF command and consumer pipelines resolve and bind the tenant WriteStore before scoped handlers and DbContexts run.
- Projection processing resolves and binds the tenant ProjectionStore before projection dependencies run.
- View-notification dispatch enumerates ProjectionStores and validates tenant/store ownership before app-owned delivery code runs.
- Wolverine subscription work and scoped outbox sweep work bind WriteStore context before scoped storage or transport services execute.
- ProcessManager scoped transitions bind tenant context and WriteStore context before process managers, process state, durable collections, and process storage execute.
- Store-kind or tenant mismatches fail before user handler, projection, notification, or process-manager code is invoked.
Provisioning is explicit. IAnchorFlowTenantProvisioner coordinates:
- Register or create WriteStore.
- Register or create ProjectionStore.
- Apply application WriteStore migrations.
- Apply application ProjectionStore migrations.
- Apply AnchorFlow WriteStore schema.
- Apply AnchorFlow ProjectionStore schema.
- Update the TenantRegistry.
Provisioning is not automatic on every request. Hosts can implement IAnchorFlowTenantStoreProvisioner, IAnchorFlowStoreMigrator, and IAnchorFlowTenantRegistryUpdater to integrate their own database creation, migration, and registry persistence.
The Core provisioner reports ordered step results and sanitized failure metadata. It does not create production databases, store secrets, or implement provider-specific provisioning engines by itself.
Testing guidance:
- Use fake or configured
IAnchorFlowTenantRegistryentries to verify SharedDatabase and DatabasePerTenant routing. - Test named connection-string and template resolution without exposing real secrets.
- Use
AddAnchorFlowInvocationContext()plusAnchorFlowScopedInvocationRunnerto verifyIAnchorFlowCurrentStorebased DbContext configuration. - Use SQLite relational tests or examples for local store routing. Do not treat EF Core
InMemorytests as proof of relational provider behavior.
Query Handling
Application read entry points can execute typed IQuery<TResult> requests through scoped IQueryProcessor.ProcessAsync(...). The processor is not a bus and does not create messages. It only initializes the current projection store when needed, resolves the typed IQueryHandler<TQuery,TResult> from DI, and calls HandleAsync(...).
Query handling has no command, outbox, inbox, idempotency, retry, broker, notification, envelope, transaction, or SaveChanges semantics. Query handlers are app-owned read services. They may inject projection DbContexts, read repositories, or other read-side services and should use normal read-only EF patterns such as AsNoTracking() where appropriate.
The public processor API only needs the query argument:
public sealed record GetCustomerDetails(string CustomerId) : IQuery<CustomerDetails>;
public sealed class GetCustomerDetailsHandler(CustomerReadDbContext dbContext)
: IQueryHandler<GetCustomerDetails, CustomerDetails>
{
public async ValueTask<CustomerDetails> HandleAsync(
GetCustomerDetails query,
CancellationToken cancellationToken = default)
{
return await dbContext.Customers
.AsNoTracking()
.Where(customer => customer.Id == query.CustomerId)
.Select(customer => new CustomerDetails(customer.Id, customer.Name))
.SingleAsync(cancellationToken);
}
}
var details = await queryProcessor.ProcessAsync(new GetCustomerDetails(customerId), cancellationToken);
The processor caches the concrete query-type dispatch plan after first use. Reflection is not used per query execution, and query results, handlers, DbContexts, stores, and application data are not cached.
For AnchorFlow-owned projection DbContexts, the processor resolves and initializes the projection store before resolving the handler. In shared-database and database-per-tenant setups, the ambient ITenantContextAccessor.Current must contain a tenant unless the current scope already has an initialized projection store.
Command Pipeline
Application entry points should publish Command<TResult> instances through scoped ICommandBus.PublishAsync(...). The bus delegates to the configured command executor, and EF-backed applications normally register one or more AnchorFlow-managed write DbContexts through provider shortcuts plus UseWriteDbContexts(...). The existing-host AddAnchorFlow<TDbContext>(...) overload and the lower-level AddAnchorFlowEntityFrameworkCommandBus<TDbContext>() still live in src/AnchorFlow.EntityFrameworkCore/Messaging for runtime internals, custom setup, and focused tests; they are not the beginner path.
Expected command flow:
- Resolve ambient tenant from
ITenantContextAccessor. - Resolve command tenant from
CommandExecutionOptions.TenantId, a commandTenantIdproperty, or system-level scope. - Resolve idempotency key from
CommandExecutionOptions.IdempotencyKey, commandIdempotencyKey, or commandCommandId. - Create a
MessageEnvelopethroughMessageEnvelopeFactory. - Verify tenant consistency through
TenantGuard. - Resolve the write store through
IAnchorFlowStoreResolver. - Check existing
AnchorFlowIdempotencyRecordby(IdempotencyPartition, IdempotencyKey). - Try to insert a started idempotency record; a duplicate start race is re-read and mapped to public duplicate semantics.
- Begin the EF transaction owned by the pipeline.
- Invoke
ICommandHandler<TCommand,TResult>withCommandContext. - Save participating application DbContext changes.
- Convert
context.Emit(...)andcontext.EmitIntegration(...)messages intoAnchorFlowOutboxMessageRecordrows. - Persist completed idempotency result.
- Commit.
- Run bounded immediate outbox flush through
IAnchorFlowImmediateOutboxFlusherif registered.
Command retry:
DbUpdateConcurrencyExceptionraised during AnchorFlow-owned command saving is retried according toConfigureCommandExecution(...).OptimisticConcurrency.- Non-concurrency
DbUpdateExceptionfailures are retried only whenConfigureCommandExecution(...).Retry.TransientFailureClassifierreturnsAnchorFlowRetryDecision.Retry(). - Command retry delays use the shared
ConfigureRetryDelays(...)profile; exponential mode retries once immediately, then grows fromBaseDelaytoMaxDelay. - The failed attempt's scope, DbContext instances, transaction, idempotency work, and staged outbox work are discarded; the next attempt creates a fresh scope and reruns
HandleAsync. HandleAsynccan run more than once for one published command. Handlers must be retry-safe and must not perform irreversible external side effects before the command commit.- Only the successful attempt commits idempotency and outbox rows. AnchorFlow outbox and idempotency records commit once for the successful attempt, but handler code itself may have been invoked more than once.
- Deterministic programming, mapping, schema, cancellation, and business-result failures are not command-retried. Exhausted retry attempts rethrow the final EF Core update exception.
- The route-free command bus and route-based command-bus paths create fresh retry scopes. Direct caller-owned
EntityFrameworkCommandPipeline<TDbContext>.ExecuteAsync(command, handler, ...)invocations run one attempt because the caller owns the handler and DbContext instances.
Handler rules:
- A command handler is the unit-of-work boundary.
- Handler code may use EF Core directly, app repositories, or app services.
- Handler code must not open/commit/rollback transactions.
- Handler code must not call
SaveChanges, brokers, transport delivery, SignalR delivery, or outbox tables directly. - Handler code emits events only through
ICommandContext.Emit(...)orEmitIntegration(...). - Actor/user metadata from
CommandExecutionOptions.ActorIdandCommandExecutionOptions.UserIdis copied into command headers and preserved through outbox/consumer paths.
Duplicate command behavior:
- Same tenant/system partition, same idempotency key, same command type, and same request hash returns the cached result.
- Same idempotency key with a different request hash throws
CommandIdempotencyConflictException. - An incomplete matching record, including a concurrent duplicate start while the first request is still running, throws
CommandAlreadyInProgressException.
Consumer Pipeline
The EF consumer pipeline lives in src/AnchorFlow.EntityFrameworkCore/Messaging/EntityFrameworkConsumerPipeline.cs.
Expected consumer flow:
- Receive an
AnchorFlowConsumerMessagefrom transport or framework dispatch. - Recreate a
MessageEnvelopefrom the persisted/brokered metadata. - Validate or establish ambient tenant context.
- Check inbox receipt by
(MessageId, ConsumerName). - Begin the EF transaction owned by the pipeline.
- Try-start or restart a failed inbox receipt.
- Deserialize payload after payload guard checks.
- Invoke
IMessageHandler<TMessage>withConsumerContext. - Save application changes.
- Stage outgoing outbox records from
context.Emit(...). - Complete inbox receipt.
- Commit.
- Flush committed outbox records if immediate flusher is registered.
Failure behavior:
- Retry decisions come from
IAnchorFlowConsumerRetryPolicy; default isMaxAttemptConsumerRetryPolicy(3). - Retryable failures persist failed receipt metadata and return
RetryScheduledwithout ack. - Permanent failures create an
AnchorFlowDeadLetterRecord, mark the inbox receiptdead-lettered, and return an ackable result.
Events And Affected Resources
Business events must derive from AnchorFlowEvent<TEntity,TId> for one primary resource or AnchorFlowManyEvent<TEntity,TId> for several primary resources. Application events should not implement IAnchorFlowEvent directly, and plain records are not valid business events. ICommandContext.Emit(...) and IConsumerContext.Emit(...) accept only strict business event contracts.
Use the event base constructors for the primary affected resource or resources. Add secondary resources by passing explicit affected-resource callbacks to Emit(...); those resources are additive and do not replace the primary resource metadata from the event base class. Do not depend on property-name conventions such as CustomerId or OrderId to make a plain record routable.
Integration events are separate from business events. They implement IAnchorFlowIntegrationEvent, are emitted with EmitIntegration(...), and provide a deterministic IntegrationOrderingKey through the OrderingKey property. Integration ordering is per key, not global. AnchorFlow persists and forwards the key through outbox, provider, subscription-work-item, and Wolverine metadata; RabbitMQ and Azure Service Bus broker-level ordering/session enforcement is not added by AnchorFlow.
Legacy Publish(...) wrappers remain for existing prerelease callers and are not the target API. When migrating prerelease application code, change emitted business-event records to derive from AnchorFlowEvent<TEntity,TId> or AnchorFlowManyEvent<TEntity,TId>, move cross-boundary notifications to IAnchorFlowIntegrationEvent, and choose stable ordering keys such as customer:{id} or order:{id}.
Affected-resource metadata is persisted on outbox rows and travels with committed outbox batches, broker messages, subscription work items, projections, and notifications. Business events are persisted with OutboxSequence, the technical write-store ordering value also exposed as StoreEventSequence on subscriber work. EntityVersion, ResourceVersion, and AffectedResourceVersions are optional domain/projection-safety values and are not required for event ordering.
Business-event source ordering is per affected-resource lane within the tenant or system scope, not global. Later outbox messages that overlap an earlier undispatched affected-resource lane are not claimable until the earlier source message is dispatched. Independent resources can still dispatch in the same wave, so CRUD and DDD command handlers may emit several events without forcing global serialization across unrelated entities.
When a source business event has subscribers, AnchorFlow creates durable subscription work items before marking that source outbox message dispatched. Subscriber work item lanes then serialize execution by OrderingSubscriptionId + AffectedResourceKey + StoreEventSequence after the work item rows exist. This means a subscriber command chain for a later same-resource event cannot start by bypassing the earlier event's durable fan-out barrier.
Startup DbContext.Model/IModel analysis may be used only for entity/resource name normalization, key-type recognition, convention validation, and early developer-error detection. It must not query application rows or infer affected resources from current database state.
Event payloads are serialized with System.Text.Json using web defaults. Keep event contracts DTO-like: public immutable records or classes, stable property names, no behavior-dependent serialization, and no framework/runtime-only objects in payloads. Plain scalar properties, nested concrete records/classes, List<T>, IReadOnlyList<T>, arrays, and lists such as List<string> do not need custom converters when T is concrete and serializable.
Polymorphic event members need an explicit contract. If an event contains a property such as IReadOnlyList<BaseChange> where items may be different derived types, register the known derived types with System.Text.Json polymorphism metadata, for example [JsonPolymorphic(TypeDiscriminatorPropertyName = "$kind")] plus [JsonDerivedType(typeof(AddressChanged), "address-changed")] on the base type. Use a custom JsonConverter only when the hierarchy is open/plugin-based, the discriminator must match legacy JSON, the base type cannot be annotated, or type selection depends on existing property combinations. Do not rely on Newtonsoft.Json-style automatic type-name handling.
Custom converters are also required for event members that System.Text.Json cannot represent by default: value objects without a supported constructor or converter, non-string dictionary keys that are not built in, external/third-party types with private state only, interface-typed properties without polymorphism metadata, and unsupported runtime types such as System.Type, delegates, Span<T>/ref struct values, DataTable, or DataSet. Prefer changing the event contract to a small explicit DTO before adding a converter.
Do not emit very large affected-resource lists. If an operation exceeds MaxAffectedResourceKeysPerEvent, model it as an explicit LargeBatch flow: use a ProcessManager, durable collection, database table, blob/object storage, or several bounded commands/events instead of one huge payload.
Storage SPI And Topologies
IAnchorFlowStorageProvider coordinates all provider-owned technical stores:
IdempotencyOutboxInboxProcessesDurableCollectionsNotificationsProjectionCheckpointsDeadLettersRetentionUnitOfWork
AnchorFlowStoreKind separates Write and Projection stores. ConfiguredAnchorFlowStoreResolver resolves stores from AnchorFlowStoreDescriptor values:
SharedDatabase: all tenants share one write store and one projection store. Rows still carry tenant metadata.DatabasePerTenant: each tenant has separate write/projection stores. Rows still carry tenant metadata.
Write store tables contain command idempotency, outbox, inbox, process instances/state/expectations/messages, durable collections, process dead letters/locks, and write-side retention markers.
Projection store tables contain projection checkpoints, notification outbox, projection dead letters/locks, and projection-side retention markers. Projection stores intentionally exclude write/process/durable tables.
Provider table naming:
- SQLite uses fixed physical names like
AnchorFlow_Outbox,AnchorFlow_Inbox,AnchorFlow_Idempotency,AnchorFlow_NotificationOutbox, andAnchorFlow_ProjectionCheckpoints. - PostgreSQL uses the
"AnchorFlow"schema with physical names like"AnchorFlow"."Outbox","AnchorFlow"."Inbox","AnchorFlow"."Idempotency","AnchorFlow"."NotificationOutbox", and"AnchorFlow"."ProjectionCheckpoints". - Older prerelease names such as
__framework_*,OutboxMessages,InboxMessages,NotificationOutboxMessages, andCommandIdempotencyare obsolete. Existing prerelease databases need explicit migration or recreation. - Prerelease databases created before resource-aware command/event emission need migration or recreation for the outbox
OutboxSequence,AffectedResourcesJson, andAffectedResourceVersionsJsoncolumns and their provider indexes. The sequence is local to the physical write store; database-per-tenant deployments have one sequence per tenant write database. OutboxSequenceis generated by the database (ValueGeneratedOnAdd, SQLite autoincrement, or PostgreSQL identity). Providers must not synthesize write ordering withMAX() + 1.- Business-event source ordering uses the framework-owned outbox lane table keyed by tenant/system scope, affected-resource lane, and
OutboxSequence. Provider claims use that table for set-based blockers instead of parsing affected-resource JSON on the hot claim path. - Durable subscription work items carry status, worker ownership, lease expiry, attempt counts, retry scheduling, last error text, timestamps, the original event payload needed for recovery, and a concurrency token. Worker runtimes recover committed
pendingrows and dueretry-scheduledrows directly from provider storage without depending on source-event or transport-work-item redelivery.runningwork may be reclaimed afterLockedUntilUtc;retry-scheduledwork is claimable only afterNextAttemptAtUtc;dead-letteredwork is final for its own item and intentionally releases ordering blockers after max retries so later overlapping work may proceed. Dashboard/API management for replay, requeue, ignore, or manual resolution is deferred. - Prerelease databases created before subscription work item recovery metadata was persisted can contain
pendingorretry-scheduledrows without lane, payload, headers, correlation, causation, process/saga, idempotency, affected-resource, and ordering metadata. Those rows cannot be reconstructed by Worker recovery claims. Drain them through the old transport path, replay or recreate the source events, or run an explicit prerelease migration that repopulates the metadata before relying on subscription work item recovery. - Prerelease databases created before tenant-scoped projection checkpoint identity need migration or recreation for
ProjectionCheckpoints. The checkpoint primary key is nowScopeKey + ProjectionName + EventId, whereScopeKeyrepresents tenant scope or system scope. Existing technical tables keyed only byProjectionName + EventIdmust be rebuilt or explicitly migrated before sharing one projection store across tenants with colliding projection/event ids.
Provider table map:
| Store | Logical table | SQLite physical table | PostgreSQL physical table |
|---|---|---|---|
| Write | Idempotency | AnchorFlow_Idempotency |
"AnchorFlow"."Idempotency" |
| Write | Outbox | AnchorFlow_Outbox |
"AnchorFlow"."Outbox" |
| Write | OutboxMessageLanes | AnchorFlow_OutboxMessageLanes |
"AnchorFlow"."OutboxMessageLanes" |
| Write | Inbox | AnchorFlow_Inbox |
"AnchorFlow"."Inbox" |
| Write | ProcessInstances | AnchorFlow_ProcessInstances |
"AnchorFlow"."ProcessInstances" |
| Write | ProcessStateChunks | AnchorFlow_ProcessStateChunks |
"AnchorFlow"."ProcessStateChunks" |
| Write | ProcessMessages | AnchorFlow_ProcessMessages |
"AnchorFlow"."ProcessMessages" |
| Write | ProcessExpectations | AnchorFlow_ProcessExpectations |
"AnchorFlow"."ProcessExpectations" |
| Write | ProcessLocks | AnchorFlow_ProcessLocks |
"AnchorFlow"."ProcessLocks" |
| Write | SubscriptionWorkItems | AnchorFlow_SubscriptionWorkItems |
"AnchorFlow"."SubscriptionWorkItems" |
| Write | SubscriptionWorkItemLanes | AnchorFlow_SubscriptionWorkItemLanes |
"AnchorFlow"."SubscriptionWorkItemLanes" |
| Write | ProcessDeadLetters | AnchorFlow_ProcessDeadLetters |
"AnchorFlow"."ProcessDeadLetters" |
| Write | DurableStateCollections | AnchorFlow_DurableStateCollections |
"AnchorFlow"."DurableStateCollections" |
| Write | DurableStateCollectionChunks | AnchorFlow_DurableStateCollectionChunks |
"AnchorFlow"."DurableStateCollectionChunks" |
| Write | RetentionMarkers | AnchorFlow_RetentionMarkers |
"AnchorFlow"."RetentionMarkers" |
| Projection | NotificationOutbox | AnchorFlow_NotificationOutbox |
"AnchorFlow"."NotificationOutbox" |
| Projection | ProjectionCheckpoints | AnchorFlow_ProjectionCheckpoints |
"AnchorFlow"."ProjectionCheckpoints" |
| Projection | ProjectionLocks | AnchorFlow_ProjectionLocks |
"AnchorFlow"."ProjectionLocks" |
| Projection | ProjectionDeadLetters | AnchorFlow_ProjectionDeadLetters |
"AnchorFlow"."ProjectionDeadLetters" |
| Projection | ProjectionRetentionMarkers | AnchorFlow_ProjectionRetentionMarkers |
"AnchorFlow"."ProjectionRetentionMarkers" |
Provider implementation notes:
- SQLite is for tests, runnable examples, demos, and lightweight single-node scenarios. It uses optimistic row-token claiming and SQLite writer serialization; it is not the high-concurrency production target and should not be treated as the default for realistic local product development.
- PostgreSQL is the production-like provider and the preferred provider for realistic local/dev database work. It uses PostgreSQL 15+ assumptions,
FOR UPDATE SKIP LOCKEDqueue claiming,timestamptz, schema-level table creation, provider-specific indexes, and row-lock based leases. - Provider SQL must be parameterized. Static table names from validated provider metadata are acceptable.
- Provider behavior must pass
AnchorFlowStorageProviderContractTests. - Outbox, notification, projection, subscription-work, and process/projection lease claims must include worker identity, lock duration, and concurrency-token checks. Provider lease storage uses the write-side
ProcessLockstable or projection-sideProjectionLockstable. A crash after publish/send but before mark-dispatched can redeliver; downstream handlers and domain constraints must remain idempotent.
Subscriber Ordering
One emitted event may fan out into multiple subscription work items. Dedupe identity is SubscriptionId + EventId within the tenant/system/store partition. Ordering identity is OrderingSubscriptionId + AffectedResourceKey + StoreEventSequence.
- Different subscribers may run in parallel, even for the same event.
- The same subscriber and same affected resource run serially by sequence.
- The same subscriber and non-overlapping affected resources may run in parallel.
- Work with several affected resources cannot start while earlier pending/running work for the same ordering subscription overlaps any of those resources.
- Source business events are also claimed by affected-resource lane before fan-out, so later overlapping source events do not create subscriber work before earlier source events have completed durable fan-out and source dispatch marking.
- Claimable durable states are
pending, dueretry-scheduled, and expiredrunning.completedanddead-letteredare final for their item; earlierpending,retry-scheduled, orrunningoverlapping work keeps later ordered work blocked, whiledead-letteredearlier work is treated as terminal and non-blocking. SubscriptionGroupandProjectionGroupare the opt-in way to make multiple handlers that mutate the same target state share one ordering identity. Independent handlers should keep independent subscription ids.
Projections
Projection code lives in src/AnchorFlow.Projections.
Developer-facing pieces:
- Implement
IProjection<TEvent>. - Register projection service types with DI before
AddAnchorFlow(...); AnchorFlow auto-discovers concreteIProjection<TEvent>implementations. - Keep projections that mutate the same target state in one projection service when they must share ordering. AnchorFlow discovers each concrete projection service as its own ordering group.
- Register EF read-model DbContexts with
UseProjectionDbContexts(projection => projection.Add<TDbContext>())when projection handlers write through EF Core. - Use
ProjectionProcessorto process one event envelope into one or more projection work items. - Use
ProjectionContextto access envelope metadata, projection name, ordering key, entity id/version, prior progress, transaction hook, and staged notifications. - Mark EF read models with
[AnchorFlowView]metadata when normal projection changes should automatically stage view notifications. - Use
ProjectionViewNotificationExtensions.AddViewNotification(...)only for explicit advanced or low-level notifications that are not backed by a marked read-model change.
Processing model:
ProjectionWorkItemFactorycreates work items from event identity, discovered projection definitions, grouping, tenant, and ordering metadata.ProjectionProcessorasksIProjectionCheckpointStore.TryBeginAsync(...)whether a work item is started/running, duplicate, or old-version.- Started items run inside an
IProjectionTransactionfromIProjectionTransactionFactory. - Projection handlers mutate projection state and stage notifications. They must not call
SaveChanges. DbUpdateConcurrencyExceptionfailures raised while committing managed EF projections are retried according toConfigureProjectionExecution(...).OptimisticConcurrency.- Non-concurrency
DbUpdateExceptionprojection failures are retried only whenConfigureProjectionExecution(...).Retry.TransientFailureClassifierreturnsAnchorFlowRetryDecision.Retry(). - Projection retry delays use the same shared
ConfigureRetryDelays(...)profile as command retries. - Checkpoint completion and notification enqueue happen before transaction commit.
- Duplicate, already-running, or old-version events are skipped idempotently.
- Failures are recorded through checkpoint failure handling.
Provider-backed projection infrastructure:
ProviderProjectionCheckpointStoreadaptsIAnchorFlowProjectionCheckpointStoretoIProjectionCheckpointStoreand requires an explicit nonblankWorkerIdplus a positive lease duration.- Provider-backed checkpoints persist worker ownership,
LockedUntilUtc, attempt/retry metadata, and a concurrency token. Completion and failure recording require the matching claim. - Projection checkpoint identity is scoped by tenant/system scope in shared stores, so the same projection and event id can be processed independently for tenant A, tenant B, and system-level work.
ProviderProjectionTransactionFactorylets projection data, checkpoint completion, and notification outbox work share provider transaction boundaries where the application supplies a real shared transaction.- Strict atomicity between application projection tables and framework checkpoint/notification tables requires the supplied transaction factory to use the same database transaction.
UseProjectionDbContexts(...)installs EF-backed checkpoint, notification outbox, and transaction adapters and enlists resolved managed Projection DbContexts in the projection transaction.- EF projection DbContexts map read-model entities only in the normal path. Projection checkpoints and notification outbox rows are AnchorFlow-owned technical tables created by the provider schema manager.
Removed prerelease projection APIs:
- Applications no longer register projections with
AddProjections(...)or directProjectionRegistrymutation. - Applications no longer route projection DbContexts with
UseProjectionDbContext<TDbContext>(...)plusHandlesProjection(...). - Application code registers projection services before
AddAnchorFlow(...)and registers EF read-model DbContexts with route-freeUseProjectionDbContexts(...).
View Notifications
View-notification dispatch lives in src/AnchorFlow.ViewNotifications.
Contract:
- API/UI initial state is queried from projections.
- EF projection handlers normally update marked read models only; AnchorFlow stages view notifications from those read-model changes.
- Manual
AddViewNotification(...)remains available for explicit advanced or low-level notifications that are not represented by a marked EF read model. - Notifications are persisted to
IAnchorFlowNotificationOutboxStorethroughAnchorFlowViewNotificationOutbox. - Projection-staged notifications use provider-backed notification outbox readiness. There is no UI wait, UI polling, client-side delay, or SignalR delivery workaround in the readiness guarantee.
- When multiple projections handle the causing event, notifications staged by any projection remain in the notification outbox until all expected projections for the same event and scope are terminal successful: applied, duplicate/already-applied, or old-version/skipped. Running or failed projection checkpoints block delivery until retry/success records one of those terminal successful states.
AnchorFlowViewNotificationDispatcherclaims only barrier-ready notification outbox rows, invokes the registeredIAnchorFlowViewNotificationDelivery, and marks rows dispatched.- Wolverine passes its configured runtime
WorkerIdto view-notification dispatch as the provider claim owner. Claims use worker ownership,LockedUntilUtc, attempt counts, retry/dead-letter state, and concurrency tokens. - A delivered view notification means all AnchorFlow-expected projection writes for the causing event and scope have been persisted and the notification outbox row was provider-ready for dispatch, subject to normal at-least-once duplicate delivery semantics.
- View-notification delivery is at-least-once. If app-owned delivery succeeds but marking the outbox row dispatched fails or the process stops, the same durable notification can be delivered again.
- App-owned
IAnchorFlowViewNotificationDeliveryimplementations must make external effects idempotent and duplicate-safe. UseAnchorFlowViewNotificationDeliveryContext.NotificationIdas the primary durable dedupe key, or derive an app key from the context'sRecipientKey, tenant/system scope, store id, andViewNotificationevent/view/resource/operation/sequence fields. - Failed notification dispatches are retried until
MaxDeliveryAttempts, then provider storage records the deterministicnotification:<NotificationId>dead letter and terminal notification mark together so a retry after a partial failure can finish the terminal mark without duplicate-key poisoning. - Handlers and projections must not invoke app transport delivery directly.
Automatic read-model view notifications:
[AnchorFlowView("trains")]
public sealed class TrainReadModel
{
[AnchorFlowViewKey]
public string TrainId { get; set; } = "";
[AnchorFlowViewSequence]
public long Version { get; set; }
public string Name { get; set; } = "";
public int VehicleCount { get; set; }
[AnchorFlowViewIgnore]
public string? InternalComment { get; set; }
}
public sealed class TrainProjection(TrainReadDbContext db)
: IProjection<VehicleRemovedFromTrain>
{
public async ValueTask ProjectAsync(
VehicleRemovedFromTrain @event,
ProjectionContext context,
CancellationToken cancellationToken = default)
{
var train = await db.Trains.SingleAsync(
item => item.TrainId == @event.TrainId,
cancellationToken);
train.Version = @event.Version;
train.VehicleCount--;
}
}
For marked read models, added rows stage an upsert, modified rows stage a patch, and deleted rows stage a remove. Payloads come from changed read-model entries in EF Core's ChangeTracker before projection commit; AnchorFlow does not reload the database in the dispatcher and does not serialize the original domain event as the standard UI payload. By default, mapped scalar public properties are included, [AnchorFlowViewIgnore] properties are excluded, and navigations are not included.
Use a read-model-specific mapper when one view needs a special UI-safe shape:
public sealed class TrainViewModelMapper
: IAnchorFlowViewModelMapper<TrainReadModel>
{
public object Map(TrainReadModel readModel, AnchorFlowViewModelMappingContext context)
{
return new
{
Id = readModel.TrainId,
readModel.Name,
readModel.VehicleCount,
context.Sequence
};
}
}
Register the mapper as IAnchorFlowViewModelMapper<TrainReadModel>. Mappers are keyed by read-model type, not by event type, and they receive event metadata through AnchorFlowViewModelMappingContext only for dedupe, audit, and shaping decisions. They are not event-specific switch points.
Applications own delivery targets. IAnchorFlowViewNotificationDelivery receives a ready ViewNotification; the application resolves SignalR groups, users, connection ids, Redis channels, or gateway topics from tenant id, view, scope, user permissions, and application subscription state:
public sealed class AppViewNotificationDelivery : IAnchorFlowViewNotificationDelivery
{
public Task DeliverAsync(
AnchorFlowViewNotificationDeliveryContext context,
CancellationToken cancellationToken = default)
{
var group = ResolveGroup(
context.Notification.TenantId,
context.Notification.View,
context.Notification.Scope);
return SendToGroupAsync(group, context.Notification, cancellationToken);
}
}
Important classes:
ViewNotificationdescribes view, scope, operation, sequence, event metadata, and payload.ViewNotificationOperationsdefines operation names such as insert/update/upsert/remove/replace/refresh.AnchorFlowViewNotificationConventionsdefines the channel and payload type expected by the dispatcher.AnchorFlowViewAttribute,AnchorFlowViewKeyAttribute,AnchorFlowViewSequenceAttribute, andAnchorFlowViewIgnoreAttributeopt EF read models into automatic notification payloads.IAnchorFlowViewModelMapper<TReadModel>maps one marked read-model type to a custom UI payload.- Applications own transport-specific delivery, including any SignalR, Redis, Azure or gateway integration.
RecordingViewNotificationDeliveryin examples is a test/demo delivery service.
Process Manager
Process manager code lives in src/AnchorFlow.ProcessManager.
Developer model:
public sealed class ImportProcess
: ProcessManager<ImportProcessState, ImportStarted>
{
public override ProcessId GetProcessId(ImportStarted startEvent)
{
return new ProcessId(startEvent.ImportId);
}
public ValueTask<Transition> When(
ImportStarted startEvent,
ImportProcessState state,
ProcessContext ctx)
{
state.Status = "running";
state.ProgressId = startEvent.ProgressId;
foreach (var typeRun in startEvent.TypeRuns)
{
ctx.PublishCommand(new ImportTypeRunCommand(
startEvent.ImportId,
startEvent.Tenant,
typeRun.TypeRunKey.Value,
typeRun.TypeRunKey.Value + "-chunk-001",
typeRun.ItemCount))
.WithExpectationKey(new AnchorFlowProcessExpectationKey(typeRun.TypeRunKey.Value))
.Expect<ImportTypeRunResult>();
}
return ValueTask.FromResult(ctx.Transition);
}
public ValueTask<Transition> When(
ImportTypeRunResult reply,
ImportProcessState state,
ProcessContext ctx)
{
state.SucceededCount += reply.SucceededCount;
state.RejectedCount += reply.RejectedCount;
ctx.Progress.Report(
new ProgressId(state.ProgressId),
new AnchorFlowProgressSectionKey(reply.TypeRunKey),
reply.SucceededCount,
reply.RejectedCount,
processed: reply.ProcessedCount);
return ValueTask.FromResult(ctx.Transition);
}
}
Rules:
- A process starts from a dedicated start event. The start event does not need a marker interface, but it must not be a command.
GetProcessId(TStartEvent)must be deterministic.When(...)methods mutate typed state and schedule commands/timeouts. They do not do business work directly.ctx.PublishCommand(command).Expect<TReply>()records one process-owned command and one expected reply using the configured default expected-reply timeout. Use it for workflow-relevant process-owned commands whose business result should advance process state. This is separate from normal application command publishing throughICommandBus.PublishAsync(...)..WithTimeout(...)overrides the default timeout for one expected reply..WithoutTimeout()intentionally opts that one command out of timeout feedback.- A transition may publish several process-owned commands. Give each command a distinct
ProcessCommandIdand, when the app needs item/chunk correlation, an app-ownedAnchorFlowProcessExpectationKeythroughWithExpectationKey(...). - Business success or business failure comes from the expected typed reply/result. Technical failure feedback arrives as
ProcessCommandFailed; timeout feedback arrives asProcessCommandTimedOut. Expect<TReply>()plusWithExpectationKey(...)is best practice for grouped or parallel process-owned commands that affect workflow state. It is not mandatory framework enforcement:FireAndForget()remains appropriate when command completion does not affect the process.- Multiple process-owned commands are not one database transaction. Model all-or-none business workflows as sagas: track terminal state per command in process state, verify unknown commit outcomes when necessary, and publish compensation commands only for steps that committed successfully.
- Progress reported through
ctx.Progressis committed atomically with process state, durable collections, process commands, expectations, outbox rows, and inbox completion. ctx.Send.Command(command)andSend.Command(command)remain legacy wrappers for the process-owned publish-command API.- Replies route only when
ProcessId,SagaId, process headers, message type, and expected step match. - Process transitions are sequential per process instance.
- Duplicate and unexpected messages are handled through inbox/process records and
ProcessUnexpectedMessagePolicy.
Runtime model:
ProcessManagerRegistryvalidates registrations, start event ownership, startWhen(...), replyWhen(...), and expected reply methods.ProcessManagerRuntimeroutes starts/replies/timeouts, acquires process-local concurrency protection, acquires provider leases when enabled, starts inbox records, loads typed state, invokesWhen(...), writes state chunks, persists outbox messages and expectations, completes inbox records, and commits provider unit of work.ProcessManagerRuntimeOptions.DefaultExpectedReplyTimeoutdefaults to 30 minutes and must be positive. NormalAddAnchorFlow(...)worker setup exposes the same value atworker.ProcessManagers.DefaultExpectedReplyTimeout.ProcessManagerRuntimeOptions.UseLeasesdefaults totrue. Worker runtimes must provide a stableWorkerId; no-lease execution is only for explicit local/test single-worker setups that also allow running without leases.ProcessStateStore<TState>can execute manual state transitions with typed state plus durable collection bindings; it persists manual expectations without scheduling delayed timeout feedback.ProcessManagerOperationsexposes operational state changes such asMarkRunningAsync;ContinueAsyncis status-only.
ProcessManager worker timeout defaults can be configured with the normal role options:
options.RunWorker(worker =>
{
worker.Id = workerId;
worker.ProcessManagers.DefaultExpectedReplyTimeout = TimeSpan.FromMinutes(10);
});
Durable state:
- One typed state exists per process instance.
- State is persisted as generic JSON chunks in framework-owned process state storage.
- Large data should live in
DurableList<T>,DurableDictionary<TKey,TValue>, orDurableBlob<T>, backed by generic durable collection tables. - Do not create process/import-specific framework tables.
Runtime Roles And Transport
The normal runtime and transport setup is configured through AddAnchorFlow(...) options:
services.AddAnchorFlow(options =>
{
options.UsePostgreSql();
options.UseSharedDatabase(writeConnectionString, projectionConnectionString);
options.UseWriteStoreEfCore(write =>
{
write.UseConnection(
(serviceProvider, store) => new NpgsqlConnection(store.ConnectionInfo),
(serviceProvider, connection, builder) =>
builder.UseNpgsql((NpgsqlConnection)connection));
});
options.UseWriteDbContexts(write => write.Add<AppDbContext>());
options.UseRabbitMq(rabbitMqConnectionString);
options.RunWorker("orders-worker-01");
});
Local developer hosts may run API, Worker, and NotificationDispatcher behavior in one process while still using the same real store and tenant configuration that separated roles use:
services.AddAnchorFlow(options =>
{
options.UseSqlite(writeConnectionString, projectionConnectionString);
options.UseWriteDbContexts(write => write.Add<AppDbContext>());
options.UseLocalTransport();
options.RunApiWorkerAndNotificationDispatcher(runtime =>
{
runtime.Worker.Id = "orders-local-dev";
runtime.Worker.MessagePublishing.MaxConcurrent = 1;
runtime.Worker.AsyncProcessing.MaxConcurrent = 4;
runtime.NotificationDispatcher.Id = "orders-local-dev";
runtime.NotificationDispatcher.NotificationDelivery.MaxConcurrent = 4;
});
});
For realistic local development, bind application DbContexts and AnchorFlow providers to real local/dev databases, preferably PostgreSQL when validating production-like leases, claims, checkpoints, and transaction behavior. UseLocalTransport() only means the local host does not need RabbitMQ or Azure Service Bus; it does not imply SQLite storage.
The combined local modes do not change command transactions, outbox/inbox behavior, provider leases, projection checkpointing, notification dispatch, or ProcessManager semantics. Worker and NotificationDispatcher roles require stable nonblank ids, and each id must be unique for concurrently running instances. For production-like role testing, run separate processes explicitly: one configured with RunApi(), one with RunWorker("..."), and one with RunNotificationDispatcher("...").
The same application binary or Docker image can be used for all production roles. The host application's configuration layer decides whether to call RunApi(), RunWorker(...), or RunNotificationDispatcher(...); AnchorFlow does not inspect Kubernetes labels, pod metadata, environment variables, or machine names. A common Kubernetes setup exposes metadata.uid through the Downward API, reads it through normal application configuration, and passes that value as the Worker or NotificationDispatcher id.
Runtime roles:
RunApi(): API host accepts commands, commits write-store changes and outbox rows, and may perform bounded immediate post-commit flush. It does not start queue listeners, fallback sweepers, projections, notification dispatch, process manager workers, or retention loops.RunWorker("worker-id"): Worker host starts transport listeners and owns fallback outbox sweeps, subscription work, projections, process manager work, scheduled work, and retention. It does not dispatch view notifications.RunNotificationDispatcher("dispatcher-id"): Notification Dispatcher host claims provider-ready notification outbox rows after projection transaction commit and projection-event barrier release, then invokes the registered app-ownedIAnchorFlowViewNotificationDelivery.RunApiAndWorker("worker-id"): local/dev host that combines both roles with an explicit worker id.RunApiWorkerAndNotificationDispatcher("runtime-id"): local/dev host that combines API, Worker, and NotificationDispatcher capabilities for runnable examples and single-process local validation.
Role option overloads:
options.RunWorker(worker =>
{
worker.Id = workerId;
worker.MessagePublishing.MaxConcurrent = 1;
worker.MessagePublishing.ReclaimAfter = TimeSpan.FromMinutes(2);
worker.MessagePublishing.RetryDelay = TimeSpan.FromSeconds(30);
worker.AsyncProcessing.MaxConcurrent = 4;
worker.AsyncProcessing.MaxConcurrentPerTenant = 2;
worker.AsyncProcessing.MaxConcurrentPerSubscription = 1;
worker.AsyncProcessing.ReclaimAfter = TimeSpan.FromMinutes(2);
});
options.RunNotificationDispatcher(dispatcher =>
{
dispatcher.Id = dispatcherId;
dispatcher.NotificationDelivery.MaxConcurrent = 4;
dispatcher.NotificationDelivery.ReclaimAfter = TimeSpan.FromSeconds(30);
});
These settings are per process/pod. Kubernetes replicas multiply total capacity, so initial values should leave CPU, memory, database connections, broker credits, and downstream notification capacity headroom. For 2 vCPU / 2 GB pods, start Worker pods with MessagePublishing.MaxConcurrent = 1 and AsyncProcessing.MaxConcurrent = 4, and start NotificationDispatcher pods with NotificationDelivery.MaxConcurrent = 4.
For MessagePublishing, ReclaimAfter and RetryDelay are separate operational knobs. ReclaimAfter is the claim lease duration: if a process crashes or cannot actively release a claim, another Worker can reclaim the row after the lease expires. RetryDelay is the scheduled wait after a non-cancellation publish failure before the next attempt is due. Shortening retry delay does not shorten crash lease expiry, and shortening reclaim time does not make failed rows immediately due for retry.
Transport modes:
UseLocalTransport()for examples and local single-process execution without RabbitMQ or Azure Service Bus.UseRabbitMq(connectionString)for RabbitMQ.UseAzureServiceBus(connectionString)for Azure Service Bus.
Low-Level And Existing-Host APIs:
UseWriteDbContext<TDbContext>(...),Handles<TCommand>(), andHandleCommandsFromAssembly(...)are the existing route-based command APIs. New beginner docs and examples should use provider shortcuts withUseWriteDbContexts(...).AddAnchorFlow<TDbContext>(...)remains available for existing single-DbContext hosts.AddAnchorFlowEntityFrameworkCommandBus<TDbContext>()remains available for focused tests, custom registration, and runtime internals. It binds one EF command DbContext and does not model multi-DbContext command routing.AddAnchorFlowWolverine(...),AnchorFlowWolverineOptions, and directUseMessaging(...)remain available for advanced tests and low-level adapter work.UseLocalWolverineDefaults(...), Wolverine RabbitMQ options, and Wolverine Azure Service Bus options are implementation-level APIs. They are not required in beginner setup.- Existing low-level store descriptors and provider SPI types remain available for custom registry/provisioning work, but normal applications should use provider shortcuts where available, or
UseSharedDatabase(...),UseDatabasePerTenant(...), andUseTenants(...)for custom topology setup.
Operational details:
- Durable outbox is always the correctness boundary. Brokers are performance/distribution infrastructure, not a replacement for the outbox.
- Immediate flush is a latency fast path after commit. If it fails without
FailRequest, the command remains successful and worker fallback recovers pending rows. MessagePublishingclaims and publishes pending committed messages for Worker hosts.AsyncProcessinghandles durable asynchronous work for normal subscribers, projections, and ProcessManagers.NotificationDeliveryclaims committed view notifications and invokes the registered app-owned delivery implementation.- An EventSubscriber may publish one follow-up command through
ICommandBus.PublishAsync(...). If that command has no explicit idempotency key, AnchorFlow derives one fromSubscriptionId, event id, and command type; multiple follow-up commands, waiting, timeouts, expected replies, or orchestration steps belong in a ProcessManager. - Default topology uses bounded logical lanes:
main,projection,notification, andprocess. Tenant id is metadata, not part of default queue names. - Friendly work names map to AnchorFlow durable infrastructure internally, including outbox rows, async work items, projection checkpoints, notification outbox rows, and transport backpressure. Normal application configuration does not require internal table names or Wolverine internals.
AnchorFlowWorkerLifecycleAdaptercloses the central runtime gate on host shutdown. During graceful shutdown or scale-in, Worker and NotificationDispatcher paths stop creating new claims and actively release unfinished runtime claims for outbox messages, durable async work, projection checkpoints, and notification outbox rows when durable completion was not recorded. Active release lets another runtime instance reclaim the work before the original lease would expire. If the process crashes or release cannot complete, passive lease expiry throughReclaimAfterremains the recovery fallback.- Outbox and notification delivery are at-least-once. If a Worker or NotificationDispatcher releases work after an external broker send or app-owned notification delivery succeeds but before the durable dispatched mark is recorded, a later runtime may deliver the same durable item again. Downstream consumers, delivery targets, and domain constraints must stay idempotent.
AnchorFlowWolverineWorkerStartupValidatorverifies that enabled worker services have the required runtime plan and DI registrations before the worker host starts processing. Worker event fan-out with registered subscriptions requires anIAnchorFlowStorageProviderthat can also persistIAnchorFlowSubscriptionWorkItemStorepending rows; low-level Wolverine configurations that cannot provide that durable subscription work-item persistence fail before subscription work-item messages are published.- Health checks separate liveness, API readiness, and Worker readiness. Worker readiness is stricter and normally requires stores plus configured transport readiness.
ASP.NET Core Integration
ASP.NET Core support lives in src/AnchorFlow.AspNetCore.
- Tenant resolution uses
AspNetCoreTenantResolverandAspNetCoreTenantResolverOptions. - Health registration maps
IAnchorFlowRuntimeHealthMonitorto liveness, API readiness, and Worker readiness checks. - API readiness requires write-store resolution for the configured readiness tenant.
- Worker readiness requires configured work stores and transport readiness unless explicitly relaxed.
Request tenant setup is host-application responsibility. In a typical ASP.NET Core API, application middleware reads and validates the tenant from JWT claims, headers, route values, or an application tenant service, then sets the AnchorFlow ambient tenant for the rest of the request:
app.Use(async (httpContext, next) =>
{
var tenantAccessor = httpContext.RequestServices.GetRequiredService<ITenantContextAccessor>();
var applicationTenantGuid = ResolveTenantGuidFromJwt(httpContext);
using var tenantScope = tenantAccessor.BeginScope(
TenantContext.ForTenant(new TenantId(applicationTenantGuid.ToString("D")), "jwt"));
await next();
});
After this point, application code can publish commands without putting the tenant id into every command constructor, as long as the command does not intentionally target a different explicit tenant. AnchorFlow command execution will use the ambient tenant and will reject mismatches between an explicit command/options tenant and the current ambient tenant.
Worker execution is different: Worker hosts do not have an HTTP request or JWT tenant. The tenant id has already been captured in the command envelope and outbox records by the API-side command transaction. When the Worker claims outbox, subscription, projection, notification, consumer, or process-manager work, AnchorFlow rehydrates the ambient tenant from the persisted message metadata for that work item before invoking user handlers.
Examples
Examples are part of the implementation contract. Keep them compile-tested and runnable without PostgreSQL, RabbitMQ, Azure Service Bus, Redis, Azure SignalR Service, or external SignalR servers. Example application code must use public AnchorFlow APIs through AddAnchorFlow(...), ICommandBus, events, subscribers, projections, notifications, and ProcessManagers; do not copy provider outbox/inbox/envelope internals into examples.
All retained examples are ASP.NET Core hosts. Each example owns appsettings.json and Options binding, then maps those host-owned values into AnchorFlow options such as SQLite file names, local transport, tenants, and runtime role ids. AnchorFlow still does not read IConfiguration or appsettings.json directly. The examples use local SQLite and local transport, accept the X-AnchorFlow-Tenant header where multiple tenants are configured, and expose expected business outcomes as HTTP result values instead of exception-driven business flow.
The run commands below start long-running web hosts. For validation after dotnet build examples/AnchorFlow.Examples.sln -c Release --no-restore, add --no-build before -- --urls ....
| Example | Demonstrates | Run host | Smoke endpoints |
|---|---|---|---|
examples/BasicCrud |
Beginner ASP.NET Core AddAnchorFlow(...) setup with managed CustomersDbContext, host-owned Options, SQLite/local transport, ICommandBus.PublishAsync(...), strict business events, command results, and subscriber-visible audit output. |
dotnet run --project examples/BasicCrud/AnchorFlow.Examples.BasicCrud.csproj -c Release -- --urls http://127.0.0.1:5102 |
GET /health/live, GET /api/basic-crud/status, POST /api/basic-crud/customers, GET /api/basic-crud/customers/{customerId}/welcome-notification |
examples/BulkOperations |
ASP.NET Core bulk command API over a managed InventoryDbContext, strict many-resource events, subscriber audit output, result responses, and SQLite/local configuration. |
dotnet run --project examples/BulkOperations/AnchorFlow.Examples.BulkOperations.csproj -c Release -- --urls http://127.0.0.1:5103 |
GET /health/live, GET /api/bulk-operations/status, POST /api/bulk-operations/archive, GET /api/bulk-operations/audits/{auditId} |
examples/AnchorFlow.Examples.DddOrders |
DDD aggregate HTTP API with root-scoped aggregate events, automatic aggregate event collection from a managed OrdersDbContext, subscriber-visible order audit output, result responses, and grouped bulk-order saga compensation. |
dotnet run --project examples/AnchorFlow.Examples.DddOrders/AnchorFlow.Examples.DddOrders.csproj -c Release -- --urls http://127.0.0.1:5108 |
GET /health/live, GET /api/ddd-orders/status, POST /api/ddd-orders/orders, GET /api/ddd-orders/bulk-submissions/{bulkId}/progress |
examples/MissionsProperties |
ASP.NET Core Mission/Property command API with entity-owned evaluation, public events, subscriber audit rows, tenant-scoped reads, and result-pattern responses. | dotnet run --project examples/MissionsProperties/AnchorFlow.Examples.MissionsProperties.csproj -c Release -- --urls http://127.0.0.1:5105 |
GET /health/live, GET /api/missions-properties/status, POST /api/missions-properties/missions/{missionId}/properties/{propertyId}/status, GET /api/missions-properties/audits |
examples/MissionsImportProcessManager |
ProcessManager-driven Mission import API using application commands, expected replies, SQLite/local services, single-tenant local API+Worker execution, and process-visible batch progress. | dotnet run --project examples/MissionsImportProcessManager/AnchorFlow.Examples.MissionsImportProcessManager.csproj -c Release -- --urls http://127.0.0.1:5106 |
GET /health/live, GET /api/missions-import-process-manager/status, POST /api/missions-import/imports, GET /api/missions-import/imports/{importId}/state |
examples/MultiTenantCrud |
Tenant-scoped ASP.NET Core command API with shared and database-per-tenant SQLite routing, tenant-isolated projections, notifications, and application-visible order cards. | dotnet run --project examples/MultiTenantCrud/AnchorFlow.Examples.MultiTenantCrud.csproj -c Release -- --urls http://127.0.0.1:5192 |
GET /health/live, GET /api/multi-tenant-crud/status, GET /api/multi-tenant-crud/topology, POST /api/multi-tenant-crud/orders with X-AnchorFlow-Tenant |
examples/ProjectionsAndNotifications |
ASP.NET Core ticket API with command-published events, projection read models, staged view notifications, recording app-owned delivery output, and SQLite/local API+Worker+NotificationDispatcher execution. | dotnet run --project examples/ProjectionsAndNotifications/AnchorFlow.Examples.ProjectionsAndNotifications.csproj -c Release -- --urls http://127.0.0.1:5191 |
GET /health/live, GET /api/projections-notifications/status, POST /api/projections-notifications/tickets, GET /api/projections-notifications/notifications |
examples/ProcessManagerImport |
ASP.NET Core multi-type import API using ProcessManager, DurableList<T>, Customers/Vehicles/Missions/MissionProperties sections, app-owned typed keys, generic progress snapshots, expected replies, operation results, and saga compensation. |
dotnet run --project examples/ProcessManagerImport/AnchorFlow.Examples.ProcessManagerImport.csproj -c Release -- --urls http://127.0.0.1:5107 |
GET /health/live, GET /api/process-manager-import/status, POST /api/process-manager-import/imports, GET /api/process-manager-import/imports/{importId}/progress |
Tests And Validation
Baseline product validation:
dotnet restore AnchorFlow.sln
dotnet build AnchorFlow.sln -c Release --no-restore
dotnet test AnchorFlow.sln -c Release --no-build -m:28 -- RunConfiguration.MaxCpuCount=28 NUnit.NumberOfTestWorkers=28
Examples validation:
dotnet restore examples/AnchorFlow.Examples.sln
dotnet build examples/AnchorFlow.Examples.sln -c Release --no-restore
dotnet test examples/AnchorFlow.Examples.sln -c Release --no-build -m:28 -- RunConfiguration.MaxCpuCount=28 NUnit.NumberOfTestWorkers=28
# Start each host with a bounded automation wrapper, wait for /health/live, run the listed HTTP smoke endpoints,
# then stop the host. Example host command shape after the Release build:
dotnet run --project examples/BasicCrud/AnchorFlow.Examples.BasicCrud.csproj -c Release --no-build -- --urls http://127.0.0.1:5102
Invoke-RestMethod -Method Get -Uri http://127.0.0.1:5102/health/live
Invoke-RestMethod -Method Get -Uri http://127.0.0.1:5102/api/basic-crud/status
dotnet run --project examples/BulkOperations/AnchorFlow.Examples.BulkOperations.csproj -c Release --no-build -- --urls http://127.0.0.1:5103
Invoke-RestMethod -Method Get -Uri http://127.0.0.1:5103/health/live
Invoke-RestMethod -Method Get -Uri http://127.0.0.1:5103/api/bulk-operations/status
dotnet run --project examples/AnchorFlow.Examples.DddOrders/AnchorFlow.Examples.DddOrders.csproj -c Release --no-build -- --urls http://127.0.0.1:5108
Invoke-RestMethod -Method Get -Uri http://127.0.0.1:5108/health/live
Invoke-RestMethod -Method Get -Uri http://127.0.0.1:5108/api/ddd-orders/status
dotnet run --project examples/MissionsProperties/AnchorFlow.Examples.MissionsProperties.csproj -c Release --no-build -- --urls http://127.0.0.1:5105
Invoke-RestMethod -Method Get -Uri http://127.0.0.1:5105/health/live
Invoke-RestMethod -Method Get -Uri http://127.0.0.1:5105/api/missions-properties/status
dotnet run --project examples/MissionsImportProcessManager/AnchorFlow.Examples.MissionsImportProcessManager.csproj -c Release --no-build -- --urls http://127.0.0.1:5106
Invoke-RestMethod -Method Get -Uri http://127.0.0.1:5106/health/live
Invoke-RestMethod -Method Get -Uri http://127.0.0.1:5106/api/missions-import-process-manager/status
dotnet run --project examples/MultiTenantCrud/AnchorFlow.Examples.MultiTenantCrud.csproj -c Release --no-build -- --urls http://127.0.0.1:5192
Invoke-RestMethod -Method Get -Uri http://127.0.0.1:5192/health/live
Invoke-RestMethod -Method Get -Uri http://127.0.0.1:5192/api/multi-tenant-crud/status
dotnet run --project examples/ProjectionsAndNotifications/AnchorFlow.Examples.ProjectionsAndNotifications.csproj -c Release --no-build -- --urls http://127.0.0.1:5191
Invoke-RestMethod -Method Get -Uri http://127.0.0.1:5191/health/live
Invoke-RestMethod -Method Get -Uri http://127.0.0.1:5191/api/projections-notifications/status
dotnet run --project examples/ProcessManagerImport/AnchorFlow.Examples.ProcessManagerImport.csproj -c Release --no-build -- --urls http://127.0.0.1:5107
Invoke-RestMethod -Method Get -Uri http://127.0.0.1:5107/health/live
Invoke-RestMethod -Method Get -Uri http://127.0.0.1:5107/api/process-manager-import/status
Package validation:
dotnet pack AnchorFlow.sln -c Release --no-build
PostgreSQL tests configure their own Testcontainers-backed PostgreSQL instance:
dotnet test AnchorFlow.sln -c Release --no-build -m:28 -- RunConfiguration.MaxCpuCount=28 NUnit.NumberOfTestWorkers=28
dotnet test tests/AnchorFlow.PostgreSql.Tests/AnchorFlow.PostgreSql.Tests.csproj -c Release --no-build -m:28 -- RunConfiguration.MaxCpuCount=28 NUnit.NumberOfTestWorkers=28
PostgreSQL provider tests start a postgres:16-alpine Testcontainers instance from the test fixture. Docker/Testcontainers must be available; otherwise the PostgreSQL tests fail explicitly instead of silently skipping provider coverage. Tests must not read database connection strings from environment variables.
Test selection guidance:
- Command, consumer, outbox, inbox, notification, retention, dead-letter, process, projection, provider, or public API changes need targeted tests close to the changed package plus broader solution validation when feasible.
- Example API changes need an examples solution build and runnable checks for touched examples when feasible.
- Provider behavior needs the relevant provider contract tests.
- EF query changes that rely on SQL translation need relational/provider-backed validation. Do not treat EF Core
InMemoryas proof of SQL behavior. - Runtime reliability changes should keep the static audits green: no framework runtime role selection from
Environment.GetEnvironmentVariable,APP_ROLE, orEnvironment.MachineName, and no provider ordering based onMAX() + 1. - Keep tests deterministic. Prefer controllable clocks, deterministic IDs, and test schedulers over sleeps.
Build, Packaging, And Versioning
Central settings:
Directory.Build.propstargetsnet10.0, sets package metadata, maps package descriptions per project name, and packs the rootREADME.mdas the NuGet package readme for every package.Directory.Packages.propsowns package versions.- Packages are written to
artifacts/packages/Releaseby default.
Version defaults:
VersionPrefix=0.1.0VersionSuffix=alpha.2
Override package version at pack time:
dotnet pack AnchorFlow.sln -c Release -p:VersionPrefix=0.1.0 -p:VersionSuffix=alpha.2
dotnet pack AnchorFlow.sln -c Release -p:VersionPrefix=0.1.0 -p:VersionSuffix=
Release script:
.\eng\release.ps1 -Configuration Release -VersionPrefix 0.1.0 -VersionSuffix alpha.2
.\eng\release.ps1 -Configuration Release -VersionPrefix 0.1.0 -VersionSuffix "" -Publish -NuGetSource $env:NUGET_SOURCE -ApiKey $env:NUGET_API_KEY
Implementation Rules For Agents
Before editing:
- Run
git status --porcelain=v1 --untracked-files=all. - If the workspace is dirty, do not overwrite user changes. Keep edits scoped and report only files you changed.
- For planned architecture work, read the relevant files under
AgentPlans/<FeatureName>, especially00_Master_Goal_Orchestration.md,01_Shared_Architecture_Contract.md,02_Shared_Validation_Contract.md, story contracts, state files, guard findings, validation ledgers, and final audit files.
Coding rules:
- Preserve package boundaries.
- Keep public APIs small, documented, and consistent.
- Product-code public APIs need XML documentation. Implementing members should use
<inheritdoc />when directly implementing documented contracts. - Keep cancellation tokens in async APIs and pass them through.
- Preserve
ConfigureAwait(false)patterns in library code. - Follow current style: block-scoped namespaces are common, explicit
this.is common, sealed classes are preferred where inheritance is not intended, and one type per file is required. - Do not return tuples from public/protected APIs. Use a named result type.
- Remove unused usings, dead code, and warnings introduced by the change.
Architecture rules:
- Commands and consumers own transaction boundaries through the pipeline, not through handlers.
- Do not call brokers or view-notification transport delivery directly from command handlers, consumers, or projections.
- Outbox/inbox behavior must remain transactional and idempotent.
- Projection handlers stage notifications and let the runtime commit. They do not call
SaveChanges. - View notifications must go through the notification outbox.
- Process managers orchestrate state and commands/replies; they do not execute business work directly.
- Framework storage must stay generic and tenant-scoped.
- Retention, dead-letter, leases, claiming, idempotency, and checkpoints must preserve tenant/system partitioning.
- AnchorFlow framework runtime code must not choose API/Worker/NotificationDispatcher role or production runtime identity from environment variables. Host apps and examples may read configuration or environment and map it into simplified direct role options such as
RunApi(),RunWorker(...),RunNotificationDispatcher(...),RunApiAndWorker(...), andRunApiWorkerAndNotificationDispatcher(...).
EF and SQL rules:
- In EF-backed packages/examples/tests, prefer EF Core LINQ in fluent style unless provider-specific SQL is the purpose of the package.
- Before changing non-trivial EF queries, inspect entity CLR types, store types, conversions, owned/JSON mapping, and translation risks.
- Be careful with typed IDs and converted properties in queries. Equality may translate; ordering/range comparisons and
.Value,.ToString(),CompareTo,Guidhelpers, andStringComparisonare fragile unless verified. - Provider SQL must be parameterized.
Useful implementation anchors:
- Command workflow:
EntityFrameworkCommandPipeline<TDbContext>and tests intests/AnchorFlow.EntityFrameworkCore.Tests/Messaging/EntityFrameworkCommandPipelineTests.cs. - Consumer workflow:
EntityFrameworkConsumerPipeline<TDbContext>and tests intests/AnchorFlow.EntityFrameworkCore.Tests/Messaging/EntityFrameworkConsumerPipelineTests.cs. - Provider contract:
AnchorFlowStorageProviderContractTestsplus SQLite/PostgreSQL wrappers. - Projection workflow:
ProjectionProcessorandtests/AnchorFlow.Projections.Tests/ProjectionProcessorTests.cs. - View-notification dispatch:
AnchorFlowViewNotificationDispatcherandtests/AnchorFlow.ViewNotifications.Tests. - Process manager runtime:
ProcessManagerRuntime,ProcessStateStore<TState>, andtests/AnchorFlow.ProcessManager.Tests. - Wolverine dispatch/runtime:
AnchorFlowWolverine*classes andtests/AnchorFlow.Wolverine.Tests. - Storage topology:
TenantKey,AnchorFlowTenantRegistryEntry,AnchorFlowTenantStoreRegistration,ConfiguredAnchorFlowTenantRegistry,ConfiguredAnchorFlowStoreResolver,IAnchorFlowCurrentStore,AnchorFlowTenantProvisioner,tests/AnchorFlow.Core.Tests/Storage/ConfiguredAnchorFlowStoreResolverTests.cs,tests/AnchorFlow.Core.Tests/Storage/AnchorFlowTenantProvisionerTests.cs, andtests/AnchorFlow.EntityFrameworkCore.Tests/Storage/CurrentStoreDbContextRoutingTests.cs. - Runnable behavior references: examples listed above.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- AnchorFlow.Core (>= 0.1.0-alpha.2)
- AnchorFlow.PostgreSql (>= 0.1.0-alpha.2)
- AnchorFlow.ProcessManager (>= 0.1.0-alpha.2)
- AnchorFlow.Progress (>= 0.1.0-alpha.2)
- AnchorFlow.Projections (>= 0.1.0-alpha.2)
- AnchorFlow.Sqlite (>= 0.1.0-alpha.2)
- AnchorFlow.ViewNotifications (>= 0.1.0-alpha.2)
- AnchorFlow.Wolverine (>= 0.1.0-alpha.2)
- AnchorFlow.Wolverine.AzureServiceBus (>= 0.1.0-alpha.2)
- AnchorFlow.Wolverine.RabbitMq (>= 0.1.0-alpha.2)
- Microsoft.EntityFrameworkCore (>= 10.0.9)
- Microsoft.EntityFrameworkCore.Relational (>= 10.0.9)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.9)
- Microsoft.Extensions.Hosting.Abstractions (>= 10.0.9)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on AnchorFlow.EntityFrameworkCore:
| Package | Downloads |
|---|---|
|
AnchorFlow.EntityFrameworkCore.Sqlite
SQLite EF Core shortcuts for configuring AnchorFlow storage provider, topology and managed DbContext defaults with connection strings. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.1.0-alpha.2 | 41 | 7/7/2026 |
| 0.1.0-alpha.1 | 60 | 7/4/2026 |
Initial prerelease packaging contract for AnchorFlow 0.1.0 alpha packages.