TCIS.Pluggable.Abstractions 1.0.0-rc.19

This is a prerelease version of TCIS.Pluggable.Abstractions.
dotnet add package TCIS.Pluggable.Abstractions --version 1.0.0-rc.19
                    
NuGet\Install-Package TCIS.Pluggable.Abstractions -Version 1.0.0-rc.19
                    
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.0.0-rc.19" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="TCIS.Pluggable.Abstractions" Version="1.0.0-rc.19" />
                    
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.0.0-rc.19
                    
#r "nuget: TCIS.Pluggable.Abstractions, 1.0.0-rc.19"
                    
#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.0.0-rc.19
                    
#: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.0.0-rc.19&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=TCIS.Pluggable.Abstractions&version=1.0.0-rc.19&prerelease
                    
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 Connection-level tenant isolation
5 ⚠️ Invariants you must not break
6 Pitfalls

1. Pipeline contracts

IPipelineContext — the bag that travels through a run

Member Type Meaning
IsSuccess bool (get) true while no error has been recorded. The engine reads it after every step to decide whether to stop
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 5
Fail(params string[]) Records errors against CurrentItemKey, or "GLOBAL" when unset
Fail(string key, string message) Records an error against a specific key
Success() Clears all errors

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

IsSuccess is a derived property (_errors.IsEmpty), 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.

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 for Priority
[PlatformStep(stepKey, executionOrder)] Standard business shared by every site 1
[PluginStep(stepKey, executionOrder)] Site-specific business 10
[PipelineStep(stepKey, priority, executionOrder)] Explicit control of both as given

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.

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(ISiteSettings settings) : IBusinessRule<IGateInContext>
{
    public string RuleKey => "Rule.Gate.ContainerWeight";

    public Task<bool> IsSatisfiedAsync(IGateInContext context, CancellationToken ct)
        => Task.FromResult(context.WeightKg <= settings.MaxContainerWeightKg);
}

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);
}

DefaultBusinessRulePolicy enables everything; it is the fallback when a site supplies no policy.


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

Classes whose names end with a configured suffix (Service, Provider, Repository, Factory, Manager, Mapper, Policy) are registered per site automatically. Use the attribute when the name does not follow a suffix, or to name the service type explicitly:

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

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. 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, closes the connection immediately, then throws
Close() / CloseAsync() Calls Cleanup, swallows cleanup errors, 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

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.DbTransactionSqlTransaction), 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.


5. ⚠️ 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.


6. 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
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.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.Pluggable.Persistence.EntityFrameworkCore

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

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.0.0-rc.19 53 8/13/2026
1.0.0-rc.18 52 8/13/2026
1.0.0-rc.17 54 8/13/2026
1.0.0-rc.16 65 8/13/2026
1.0.0-rc.15 66 8/12/2026
1.0.0-rc.14 69 8/12/2026
1.0.0-rc.13 82 8/11/2026
1.0.0-rc.12 85 8/10/2026
1.0.0-rc.11 89 7/28/2026
1.0.0-rc.10 93 7/24/2026
1.0.0-rc.9 83 7/21/2026
1.0.0-rc.8 80 7/21/2026
1.0.0-rc.7 79 7/17/2026
1.0.0-rc.6 85 7/7/2026
1.0.0-rc.5 92 7/7/2026
1.0.0-rc.2 76 5/12/2026