TCIS.Pluggable.Abstractions 1.1.3

dotnet add package TCIS.Pluggable.Abstractions --version 1.1.3
                    
NuGet\Install-Package TCIS.Pluggable.Abstractions -Version 1.1.3
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="TCIS.Pluggable.Abstractions" Version="1.1.3" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="TCIS.Pluggable.Abstractions" Version="1.1.3" />
                    
Directory.Packages.props
<PackageReference Include="TCIS.Pluggable.Abstractions" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add TCIS.Pluggable.Abstractions --version 1.1.3
                    
#r "nuget: TCIS.Pluggable.Abstractions, 1.1.3"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package TCIS.Pluggable.Abstractions@1.1.3
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=TCIS.Pluggable.Abstractions&version=1.1.3
                    
Install as a Cake Addin
#tool nuget:?package=TCIS.Pluggable.Abstractions&version=1.1.3
                    
Install as a Cake Tool

TCIS.Pluggable.Abstractions

The contract package of the TCIS Pluggable architecture. It defines pipeline steps, business rules, site modules, and connection-level tenant isolation.

Zero infrastructure dependencies. No ASP.NET Core, no EF Core, no database provider. A plugin only needs a reference to this package plus the Platform Core it extends.


Table of contents

Section Contents
1 Pipeline contracts
2 Business rules
3 Site modules
4 Returning a pipeline result to the caller
5 Connection-level tenant isolation
6 ⚠️ Invariants you must not break
7 Pitfalls

1. Pipeline contracts

IPipelineContext — the bag that travels through a run

Member Type Meaning
Status PipelineStatus (get) Succeeded, Failed or Halted — derived from errors and the halt signal, see below
IsSuccess bool (get) Status == Succeeded: no error and not halted. true while a run is in progress and nothing went wrong
IsFailed bool (get) Status == Failed — use this to ask "is there an error?"; !IsSuccess no longer means that
ShouldStop bool (get) Halted, or Failed without ContinueOnError. Use it in any loop a step runs itself
HaltInfo PipelineHalt? (get) Code, Reason, Key, Source of the halt, or null
IgnoredSignals IReadOnlyList<string> Terminal signals dropped because another one came first; the engine logs them as warnings
Errors IReadOnlyList<PipelineError> Structured errors, each a Key + Message
State ConcurrentDictionary<string, object> Shared blackboard between steps. Key comparison is case-insensitive
ContinueOnError bool (get/set) false = fail fast (default). true = keep going, for batch imports
CurrentItemKey string? Key of the item being processed; Fail(message) attaches the error to it
SiteCode string? A convenience copy written by the engine. Not an input channel — see section 6
Fail(params string[]) Records errors against CurrentItemKey, or "GLOBAL" when unset
Fail(string key, string message) Records an error against a specific key
Halt(code, reason, key?) Stops the pipeline early without an error — see below
MarkHaltSource(source) Framework use: fills HaltInfo.Source if still empty
Success() Clears errors, the halt and the ignored signals. The engine never calls it

IPipelineContext<TResponse> adds TResponse? Result { get; set; } for pipelines that return data.

Status is derived from the errors and the halt signal, not a flag — so a context can never end up "holding errors but reporting success". Errors are stored in a ConcurrentQueue, which keeps it safe when a step fans out internally.

Halt — stopping early without an error

Some outcomes are neither success nor failure: the item is not ready yet and must be retried later, or a newer item has superseded it. Halt is that third outcome:

if (!await vesselList.ContainsAsync(context.ContainerNo, ct))
{
    context.Halt("EDO.PENDING", $"Container {context.ContainerNo} is not on the discharge list yet.");
    return;
}
Fail Halt
Meaning Business rejection Early stop, not an error
IsSuccess / IsFailed false / true false / false
Engine Stops, unless ContinueOnError Always stops after the current step
Step span Error Ok, tag tcis.outcome = code
Metric step.business_failures step.halts, tag tcis.outcome
ToResult() FailWithData(FailureCode, errors) Fail(halt.Code, halt.Reason)
  • The first terminal signal wins. Halt after Fail (or Fail after Halt) is dropped and listed in IgnoredSignals. Further Fail calls on an already-failed context still accumulate, as batch processing needs. So Halt cannot close a group of rules that has already collected errors — use StopIfFailed(), see Collecting errors in groups.
  • The code is the domain's, and must come from a finite set (EDO.PENDING, EDO.SUPERSEDED). It becomes a metric label; a code carrying a container number would explode the series count. Per-item detail goes into Reason.
  • Halted gives IsSuccess == false on purpose (fail-closed): code that only checks IsSuccess will not record a result for an item that is still waiting.
  • An infrastructure fault is still an exception. Halt is for valid business outcomes, not for swallowing errors.

Background jobs should branch on Status, not on ToResult(). ToResult() is for the HTTP/gRPC boundary; there, Halted and Failed differ only by their code.

IPipelineStep<TContext> — one business step

[PlatformStep("Gate.ValidateContainer", executionOrder: 10)]
public sealed class CoreValidateContainerStep(IContainerRepository repo)
    : IPipelineStep<IGateInContext>
{
    public async Task ProcessAsync(IGateInContext context, CancellationToken ct = default)
    {
        var container = await repo.FindAsync(context.ContainerNo, ct);   // exception -> 503
        if (container is null)
        {
            context.Fail($"Container {context.ContainerNo} does not exist.");   // business -> 4xx
            return;
        }

        context.SetFeature(container);      // hand it to later steps
    }
}

Step attributes

Attribute Use in Priority
[PlatformStep(stepKey, executionOrder)] A Platform assembly (no site module, or SiteCode DEFAULT) 1
[PluginStep(stepKey, executionOrder)] A site plugin assembly (registered with AddSitePlugins) 10

Reusing a StepKey overrides: the step with the higher priority wins, so a [PluginStep] replaces the [PlatformStep] carrying the same key. A new StepKey inserts: pick an executionOrder between two existing ones.

Priority is the assembly's role, not a choice (RFC-0009, 11/2026). PipelineStepAttribute cannot be used directly and Priority cannot be set. Discovery checks each attribute against the site code of its assembly and fails at startup when they disagree:

Mistake Startup error What it used to do, silently
[PlatformStep] in a plugin assembly Use [PluginStep] Tied with the core step of the same key and lost
[PluginStep] in a Platform assembly Use [PlatformStep] Applied to every site, and no site could replace it
A site's assembly passed to AddPluggablePipeline Register it with AddSitePlugins Skipped every plugin check
Two different steps with the same key, site and role for overlapping contexts Give one another key, or remove one The winner depended on scan order

Before overriding, weigh it up: if you find yourself copying most of the Core step's logic, the real problem is the granularity of the Core step. Ask the Platform team to split it instead of copying — copying is precisely the mechanism that produces drift between a port and Core.

Passing data between steps

context.SetFeature(container);                       // producer step
var container = context.GetRequiredFeature<Container>();   // consumer step
var maybe     = context.GetFeature<Container>();           // null when absent

Prefer this over State when the value is typed — GetRequiredFeature<T> fails loudly with the type name instead of returning a silent null.


2. Business rules

Use IBusinessRule<TContext> when a step needs to be split into independently switchable checks.

public sealed class ContainerWeightRule(IOptions<GateSettings> settings) : IBusinessRule<IGateInContext>
{
    public string RuleKey => "Rule.Gate.ContainerWeight";

    public Task ExecuteAsync(IGateInContext context, CancellationToken ct = default)
    {
        if (context.WeightKg > settings.Value.MaxContainerWeightKg)
        {
            context.Fail($"Container {context.ContainerNo} exceeds the weight limit.");
        }

        return Task.CompletedTask;
    }
}

A rule reports a violation exactly like a step does — through context.Fail(...). It returns no verdict and throws nothing; an exception here means infrastructure, same as invariant 5 in section 6.

Rule order — [RuleOrder(n)]

When rules can fail, the first failing rule decides the message — and with Halt, whether the item waits or is rejected. Without an explicit order, rules run in DI registration order, i.e. whatever Assembly.GetTypes() returns: renaming a file or splitting a class silently changes behaviour.

[RuleOrder(210)] public sealed class CategoryEmptyRule      : IBusinessRule<IEdoContext> { … }   // -> Halt "pending"
[RuleOrder(220)] public sealed class CategoryNotAcceptedRule : IBusinessRule<IEdoContext> { … }   // -> Fail
Rule Why
Ordering applies to the merged Platform + site set A site rule can slot between two Platform rules
Rules without [RuleOrder] run after every ordered rule, in registration order Backward compatible
Two rules with the same order in one run set (same IBusinessRule<T>, DEFAULT ∪ one site) Startup error — the outcome would be undefined
Same order in two different sites, or for two different contracts Allowed — they never run together
AddPluggablePipeline(cfg, o => o.RequireRuleOrder = true, …) A rule without [RuleOrder] becomes a startup error

Step by 10 so sites have room to slot in. The order is read from the attribute at discovery time, not from the instance, so AOP proxies do not hide it.

Rule key — [RuleOrder(n, Key = …)]

A rule has DI dependencies, so nothing can create it at startup to read its RuleKey. Declare the key on the attribute instead — discovery reads it from the type, and IBusinessRule.RuleKey returns it by default, so the key is written once. This lets a host check a "disabled rules" setting at startup (R9 typed instead of R09 must fail, not pass silently):

[RuleOrder(210, Key = EdoRuleKeys.R21)]
public sealed class CategoryEmptyRule : IBusinessRule<IEdoContext>
{
    // no RuleKey here: the default reads Key above
    …
}

A default interface member is not inherited by the class: inside the rule's body, RuleKey does not compile unless the class implements it. When rules need their key in their body (context.Reject(RuleKey, …)), implement it once, on their base class, from the attribute:

public abstract class EdoRule : IBusinessRule<IEdoContext>
{
    public string RuleKey => RuleKeys.Of(GetType());   // [RuleOrder(Key)] of the concrete rule
    …
}
Rule Why
Two rules with the same key in one run set (same IBusinessRule<T>, DEFAULT ∪ one site), ignoring case Startup error — a policy disabling that key would disable both
Same key in two different sites Allowed — they never run together
A rule relying on the default RuleKey declares no Key Startup error, whatever RequireRuleKey says — otherwise every resolution of the rule would throw
The rule implements RuleKey and it ≠ the declared key Error the first time the rules are resolved — startup checks read the declared key, the policy reads the instance; letting them drift makes a disabled rule keep running. Do not implement it with a string literal
Key = "" Startup error. Leading/trailing spaces are trimmed
AddPluggablePipeline(cfg, o => o.RequireRuleKey = true, …) A rule without Key becomes a startup error, so the key set below is complete. Turn it on when rules take their key from a base class calling RuleKeys.Of — discovery cannot tell that apart from any other implementation

RuleKeys.Of(type) / RuleKeys.Find(type) read the attribute of the type itself (Inherited = false: a subclass that declares nothing does not borrow its parent's key), skipping only AOP proxy classes generated at runtime. Closed generic rules read their generic definition. Of throws PipelineDiscoveryException when there is no key; Find returns null.

The host reads the valid keys for a site from IBusinessRuleRegistry (package TCIS.Pluggable.Engine):

IReadOnlySet<string> valid = registry.GetRuleKeys(typeof(IBusinessRule<IEdoContext>), "CTL");
// the keys GetRules("CTL") runs: DEFAULT rules minus those CTL replaces, plus CTL's own; generic included; case-insensitive

Replacing a platform rule — [RuleOrder(n, Key = …, Replaces = …)]

A site rule that replaces a platform rule says so on itself. At that site the platform rule leaves the run set — no setting to switch it off, no key naming convention:

[RuleOrder(1221, Key = CoreorRuleKeys.R16, Replaces = CoreorRuleKeys.R16)]   // reusing the core key is allowed
public sealed class HictEportNoEirChangeRule(...) : EportBranchRule(...) { … }
Rule Why
The platform rule is removed before ordering and the policy The policy then applies to the replacing rule, by its key
Key may equal Replaces The replaced rule is out of the set, so there is no duplicate — outcomes, logs and reports keep the core key. Disabling that key at the site disables the replacing rule
Matching is per contract (IBusinessRule<T>) Two pipelines sharing a key are independent: replacing R20 in one leaves R20 in the other
Replaces on a platform (DEFAULT) rule Startup error — change the platform rule's code instead
Replaces matching no platform key of that contract Startup error — a typo would replace nothing and both rules would run
Replaces without Key · two rules of one site replacing the same key Startup error
Replacing a Mandatory rule with a rule that is not Mandatory Startup error — the slot must stay out of the policy's reach
The replacing rule's Order Declared like any other rule; it may reuse the replaced rule's order
A rule with Replaces not seen by discovery (registered by hand) Error when the rules are resolved — the replacement cannot be applied, and running both is the failure this exists to prevent

IBusinessRuleRegistry.GetReplacements(contract, site) returns each replaced key with the replacing rule's metadata — log it at startup so operators see which site runs differently.

Mandatory rules — [RuleOrder(n, Key = …, Mandatory = true)]

Some rules are prerequisites of the rules after them — typically a rule that enriches the context instead of only checking it. Switching it off makes later rules compare against missing data and reject items wholesale, with no error. Mark it mandatory and the policy can no longer switch it off:

[RuleOrder(110, Key = EdoRuleKeys.R63, Mandatory = true)]   // copies the existing secure code into the context
public sealed class ReuseSecureCodeRule : IBusinessRule<IEdoContext> { … }
Rule Why
The policy disables a mandatory rule The rule still runs; one warning per site and key for the process lifetime. Throwing would stop the whole site over a meaningless setting
Mandatory = true without Key Startup error — the host checks its settings by key
PipelineAnalyzer Reports the rule as enabled, as it actually runs

Reject such a setting at startup rather than relying on the warning:

var mandatory = registry.GetMandatoryRuleKeys(typeof(IBusinessRule<IEdoContext>), "CTL");
// fail ValidateOnStart if site.DisabledRules overlaps `mandatory`

The threshold above comes from the application's own configuration. The framework has no per-site settings mechanism yet — ISiteModule.RegisterServices receives the global IConfiguration only, so a value that differs per port has to be resolved by the site's own module for now (md/27 §2.2).

A site can switch a Platform rule off without touching Platform code:

public sealed class CatLaiRulePolicy : IBusinessRulePolicy   // the "Policy" suffix auto-registers it
{
    private static readonly HashSet<string> Disabled = ["Rule.Gate.ContainerWeight"];

    public bool IsRuleEnabled(string ruleKey) => !Disabled.Contains(ruleKey);
}

That is the whole job. Nothing else to wire up:

  • AddPluggablePipeline already registers DefaultBusinessRulePolicy under the DEFAULT key, so a site that supplies no policy of its own still runs every rule.
  • A site may have more than one policy (for example one built from configuration and one in its plugin). They are combined: a rule is off when any of them switches it off. Lookup goes by tier — the site's policies, else the DEFAULT policies, else a plain registration — and the first tier that has any wins; a site with its own policy does not also get the DEFAULT ones. Mandatory rules still run. Before RFC-0008 (11/2026) only the last-registered policy of a tier was used and the others were dropped silently. Pinned by S4_PolicyCompositionTests.
  • IBusinessRuleProvider applies the policy itself, so the coordinating step just hands over:
[PlatformStep("Gate.CheckRules", executionOrder: 200)]
public sealed class CoreCheckRulesStep(IBusinessRuleProvider<IGateInContext> rules) : IPipelineStep<IGateInContext>
{
    public Task ProcessAsync(IGateInContext context, CancellationToken ct = default)
        => rules.RunAsync(context, ct);
}

RunAsync orders the rules by [RuleOrder], applies the site's policy, checks cancellation before each rule, and stops on context.ShouldStop — always on Halt, and on a failure unless ContinueOnError. A rule that halts becomes HaltInfo.Source. It does nothing if the context has already stopped. A rule whose terminal signal was dropped (see IgnoredSignals) is logged as a warning with its RuleKey — also when RunAsync is called directly, outside the engine.

Collecting errors in groups

Some rulebooks mix behaviours: stop at once on one check, collect every error of a group, then stop before the next group if that group failed. Turn on ContinueOnError and close each group with a gate rule that calls StopIfFailed():

// context.ContinueOnError = true before RunAsync
[RuleOrder(45)]
public sealed class Group1GateRule : IBusinessRule<ExportBookingContext>
{
    public string RuleKey => "GATE1";

    public Task ExecuteAsync(ExportBookingContext context, CancellationToken ct = default)
    {
        context.StopIfFailed();   // errors from rules 20..40 ⇒ stop here, still Failed, errors kept
        return Task.CompletedTask;
    }
}

StopIfFailed() turns ContinueOnError off only when the context already has an error, so ShouldStop becomes true and both RunAsync and the engine stop after the current rule or step. With no error yet it does nothing. A rule that must stop the run on its own error calls Fail(...) then StopIfFailed().

Do not close a group with Halt. On a context that already failed, Halt is dropped — the run carries on into the next group and only a warning is left behind. Pinned by U1_RuleRunnerTests.RunAsync_HaltAfterFail_IsIgnored_AndLoggedAsAWarning.

Prefer RunAsync over a hand-written loop. A loop has to remember the stop condition itself; one that only checks !IsSuccess && !ContinueOnError keeps running after a Halt whenever ContinueOnError is on — no error, no log. GetRules(siteCode) is still there when you genuinely need the list (diagnostics, a custom fan-out).

Do not register a policy with plain AddScoped. Per-site services are dispatched through a keyed dispatcher that AddAutoMultiTenantService installs with TryAdd; a plain registration on the same service type makes that TryAdd a no-op, and the site's policy is then never used — no error, no log. The engine already registers the default correctly; leave it alone.

Changed 19/08/2026: filtering moved from the coordinating step into the provider. Previously every step had to remember a .Where(...) clause, and forgetting it silently disabled the whole feature. Pinned by S2_BusinessRulePolicyTests.


3. Site modules

ISiteModule — the entry point of a module

public sealed class CatLaiModule : ISiteModule
{
    public string SiteCode => "CATLAI";        // MUST NOT be "DEFAULT" for a plugin

    public void RegisterServices(IServiceCollection services, IConfiguration configuration)
    {
        services.AddScoped<ICustomsGateway, CatLaiCustomsGateway>();
    }
}

Platform modules declare SiteCode = "DEFAULT" and load for every site. Plugin modules declare their own site code and load only for that site.

[MultiTenantService] — per-site service implementations

Mark only the contracts a site may replace — the Platform default and each site's version alike:

[MultiTenantService(typeof(IFeeCalculator))]
public sealed class FeeCalculator : IFeeCalculator { … }          // Platform

[MultiTenantService(typeof(IFeeCalculator))]
public sealed class CatLaiFeeCalculator : IFeeCalculator { … }    // plugin CATLAI

In ServiceRegistrationMode.Explicit (recommended, set on AddPluggablePipeline) the attribute is the only way in and typeof(...) is mandatory. In the default Convention mode, classes whose names end with Service, Provider, Mapper, Factory, Manager, Repository or Policy are registered automatically as well. See the Engine README, Multi-tenant service registration.

IBusinessRulePolicy implementations need no attribute and no particular name — they are recognised by type in every mode.

IPluggableDiscoveryHook — joining discovery from your own library

Implement it when a library of yours needs to inspect or contribute during the discovery sweep, rather than requiring every host to wire it manually.


4. Returning a pipeline result to the caller

A pipeline ends with a context, but every TCIS business flow is expected to hand the transport layer a TResult so a controller can just call ProcessResult. ToResult bridges the two:

The failure code and message are declared on the context, not at the call site:

public sealed class GateInContext : TPipelineContextBase<GateInResult>
{
    public override string FailureCode => "GATE_IN_REJECTED";   // no taxonomy prefix -> HTTP 409
    public override string FailureMessage => $"Container {ContainerNo} cannot enter.";

    public string ContainerNo { get; set; } = string.Empty;
}
await pipelineEngine.ExecuteAsync(context, ct);

return ProcessResult(context.ToResult());
Method Use for
ToResult() Default. Reads FailureCode / FailureMessage off the context
ToResult(code, message) Overrides what the context declares — rare. Does not replace a halt's code

A halted pipeline maps to Fail(halt.Code, halt.Reason) with no error data. The HTTP status follows the code's taxonomy prefix, exactly as for any failure — so the domain picks the status by picking the prefix. | ToValidationErrors() | Just the grouped errors, when you build the TResult yourself |

Both FailureCode and FailureMessage are abstract on TPipelineContextBase, so the compiler points at any context that has not decided yet. A virtual default would let a context silently ship a generic code the client cannot act on.

Why they live on the context. FailureCode is a constant of the use case — a given pipeline always rejects under the same code — so it belongs to the context type, not to a single call. Put it there and every entry point running that pipeline (REST, gRPC, a background job) reports the same code, instead of each caller repeating a literal that nothing keeps in sync. FailureMessage can interpolate the context's own fields, because the context already carries the request data.

Why this exists. The Pluggable architecture has two ways into business logic — CQRS handlers and the pipeline engine — but the response standard was written for handlers only. Without a shared bridge every controller hand-rolls its own JSON from context.Errors, so a client ends up parsing two different error shapes on the same API, and pipeline endpoints lose the TraceId that ties a report back to its logs.

Why the conversion is lossless. A PipelineError carries Key + Message, which is exactly a TValidationError once grouped by key. Errors sharing a key collapse into one entry, keys keep first-appearance order, and an error with no key lands in "GLOBAL" — the same convention context.Fail(message) already uses:

"data": [
  { "key": "TCIU1000001", "errors": ["Overweight.", "Seal number missing."] },
  { "key": "TCIU2000002", "errors": ["Booking not found."] }
]

That shape matters most for batch endpoints running with ContinueOnError — one row failing must not collapse the whole report into a single message.

Only business failures reach ToResult. An infrastructure fault was thrown by the step and never got here — that boundary is preserved by the engine, see section 6.


5. Connection-level tenant isolation

This is the layer that makes a single process safely serve several tenants.

ITenantConnectionInitializer

void Initialize(DbConnection connection, string? tenantId);
Task InitializeAsync(DbConnection connection, string? tenantId, CancellationToken ct = default);
void Cleanup(DbConnection connection);
Task CleanupAsync(DbConnection connection);

Initialize runs immediately after the connection opens; Cleanup runs before it returns to the pool.

TenantConnectionInitializerBase — write only the SQL

A template-method base: derived classes describe the command, the orchestration is written once.

public sealed class MyRlsInitializer : TenantConnectionInitializerBase
{
    protected override void ConfigureInitializeCommand(DbCommand command, string? tenantId) { … }
    protected override void ConfigureCleanupCommand(DbCommand command) { … }
}

Every initializer used to hand-write all four methods, with the sync and async bodies about 84% identical. That duplication produced two serious defects — a fix applied to the async branch but not the sync one, and Tier 1 failing closed while Tier 2 failed open because the branches were written separately.

Throwing from ConfigureInitializeCommand means the framework executes no command at all — fail-closed by construction.

TenantAwareDbConnection — the decorator trio

public sealed class TenantAwareDbConnection(
    DbConnection innerConnection, string? tenantId, ITenantConnectionInitializer initializer) : DbConnection

Transparent to both EF Core and Dapper.

Method Added behaviour
Open() / OpenAsync() Calls Initialize. On failure, discards the session from the pool, closes it, then rethrows the original error
Close() / CloseAsync() Calls Cleanup; if cleanup fails, discards the session from the pool instead of throwing. Always resets the flag and closes
Dispose() / DisposeAsync() Same as Close
BeginDbTransaction() Returns a TenantAwareDbTransaction wrapper
CreateDbCommand() Returns a TenantAwareDbCommand wrapper
ExecuteReader(CloseConnection) Returns a TenantAwareDbDataReader wrapper

Why "discard". ADO.NET's Close() returns a connection to the pool; it does not throw it away. A session whose isolation setup or reset failed may still carry the previous tenant's schema or context, and the next borrower would run on it. The wrapper calls the driver's static ClearPool(connection) (SqlClient, Npgsql and ODP.NET all have one): the session is destroyed instead of pooled, and that pool's idle sessions are closed — a cost paid only on failure. A driver without ClearPool cannot be protected this way. ODP.NET is the one that matters: it does not reset sessions taken from the pool. Pinned on Oracle by Tier2_A_Failed_Cleanup_Discards_The_Session_Instead_Of_Pooling_It (changed 27/10/2026 — cleanup errors were swallowed and the session pooled).

Four safety details worth knowing:

  1. Initialize fails → Close() at once. A connection that never got its isolation applied must not reach the pool; it would carry poisoned state into another tenant's request.

  2. _isInitialized is reset in a finally — including when State == Broken (dropped network, database restart) causes the cleanup branch to be skipped. Leaving the flag set would make the next Open() skip Initialize, giving a session with no tenant isolation at all.

  3. A connection decorator must decorate all four types, because the invariant only holds if every path that opens a transaction, creates a command or closes the connection goes through the wrapper:

    Type Why it is needed
    TenantAwareDbConnection The entry point — applies and clears tenant isolation
    TenantAwareDbTransaction EF Core validates connection.DbConnection != transaction.Connection inside the RelationalTransaction constructor
    TenantAwareDbCommand Providers downcast (SqlCommand.DbTransaction → SqlTransaction), so the wrapper must be unwrapped before reaching the inner command
    TenantAwareDbDataReader CommandBehavior.CloseConnection makes the reader close the connection. Letting it close the inner connection skips Cleanup and leaves the initialised flag set — the next Open() then runs with no tenant isolation
  4. DisposeAsync needs a double-release guard. DbTransaction.DisposeAsync() and DbCommand.DisposeAsync() fall back to calling Dispose(), so an async branch that releases the inner object and lets base run releases it twice. It does not crash on SQL Server or SQLite because their Dispose is idempotent — exactly the kind of defect a green test suite does not catch.

Why the command and the transaction are wrapped too

EF Core validates identity inside the RelationalTransaction constructor — connection.DbConnection != transaction.Connection throws "The specified transaction is not associated with the current connection." Returning the inner connection's transaction directly fails that check, because EF holds the decorator while the transaction reports the inner connection.

That broke every transaction in the Pluggable stack — not only explicit BeginTransactionAsync, but also the transaction EF opens by itself when SaveChanges has to send more than one batch. Saving many rows at once was enough to hit it.

Wrapping the transaction then forces wrapping the command: providers downcast (SqlCommand.DbTransaction casts to SqlTransaction), so the wrapper has to be unwrapped before it is handed to the inner command. TenantAwareDbCommand exists for exactly that one job and delegates everything else untouched.

Verified against real SQL Server and PostgreSQL containers.

TenantConnectionIdentifier — which identifier each tier needs

public static string? Resolve(IsolationTier tier, string? tenantId, string? tenantIdentifier);
Tier Identifier used Why
Tier 1 — RLS TenantInfo.Id (GUID) Bound with the correct type into SESSION_CONTEXT to preserve index seeks
Tier 2 — Schema TenantInfo.Identifier (site code) The value becomes a schema name; a GUID contains - and is always rejected by the whitelist
Tier 3 — Database not used The initializer is a no-op

The rule lives in one place because there are six connection-building sites (3 providers × EF Core and Dapper). All six once passed Id straight through, which made Tier 2 impossible to run.

NoOpConnectionInitializer does nothing and serves Tier 3, where a dedicated connection string already provides isolation.


6. ⚠️ Invariants you must not break

# Invariant What breaks otherwise
1 WorkContext is the only source of SiteCode. context.SiteCode is a copy, never an input The blueprint is built from WorkContext while rule dispatch reads context.SiteCode — one site's steps run with another site's rules, silently
2 A Platform module's ConfigureDatabase must be deterministic The EF model cache key does not contain the site code; non-deterministic mapping leaks across sites
3 IModuleRegistry must be a Singleton Captive state inside the EF model cache
4 Both places that answer "does this model need a tenant filter?" must agree EF Core serves the wrong model
5 Business failure is context.Fail(...) → 4xx; infrastructure failure is a thrown exception → 503 Wrong HTTP status and wrong alerting severity
6 A plugin module must declare its own SiteCode, never DEFAULT The module loads for every site and overrides group-wide standard business
7 PipelineBlueprintResolver belongs to the engine; the analyzer only borrows it read-only Diagnostics requirements bend execution semantics

Full reasoning: md/29 Part 6.


7. Pitfalls

# Pitfall Consequence
1 Throwing an exception for a business failure 503 plus an operational alert for something that is merely a rejected request
2 Calling context.Fail(...) for an infrastructure failure 4xx, and the real outage never reaches monitoring
3 Two steps sharing the same ExecutionOrder Their relative order is undefined
4 A plugin module left at SiteCode = "DEFAULT" Rejected at startup by AddSitePlugins — deliberately loud
5 Writing to context.SiteCode to "switch site" Ignored; the engine overwrites it from WorkContext
6 Reading ambient/static state inside ConfigureDatabase Module instances are singletons — the state sticks to every site
7 Registering an IBusinessRulePolicy with plain AddScoped Shadows the keyed dispatcher — the site's policy is never used, silently (section 2)
Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (4)

Showing the top 4 NuGet packages that depend on TCIS.Pluggable.Abstractions:

Package Downloads
TCIS.Pluggable.Persistence.EntityFrameworkCore

TCIS Core Framework is an application framework for building modular, multi-tenant applications on ASP.NET Core. Pluggable Persistence EntityFrameworkCore

TCIS.Pluggable.Engine

TCIS Core Framework is an application framework for building modular, multi-tenant applications on ASP.NET Core. Execution engine and registry for TCIS Pluggable Pipeline Architecture.

TCIS.Mediator.Keyed

TCIS Core Framework is an application framework for building modular, multi-tenant applications on ASP.NET Core. Multi-tenant support for TCIS.Mediator using Keyed Services.

TCIS.Pluggable.Engine.AspNetCore

TCIS Core Framework is an application framework for building modular, multi-tenant applications on ASP.NET Core. ASP.NET Core endpoint mapping for TCIS Pluggable Architecture. Tách khỏi TCIS.Pluggable.Engine để Worker Service, Console App và Hangfire host không phải kéo theo toàn bộ ASP.NET Core.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.1.3 76 11/11/2026
1.1.2 159 11/10/2026
1.1.1 165 11/9/2026
1.1.0 181 11/7/2026
1.0.9 219 11/5/2026
1.0.9-rc.2 98 11/5/2026
1.0.9-rc.1 104 11/4/2026
1.0.8 253 11/3/2026
1.0.8-rc.11 105 10/30/2026
1.0.8-rc.10 97 10/30/2026
1.0.8-rc.9 96 10/30/2026
1.0.8-rc.8 97 10/29/2026
1.0.8-rc.7 96 10/27/2026
1.0.8-rc.6 101 10/27/2026
1.0.8-rc.5 107 10/23/2026
1.0.8-rc.4 105 10/23/2026
1.0.8-rc.3 101 10/23/2026
1.0.8-rc.2 98 10/22/2026
1.0.8-rc.1 110 10/22/2026
1.0.7 159 10/22/2026
Loading failed