elsa-core/doc/wiki/module-system.md
Sipke Schoorstra a818b5110e
fix(features): support features introduced during Module.Apply() (#7966)
Module.Apply() enumerated _features.Values directly while calling
feature.Apply(). A feature whose Apply() introduces another feature —
Module.Configure<T>() directly, or via a helper such as AddActivity<T>()
which configures WorkflowManagementFeature — mutated that collection
mid-enumeration and threw "Collection was modified; enumeration
operation may not execute", naming nothing about features. Whether it
fired depended on whether the other feature happened to be installed
already, so a module built or did not based on unrelated host config.

The module already treats introduction-during-apply as supported: the
ConfigureFeature loop iterates a snapshot for exactly this reason, and
Configure<T>() has an _isApplying branch that creates, resolves and
configures a feature introduced mid-Apply. Only the final apply loop
missed the same treatment, so make it tolerant rather than diagnose a
constraint the code does not hold.

The apply loop now runs in rounds until no new features appear, each
round topologically sorted so a late feature's dependencies apply before
it. Hosted services are registered in a single pass after that loop,
then moved back to the index the block previously occupied: registering
late is needed so features contributed during Apply() are included and
ordered by priority, while keeping the position matters because features
register hosted services directly from Apply() — WorkflowRuntimeFeature
adds DrainOrchestratorHostedService that way — and module-managed
services must keep starting first, or a priority such as ActivateTenants
at -1 would silently start ordering after them.

Adds Elsa.Features.UnitTests, covering the introduced feature applying,
a three-deep introduction chain, dependency ordering, hosted service
registration and priority ordering for late arrivals, the installed-
feature registry, and no double-apply, plus guards for pre-existing
ordering behaviour.

Closes #7944

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 23:53:07 +02:00

99 lines
6.2 KiB
Markdown

# Module System
The module system is the backbone of Elsa. It is a thin abstraction over `IServiceCollection` that lets packages register cohesive feature sets with dependency ordering.
## Core Types
| Type | File | Role |
| --- | --- | --- |
| `IModule` | [src/common/Elsa.Features/Services/IModule.cs](../../src/common/Elsa.Features/Services/IModule.cs) | Holds `IServiceCollection`, module properties, configured features, hosted service descriptors, and `Apply()`. |
| `Module` | [src/common/Elsa.Features/Implementations/Module.cs](../../src/common/Elsa.Features/Implementations/Module.cs) | Concrete feature graph builder and applier. |
| `IFeature` | [src/common/Elsa.Features/Services/IFeature.cs](../../src/common/Elsa.Features/Services/IFeature.cs) | Feature lifecycle contract. |
| `FeatureBase` | [src/common/Elsa.Features/Abstractions/FeatureBase.cs](../../src/common/Elsa.Features/Abstractions/FeatureBase.cs) | Base class for most code-first features. |
| `DependsOnAttribute` | [src/common/Elsa.Features/Attributes/DependsOn.cs](../../src/common/Elsa.Features/Attributes/DependsOn.cs) | Declares feature dependencies. |
| `DependencyOfAttribute` | [src/common/Elsa.Features/Attributes/DependencyOf.cs](../../src/common/Elsa.Features/Attributes/DependencyOf.cs) | Declares optional dependency relationships. |
## Lifecycle
Feature classes usually use three lifecycle methods:
1. `Configure()`: declare additional feature configuration, scan activities, or add endpoint assemblies.
2. `ConfigureHostedServices()`: register hosted services with optional priority.
3. `Apply()`: add concrete services, options, stores, handlers, endpoints, and providers to DI.
`Module.Apply()` topologically sorts configured features and dependencies, configures them once, filters features with missing optional dependencies, registers hosted services, applies services, and finally registers installed-feature metadata.
A feature may introduce another feature from `Apply()`, for example by calling `Module.Configure<OtherFeature>()` directly or through a helper such as `AddActivity<T>()`. `Module.Apply()` keeps applying until no new features show up, so the introduced feature (and its dependencies) are configured and applied as well.
## Entry Points
The common public path is:
```csharp
services.AddElsa(elsa =>
{
elsa
.UseWorkflowManagement()
.UseWorkflowRuntime()
.UseWorkflowsApi();
});
```
Implementation links:
- [AddElsa and ConfigureElsa](../../src/modules/Elsa/Extensions/DependencyInjectionExtensions.cs)
- [CreateModule and Use<T>](../../src/common/Elsa.Features/Extensions/DependencyInjectionExtensions.cs)
- [ElsaFeature](../../src/modules/Elsa/Features/ElsaFeature.cs)
- [AppFeature](../../src/modules/Elsa/Features/AppFeature.cs)
`AppFeature` is a small wrapper that lets application-specific configuration run after the default `ElsaFeature` dependencies.
## Feature Dependencies
Feature dependencies are explicit attributes. Examples:
- [WorkflowsFeature](../../src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs) depends on system clock, expressions, mediator, default formatters, multitenancy, and commit strategies.
- [WorkflowManagementFeature](../../src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs) depends on string compression, mediator, memory cache, system clock, workflows, workflow definitions, and workflow instances.
- [WorkflowsApiFeature](../../src/modules/Elsa.Workflows.Api/Features/WorkflowsApiFeature.cs) depends on workflow instances, management, runtime, and SAS tokens.
This is why feature classes are the best way to learn a module. They encode its runtime assumptions.
## Module Properties
`IModule.Properties` is used as a shared bag during feature configuration. A concrete example is FastEndpoints assembly collection in [Elsa.Api.Common/Extensions/ModuleExtensions.cs](../../src/common/Elsa.Api.Common/Extensions/ModuleExtensions.cs). Features call `AddFastEndpointsAssembly`, and later `AddFastEndpointsFromModule` registers all collected assemblies with FastEndpoints.
## Shell Features
Many modules also have `ShellFeatures/*Feature.cs`. These implement CShells interfaces and allow modular server hosts to activate feature sets from configuration or packages. Shell features are parallel to code-first features:
- Code-first feature: [Elsa.Diagnostics.StructuredLogs/Features/StructuredLogsFeature.cs](../../src/modules/Elsa.Diagnostics.StructuredLogs/Features/StructuredLogsFeature.cs)
- Shell feature: [Elsa.Diagnostics.StructuredLogs/ShellFeatures/StructuredLogsFeature.cs](../../src/modules/Elsa.Diagnostics.StructuredLogs/ShellFeatures/StructuredLogsFeature.cs)
Use shell features when working on modular hosting, package discovery, or `Elsa.ModularServer.Web`. Use code-first features for normal host configuration and tests.
## Extension Method Pattern
Modules expose fluent extension methods in `Extensions/ModuleExtensions.cs` or related files. The method usually calls `module.Configure<TFeature>()` and returns `IModule`:
```csharp
public static IModule UseWorkflowsApi(this IModule module, Action<WorkflowsApiFeature>? configure = default)
{
module.Configure(configure);
return module;
}
```
When adding a new module, follow this shape:
- one `Features/*Feature.cs`
- one `ShellFeatures/*Feature.cs` if the module must work with CShells
- one `Extensions/ModuleExtensions.cs`
- tests that prove the feature registers its core contracts
## Common Pitfalls
- Do not register services in extension methods when the module already has a feature class. Put service registration in `Apply()`.
- Do not bypass dependencies with direct service provider access in unrelated modules. Add a contract and dependency if the relationship is real.
- Use `TryAdd*` for overridable defaults and normal `Add*` for deliberate multiple registrations such as handlers, validators, and descriptors.
- If a feature uses `Module.Configure<OtherFeature>()`, verify that the other feature is already a dependency or that optional behavior is intentional. Introducing a feature this way works from `Configure()` and from `Apply()`, but declaring `[DependsOn(typeof(OtherFeature))]` keeps the relationship visible and gives the dependency the normal ordering.