TCIS.Pluggable.Abstractions
1.0.0-rc.19
dotnet add package TCIS.Pluggable.Abstractions --version 1.0.0-rc.19
NuGet\Install-Package TCIS.Pluggable.Abstractions -Version 1.0.0-rc.19
<PackageReference Include="TCIS.Pluggable.Abstractions" Version="1.0.0-rc.19" />
<PackageVersion Include="TCIS.Pluggable.Abstractions" Version="1.0.0-rc.19" />
<PackageReference Include="TCIS.Pluggable.Abstractions" />
paket add TCIS.Pluggable.Abstractions --version 1.0.0-rc.19
#r "nuget: TCIS.Pluggable.Abstractions, 1.0.0-rc.19"
#:package TCIS.Pluggable.Abstractions@1.0.0-rc.19
#addin nuget:?package=TCIS.Pluggable.Abstractions&version=1.0.0-rc.19&prerelease
#tool nuget:?package=TCIS.Pluggable.Abstractions&version=1.0.0-rc.19&prerelease
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:
Initializefails →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._isInitializedis reset in afinally— including whenState == Broken(dropped network, database restart) causes the cleanup branch to be skipped. Leaving the flag set would make the nextOpen()skipInitialize, giving a session with no tenant isolation at all.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 TenantAwareDbConnectionThe entry point — applies and clears tenant isolation TenantAwareDbTransactionEF Core validates connection.DbConnection != transaction.Connectioninside theRelationalTransactionconstructorTenantAwareDbCommandProviders downcast ( SqlCommand.DbTransaction→SqlTransaction), so the wrapper must be unwrapped before reaching the inner commandTenantAwareDbDataReaderCommandBehavior.CloseConnectionmakes the reader close the connection. Letting it close the inner connection skipsCleanupand leaves the initialised flag set — the nextOpen()then runs with no tenant isolationDisposeAsyncneeds a double-release guard.DbTransaction.DisposeAsync()andDbCommand.DisposeAsync()fall back to callingDispose(), so an async branch that releases the inner object and letsbaserun releases it twice. It does not crash on SQL Server or SQLite because theirDisposeis 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
RelationalTransactionconstructor —connection.DbConnection != transaction.Connectionthrows "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 whenSaveChangeshas 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.DbTransactioncasts toSqlTransaction), so the wrapper has to be unwrapped before it is handed to the inner command.TenantAwareDbCommandexists 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 | Versions 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. |
-
net8.0
- Microsoft.Extensions.Configuration.Abstractions (>= 9.0.0)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 9.0.0)
- TCIS.Core (>= 1.0.0-rc.19)
- TCIS.Persistence.Abstractions (>= 1.0.0-rc.19)
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 |