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>
6.2 KiB
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 | Holds IServiceCollection, module properties, configured features, hosted service descriptors, and Apply(). |
Module |
src/common/Elsa.Features/Implementations/Module.cs | Concrete feature graph builder and applier. |
IFeature |
src/common/Elsa.Features/Services/IFeature.cs | Feature lifecycle contract. |
FeatureBase |
src/common/Elsa.Features/Abstractions/FeatureBase.cs | Base class for most code-first features. |
DependsOnAttribute |
src/common/Elsa.Features/Attributes/DependsOn.cs | Declares feature dependencies. |
DependencyOfAttribute |
src/common/Elsa.Features/Attributes/DependencyOf.cs | Declares optional dependency relationships. |
Lifecycle
Feature classes usually use three lifecycle methods:
Configure(): declare additional feature configuration, scan activities, or add endpoint assemblies.ConfigureHostedServices(): register hosted services with optional priority.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:
services.AddElsa(elsa =>
{
elsa
.UseWorkflowManagement()
.UseWorkflowRuntime()
.UseWorkflowsApi();
});
Implementation links:
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 depends on system clock, expressions, mediator, default formatters, multitenancy, and commit strategies.
- WorkflowManagementFeature depends on string compression, mediator, memory cache, system clock, workflows, workflow definitions, and workflow instances.
- WorkflowsApiFeature 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. 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
- Shell feature: 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:
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.csif 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 normalAdd*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 fromConfigure()and fromApply(), but declaring[DependsOn(typeof(OtherFeature))]keeps the relationship visible and gives the dependency the normal ordering.