TCIS.Pluggable.Engine
1.0.0-rc.19
dotnet add package TCIS.Pluggable.Engine --version 1.0.0-rc.19
NuGet\Install-Package TCIS.Pluggable.Engine -Version 1.0.0-rc.19
<PackageReference Include="TCIS.Pluggable.Engine" Version="1.0.0-rc.19" />
<PackageVersion Include="TCIS.Pluggable.Engine" Version="1.0.0-rc.19" />
<PackageReference Include="TCIS.Pluggable.Engine" />
paket add TCIS.Pluggable.Engine --version 1.0.0-rc.19
#r "nuget: TCIS.Pluggable.Engine, 1.0.0-rc.19"
#:package TCIS.Pluggable.Engine@1.0.0-rc.19
#addin nuget:?package=TCIS.Pluggable.Engine&version=1.0.0-rc.19&prerelease
#tool nuget:?package=TCIS.Pluggable.Engine&version=1.0.0-rc.19&prerelease
TCIS.Pluggable.Engine
The core engine of the TCIS Pluggable architecture: unified discovery (modules, steps, rules, services), the pipeline engine, and the pipeline analyzer.
Contracts live in
TCIS.Pluggable.Abstractions. Your plugins reference the contracts; the host references this package.
Table of contents
| Section | Contents |
|---|---|
| 1 | Registration |
| 2 | Plugins are referenced statically |
| 3 | Discovery |
| 4 | Execution semantics |
| 5 | Error behaviour |
| 6 | Resilience is not applied at step level |
| 7 | Metrics |
| 8 | Diagnosing "my step doesn't run" |
| 9 | Pitfalls |
1. Registration
dotnet add package TCIS.Pluggable.Engine
builder.Services
// Standard business, shared by every site
.AddPluggablePipeline(builder.Configuration,
typeof(GatePlatformModule).Assembly,
typeof(VesselPlatformModule).Assembly)
// Site-specific business — EXPLICIT assembly references, no directory scanning
.AddSitePlugins(builder.Configuration,
typeof(CatLaiModule).Assembly);
Auto-registration suffixes are configurable:
{
"TLogSettings": {
"AopSettings": {
"DiRegistrationSuffixes": [ "Service", "Provider", "Mapper", "Factory", "Manager", "Repository", "Policy" ]
}
}
}
2. Plugins are referenced statically
Every deployment has its own host project, and the hosts differ in exactly one thing — the assemblies passed to AddSitePlugins:
// A large port with its own deployment
.AddSitePlugins(configuration, typeof(CatLaiModule).Assembly);
// Several small ports sharing one deployment
.AddSitePlugins(configuration,
typeof(SiteAModule).Assembly,
typeof(SiteBModule).Assembly);
Why there is no DLL directory scanning
The plugin set of any deployment is already known at build time, so an
AssemblyLoadContextbuys nothing — while a static reference buys something far more valuable: the compiler becomes a drift detector.With dynamic loading, Core can change an interface signature and the old plugin still compiles (it is built separately); the failure then surfaces at runtime, in production, at that one port. With static references the build goes red immediately and names exactly which plugin broke. That is the cheapest possible guard against Core/plugin drift — see
md/28.
A plugin must declare a SiteCode other than DEFAULT. Forgetting it is the most dangerous
silent failure of this model: the module would load for every site and override the whole
group's standard business. AddSitePlugins checks every module in the assembly and throws
PipelineDiscoveryException at startup.
3. Discovery
Discovery runs once, during ConfigureServices:
| # | Work |
|---|---|
| 1 | Register the engine's own services |
| 2 | Scan the Platform assemblies |
| 3 | For each assembly: find every ISiteModule, call RegisterServices, add it to IModuleRegistry |
| 4 | Run third-party extension hooks (IPluggableDiscoveryHook) |
| 5 | For each class: recognise a rule / step / service and register it in keyed DI |
| 6 | Collect every error and throw once — strict mode |
| 7 | Repeat 2–6 for plugin assemblies, adding the site-code check and provenance marking |
Discovery never skips an error quietly. One malformed step and the application will not start. That is deliberate: a step that failed to register would never run, and a pipeline missing a safety check is more dangerous than an application that refuses to boot.
PipelineDiscoveryException lists all problems at once, not just the first.
4. Execution semantics
Controller / handler
└─ IPipelineEngine.ExecuteAsync(context, ct)
1. Guard the context
2. siteCode <- WorkContext.Tenant.SiteCode (THE ONLY SOURCE OF TRUTH)
3. context.SiteCode = siteCode (direct assignment, never ??=)
4. blueprint <- cache[(siteCode, contextType)]
5. for each step:
- resolve through keyed DI
- start an Activity, start a stopwatch
- await step.ProcessAsync(context, ct)
- record metrics
- if (!context.IsSuccess) stop, unless ContinueOnError
Writing, overriding, inserting
// Platform: standard business
[PlatformStep("Gate.ValidateContainer", executionOrder: 10)]
public sealed class CoreValidateContainerStep : IPipelineStep<IGateInContext> { … }
// Plugin: override — SAME StepKey, wins on priority
[PluginStep("Gate.ValidateContainer", executionOrder: 10)]
public sealed class CatLaiValidateContainerStep : IPipelineStep<IGateInContext> { … }
// Plugin: insert — NEW StepKey, slotted between 20 and 30
[PluginStep("Gate.CustomsCheck", executionOrder: 25)]
public sealed class CatLaiCustomsCheckStep : IPipelineStep<IGateInContext> { … }
Blueprints are cached per (siteCode, contextType), so step selection is resolved once, not per request.
Generic steps with several type parameters are not supported. Discovery rejects them at startup rather than failing later at resolution time.
5. Error behaviour
| Situation | Behaviour |
|---|---|
A step calls context.Fail(...) |
Business failure — stays in the context, the pipeline stops (or continues when ContinueOnError) |
| A step throws | Infrastructure failure — the engine logs and rethrows, preserving the type so TExceptionMiddleware maps the right HTTP status. It is never downgraded to a business failure. Later steps do not run |
| A step cannot be resolved from DI | PipelineDiscoveryException. The engine does not fall back to ActivatorUtilities — that would silently strip AOP logging and telemetry |
Three cases, and none of them skips a step. Every step either runs or stops the pipeline — so the failure class "pipeline reports success although a business step never ran" no longer exists structurally.
6. Resilience is not applied at step level
There is no timeout, retry or circuit breaker around a step. This is a deliberate decision, not an omission — see md/28.
Resilience belongs where a call crosses an infrastructure boundary, not around in-process business logic:
| Need | Where it belongs |
|---|---|
| Bound a database query | AddPluggableSqlServer<T>(commandTimeoutSeconds: 30) — the only thing that genuinely cancels a query server-side |
| Calling an external API / gRPC service | TCIS.Http already carries Polly policies; gRPC uses deadlines |
| A final backstop for the whole request | ASP.NET Core AddRequestTimeouts |
| Detecting which step of which site is failing | The tcis.pipeline.step.* metrics — see section 7 |
Why a step-level timeout does not do what people expect
Polly uses cooperative cancellation: it cancels a token and throws to the caller, but the step keeps running — .NET cannot forcibly abort a task. The result can be: the client already received an error, the pipeline already aborted, and the step still writes to the database seconds later. The only timeouts that genuinely stop work are
CommandTimeoutand the ones at the connection layer.
Configure the backstop like this:
builder.Services.AddRequestTimeouts(o =>
o.DefaultPolicy = new RequestTimeoutPolicy { Timeout = TimeSpan.FromSeconds(60) });
app.UseTCISCore(); // TExceptionMiddleware must WRAP the outside to classify correctly
app.UseRequestTimeouts();
TExceptionMiddlewaredistinguishes a server timeout from a client disconnect throughIHttpRequestTimeoutFeature.RequestTimeoutToken— both surface asOperationCanceledException, so the exception type alone cannot tell them apart.
Situation HTTP Log level Request exceeded the server time limit 504 GATEWAY_TIMEOUTError → alerts fire Client closed the connection 499 CLIENT_CLOSEDInformation → no alert Misclassifying is not only the wrong status code, it is the wrong log level — an overload incident would slip past every alerting system.
7. Metrics
A circuit breaker used to detect a repeatedly failing step. It was removed because it guarded the wrong threat, but the signal is still needed. Alerting is now the only breaker.
builder.Services.AddOpenTelemetry()
.WithTracing(t => t.AddSource("TCIS.Platform.Pipelines"))
.WithMetrics(m => m.AddMeter(PipelineMetrics.MeterName)); // "TCIS.Platform.Pipelines"
| Metric | Meaning |
|---|---|
tcis.pipeline.step.duration |
Step execution time (ms) |
tcis.pipeline.step.errors |
The step threw — infrastructure failure |
tcis.pipeline.step.business_failures |
The step stopped the pipeline via context.Fail — business |
All three carry the tcis.site_code, tcis.step_key and tcis.context labels.
Business failures are counted separately. Rejecting an overweight container is the system doing its job correctly; mixing the two into one metric makes every alerting threshold meaningless.
The
tcis.site_codelabel is mandatory rather than decorative: many small ports share one process, so a system-wide error total hides the very situation that most needs an alert — one port failing completely while nine others stay green.
Suggested alerting rules:
# One step of one port failing repeatedly — replaces what the circuit breaker used to do
sum by (tcis_site_code, tcis_step_key) (rate(tcis_pipeline_step_errors_total[5m])) > 0.2
# One step unusually slow — an early sign of a stalling database
histogram_quantile(0.95,
sum by (le, tcis_site_code, tcis_step_key) (rate(tcis_pipeline_step_duration_bucket[5m]))) > 5000
8. Diagnosing "my step doesn't run"
| Symptom | Usual cause |
|---|---|
| The application will not start | Read the PipelineDiscoveryException message — it lists every problem at once |
| The step is absent from the pipeline | Its target site differs from the current site code, or another step with the same StepKey and higher priority overrode it |
| Steps run in the wrong order | Two steps share an ExecutionOrder — the order between them is undefined |
| The plugin never loaded | The host did not pass that assembly to AddSitePlugins |
| Platform business runs instead of the site's | WorkContext is missing — check the middleware, or Hangfire/EventBus context propagation |
| You are not sure what is happening | Call IPipelineAnalyzer.GetLayout(contextType, siteCode) — only on an authenticated environment |
The analyzer is a read-only borrower of the engine's step-selection rules. It never influences them.
9. Pitfalls
| # | Pitfall | Consequence |
|---|---|---|
| 1 | Throwing for a business failure | 503 and an operational alert for a merely rejected request |
| 2 | context.Fail(...) for an infrastructure failure |
4xx, and the real outage never reaches monitoring |
| 3 | Expecting a step-level timeout to stop work | Cancellation is cooperative — the step keeps running |
| 4 | Duplicate ExecutionOrder |
Undefined ordering |
| 5 | Copying most of a Core step in order to override it | Drift — ask the Platform team to split the step instead |
| 6 | Calling the analyzer from an unauthenticated endpoint | Exposes the internal business layout |
| 7 | Running a pipeline without WorkContext |
Falls back to the default site, silently running the wrong business |
| 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.Configuration.Binder (>= 9.0.0)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 9.0.0)
- Microsoft.Extensions.Logging.Abstractions (>= 9.0.0)
- Microsoft.Extensions.Options (>= 9.0.0)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 9.0.0)
- TCIS.Core (>= 1.0.0-rc.19)
- TCIS.Logging.Abstractions (>= 1.0.0-rc.19)
- TCIS.Pluggable.Abstractions (>= 1.0.0-rc.19)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on TCIS.Pluggable.Engine:
| Package | Downloads |
|---|---|
|
TCIS.Pluggable.Engine.Autofac
TCIS Core Framework is an application framework for building modular, multi-tenant applications on ASP.NET Core. Autofac integration for TCIS.Pluggable.Engine |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.0.0-rc.19 | 42 | 8/13/2026 |
| 1.0.0-rc.18 | 38 | 8/13/2026 |
| 1.0.0-rc.17 | 35 | 8/13/2026 |
| 1.0.0-rc.16 | 43 | 8/13/2026 |
| 1.0.0-rc.15 | 43 | 8/12/2026 |
| 1.0.0-rc.14 | 44 | 8/12/2026 |
| 1.0.0-rc.13 | 43 | 8/11/2026 |
| 1.0.0-rc.12 | 52 | 8/10/2026 |
| 1.0.0-rc.11 | 71 | 7/28/2026 |
| 1.0.0-rc.10 | 61 | 7/24/2026 |
| 1.0.0-rc.9 | 62 | 7/21/2026 |
| 1.0.0-rc.8 | 51 | 7/21/2026 |
| 1.0.0-rc.7 | 57 | 7/17/2026 |
| 1.0.0-rc.6 | 73 | 7/7/2026 |
| 1.0.0-rc.5 | 77 | 7/7/2026 |
| 1.0.0-rc.2 | 73 | 5/12/2026 |