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

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:

  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:

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:

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.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.