Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
62d31da
docs: Add design for ModuleResult discriminated union pattern
thomhurst Jan 7, 2026
b663d02
docs: add implementation plan for ModuleResult discriminated union
thomhurst Jan 7, 2026
78e6a4b
refactor(IModuleResult): add safe accessor properties for discriminat…
thomhurst Jan 7, 2026
bba491f
refactor(ModuleResult): implement discriminated union with Success/Fa…
thomhurst Jan 7, 2026
55739b3
refactor: remove SkippedModuleResult and TimedOutModuleResult (replac…
thomhurst Jan 7, 2026
c8fd00a
refactor(ModuleResultFactory): simplify to use ModuleResult static fa…
thomhurst Jan 7, 2026
bfe8050
refactor(ModuleExecutionPipeline): use discriminated union factory me…
thomhurst Jan 7, 2026
a977019
deprecate: mark ModuleFailedException and ModuleSkippedException as o…
thomhurst Jan 7, 2026
c994a53
test: update test helpers for discriminated union pattern
thomhurst Jan 7, 2026
9264354
test: update unit tests for discriminated union pattern
thomhurst Jan 7, 2026
2dac9b2
fix: update Build project modules for discriminated union pattern
thomhurst Jan 7, 2026
ae1597a
fix: add JSON serialization support and fix history repository handling
thomhurst Jan 7, 2026
fa184c0
fix: address code review issues for discriminated union PR
thomhurst Jan 7, 2026
21bb7c3
fix: address PR #1895 review comments
thomhurst Jan 7, 2026
0c84019
Merge main into feature/1869-module-result-discriminated-union
thomhurst Jan 7, 2026
00e57f7
fix: update DirectModuleHooksTests to use new discriminated union API
thomhurst Jan 7, 2026
2245536
fix: update Azure example modules to use ValueOrDefault
thomhurst Jan 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

198 changes: 198 additions & 0 deletions docs/plans/2026-01-07-module-result-discriminated-union.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
# ModuleResult Discriminated Union Design

**Issue:** #1869
**Date:** 2026-01-07
**Status:** Approved

## Problem

The current `ModuleResult<T>.Value` property throws exceptions when accessed on failed or skipped results:

```csharp
var result = await myModule;
var value = result.Value; // Throws ModuleFailedException or ModuleSkippedException!
```

This leads to:
- Implicit exceptions and poor control flow
- No compile-time exhaustiveness checking
- Awkward try-catch patterns around property access

## Solution

Replace with a discriminated union pattern using C# records:

```csharp
public abstract record ModuleResult<T> : IModuleResult
{
// Metadata (available on all outcomes)
public required string ModuleName { get; init; }
public required TimeSpan ModuleDuration { get; init; }
public required DateTimeOffset ModuleStart { get; init; }
public required DateTimeOffset ModuleEnd { get; init; }
public required Status ModuleStatus { get; init; }

// Quick checks
public bool IsSuccess => this is Success;
public bool IsFailure => this is Failure;
public bool IsSkipped => this is Skipped;

// Safe accessors (no exceptions)
public T? ValueOrDefault => this is Success s ? s.Value : default;
public Exception? ExceptionOrDefault => this is Failure f ? f.Exception : null;
public SkipDecision? SkipDecisionOrDefault => this is Skipped sk ? sk.Decision : null;

// Computed for compatibility
public ModuleResultType ModuleResultType => this switch
{
Success => ModuleResultType.Success,
Failure => ModuleResultType.Failure,
Skipped => ModuleResultType.Skipped,
_ => throw new InvalidOperationException()
};

// Discriminated variants
public sealed record Success(T Value) : ModuleResult<T>;
public sealed record Failure(Exception Exception) : ModuleResult<T>;
public sealed record Skipped(SkipDecision Decision) : ModuleResult<T>;

// Prevent external inheritance
private ModuleResult() { }
}
```

## Usage Examples

### Pattern Matching (Recommended)

```csharp
var result = await buildModule;

switch (result)
{
case ModuleResult<string>.Success { Value: var value }:
Console.WriteLine($"Got: {value}");
break;
case ModuleResult<string>.Failure { Exception: var ex }:
Console.WriteLine($"Failed: {ex.Message}");
break;
case ModuleResult<string>.Skipped { Decision.Reason: var reason }:

Copilot AI Jan 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Invalid property pattern syntax. The pattern { Decision.Reason: var reason } is not valid C# syntax. To access nested properties in pattern matching, use { Decision: { Reason: var reason } } or { Decision.Reason: var reason } only works if Decision is the declaring type. The correct syntax should be case ModuleResult&lt;string&gt;.Skipped { Decision: var skip }: followed by accessing skip.Reason.

Suggested change
case ModuleResult<string>.Skipped { Decision.Reason: var reason }:
case ModuleResult<string>.Skipped { Decision: { Reason: var reason } }:

Copilot uses AI. Check for mistakes.
Console.WriteLine($"Skipped: {reason}");
break;
}
```

### Match Helper

```csharp
string message = result.Match(
onSuccess: value => $"Got: {value}",
onFailure: ex => $"Error: {ex.Message}",
onSkipped: skip => $"Skipped: {skip.Reason}"
);
```

### Quick Checks

```csharp
if (result.IsSuccess)
{
DoSomething(result.ValueOrDefault!);
}
```

## Helper Methods

```csharp
// Match - transform to single type
public TResult Match<TResult>(
Func<T, TResult> onSuccess,
Func<Exception, TResult> onFailure,
Func<SkipDecision, TResult> onSkipped);

// Switch - side effects
public void Switch(
Action<T> onSuccess,
Action<Exception> onFailure,
Action<SkipDecision> onSkipped);
```

## JSON Serialization

Uses .NET 7+ polymorphic serialization:

```csharp
[JsonDerivedType(typeof(Success), "Success")]
[JsonDerivedType(typeof(Failure), "Failure")]
[JsonDerivedType(typeof(Skipped), "Skipped")]
public abstract record ModuleResult<T> : IModuleResult
```

Output:
```json
{
"$type": "Success",
"Value": "build output",
"ModuleName": "BuildModule",
"ModuleDuration": "00:01:23"
}
```
Comment on lines +120 to +139

Copilot AI Jan 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documentation-code discrepancy. The design document states that JSON serialization uses .NET 7+ polymorphic serialization with [JsonDerivedType] attributes, but the actual implementation uses a custom ModuleResultJsonConverterFactory and manual converter. Update the documentation to reflect the actual implementation approach, or explain why the custom converter was chosen instead.

Copilot uses AI. Check for mistakes.

## Non-Generic Interface

For type-erased scenarios (registries, summaries):

```csharp
public interface IModuleResult
{
string ModuleName { get; }
TimeSpan ModuleDuration { get; }
DateTimeOffset ModuleStart { get; }
DateTimeOffset ModuleEnd { get; }
Status ModuleStatus { get; }
ModuleResultType ModuleResultType { get; }

object? ValueOrDefault { get; }
Exception? ExceptionOrDefault { get; }
SkipDecision? SkipDecisionOrDefault { get; }
}
```

## Factory Methods

Internal factory methods for engine use:

```csharp
internal static Success CreateSuccess(T value, ModuleExecutionContext ctx);
internal static Failure CreateFailure(Exception exception, ModuleExecutionContext ctx);
internal static Skipped CreateSkipped(SkipDecision decision, ModuleExecutionContext ctx);
```

## Breaking Changes

### Removed
- `ModuleResult<T>.Value` property (the throwing one)
- `ModuleFailedException` class
- `ModuleSkippedException` class
- `SkippedModuleResult<T>` subclass
- `TimedOutModuleResult<T>` subclass
Comment on lines +173 to +176

Copilot AI Jan 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documentation inaccuracy. The breaking changes section states that ModuleFailedException and ModuleSkippedException classes are "Removed", but they are actually deprecated with [Obsolete] attributes, not deleted. Update the documentation to reflect that these classes are deprecated rather than removed to provide a more accurate migration path.

Copilot uses AI. Check for mistakes.

### Preserved
- `ModuleResultType` property (now computed)
- `IModuleResult` interface
- All metadata property names
- JSON serialization support

## Target

.NET 10 only - no backward compatibility shims needed.

## Files to Modify

1. `src/ModularPipelines/Models/ModuleResult.cs` - Main implementation
2. `src/ModularPipelines/Models/IModuleResult.cs` - Update interface
3. `src/ModularPipelines/Engine/Execution/ModuleResultFactory.cs` - Update factory
4. `src/ModularPipelines/Exceptions/` - Remove ModuleFailedException, ModuleSkippedException
5. `src/ModularPipelines/Models/SkippedModuleResult.cs` - Delete
6. `src/ModularPipelines/Models/TimedOutModuleResult.cs` - Delete
7. All files accessing `.Value` - Update to pattern matching
4 changes: 2 additions & 2 deletions src/ModularPipelines.Build/Modules/CreateReleaseModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,9 @@ public Task<SkipDecision> ShouldSkip(IPipelineContext context)
}

return await context.GitHub().Client.Repository.Release.Create(repositoryId,
new NewRelease($"v{versionInfoResult.Value}")
new NewRelease($"v{versionInfoResult.ValueOrDefault}")
{
Name = versionInfoResult.Value,
Name = versionInfoResult.ValueOrDefault,
GenerateReleaseNotes = true,
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public class FindProjectDependenciesModule : Module<FindProjectDependenciesModul

var dependencies = new List<File>();

foreach (var file in projects.Value!)
foreach (var file in projects.ValueOrDefault!)
{
var projectRootElement = ProjectRootElement.Open(file)!;

Expand All @@ -27,7 +27,7 @@ public class FindProjectDependenciesModule : Module<FindProjectDependenciesModul
foreach (var reference in projectReferences)
{
var name = Path.GetFileName(reference);
var project = projects.Value!.FirstOrDefault(x => x.Name == name);
var project = projects.ValueOrDefault!.FirstOrDefault(x => x.Name == name);

if (project != null)
{
Expand All @@ -36,7 +36,7 @@ public class FindProjectDependenciesModule : Module<FindProjectDependenciesModul
}
}

var projectDependencies = new ProjectDependencies(Dependencies: dependencies.Distinct().ToList(), Others: projects.Value!.Except(dependencies).Distinct().ToList());
var projectDependencies = new ProjectDependencies(Dependencies: dependencies.Distinct().ToList(), Others: projects.ValueOrDefault!.Except(dependencies).Distinct().ToList());

LogProjects(context, projectDependencies);

Expand Down
2 changes: 1 addition & 1 deletion src/ModularPipelines.Build/Modules/GenerateReadMeModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public class GenerateReadMeModule : Module<IDictionary<string, object>>, IAlways

var projects = context.GetModule<FindProjectsModule, IReadOnlyList<File>>();

foreach (var project in projects.Value!
foreach (var project in projects.ValueOrDefault!
.Where(x => !x.NameWithoutExtension.StartsWith("ModularPipelines.Analyzers")))
{
var moduleName = project.NameWithoutExtension;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public Task<bool> ShouldIgnoreFailures(IPipelineContext context, Exception excep
return await context.DotNet().Nuget.Add.Source(new DotNetNugetAddSourceOptions
{
Name = "ModularPipelinesLocalNuGet",
Packagesourcepath = localNugetPathResult.Value.AssertExists(),
Packagesourcepath = localNugetPathResult.ValueOrDefault.AssertExists(),
}, cancellationToken: cancellationToken);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ public class UploadPackagesToLocalNuGetModule : Module<CommandResult[]>

return await NugetUploadHelper.UploadPackagesAsync(
context,
packagePaths.Value!,
source: localRepoLocation.Value.AssertExists()!.Path,
packagePaths.ValueOrDefault!,
source: localRepoLocation.ValueOrDefault.AssertExists()!.Path,
apiKey: null,
cancellationToken);
}
Expand Down
12 changes: 6 additions & 6 deletions src/ModularPipelines.Build/Modules/PackProjectsModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,23 +28,23 @@ public class PackProjectsModule : Module<CommandResult[]>

var changedFiles = context.GetModule<ChangedFilesInPullRequestModule, IReadOnlyList<File>>();

var dependencies = await projectFiles.Value!.Dependencies
var dependencies = await projectFiles.ValueOrDefault!.Dependencies
.ToAsyncProcessorBuilder()
.SelectAsync(async projectFile => await Pack(context, cancellationToken, projectFile, packageVersion))
.ProcessOneAtATime();

var gitVersioningInformation = await context.Git().Versioning.GetGitVersioningInformation();

var others = await projectFiles.Value!.Others
var others = await projectFiles.ValueOrDefault!.Others
.Where(x =>
{
if (changedFiles.SkipDecision.ShouldSkip)
if (changedFiles.SkipDecisionOrDefault.ShouldSkip)

Copilot AI Jan 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential null reference exception. SkipDecisionOrDefault returns SkipDecision? (nullable), so accessing .ShouldSkip directly will throw a NullReferenceException if the result is not skipped. Use null-conditional operator or check for null first.

Suggested change
if (changedFiles.SkipDecisionOrDefault.ShouldSkip)
if (changedFiles.SkipDecisionOrDefault?.ShouldSkip == true)

Copilot uses AI. Check for mistakes.
{
return true;
}

return ProjectHasChanged(x,
changedFiles.Value!, context);
changedFiles.ValueOrDefault!, context);
})
.ToAsyncProcessorBuilder()
.SelectAsync(async projectFile => await Pack(context, cancellationToken, projectFile, packageVersion))
Expand Down Expand Up @@ -79,8 +79,8 @@ private static async Task<CommandResult> Pack(IModuleContext context, Cancellati
NoRestore = true,
Properties = new List<KeyValue>
{
("PackageVersion", packageVersion.Value!),
("Version", packageVersion.Value!),
("PackageVersion", packageVersion.ValueOrDefault!),
("Version", packageVersion.ValueOrDefault!),
},
}, cancellationToken: cancellationToken);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ public class PackagePathsParserModule : Module<List<File>>
{
var packPackagesModuleResult = context.GetModule<PackProjectsModule, CommandResult[]>();

return Task.FromResult<List<File>?>(packPackagesModuleResult.Value!
return Task.FromResult<List<File>?>(packPackagesModuleResult.ValueOrDefault!
.Select(x => x.StandardOutput)
.Select(x => x.Split(PackageCreationSuccessPrefix)[1])
.Select(x => x.Split(PackagePathSuffix)[0])
Expand Down
4 changes: 2 additions & 2 deletions src/ModularPipelines.Build/Modules/PushVersionTagModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public Task<bool> ShouldIgnoreFailures(IPipelineContext context, Exception excep
{
var versionInformation = ((IModuleContext) context).GetModule<NugetVersionGeneratorModule, string>();

return Task.FromResult(exception.Message.Contains($"tag 'v{versionInformation.Value!}' already exists"));
return Task.FromResult(exception.Message.Contains($"tag 'v{versionInformation.ValueOrDefault!}' already exists"));
}

public override async Task<CommandResult?> ExecuteAsync(IModuleContext context, CancellationToken cancellationToken)
Expand All @@ -27,7 +27,7 @@ public Task<bool> ShouldIgnoreFailures(IPipelineContext context, Exception excep

await context.Git().Commands.Tag(new GitTagOptions
{
Arguments = [$"v{versionInformation.Value!}"],
Arguments = [$"v{versionInformation.ValueOrDefault!}"],
}, token: cancellationToken);

return await context.Git().Commands.Push(new GitPushOptions
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ public Task<SkipDecision> ShouldSkip(IPipelineContext context)

return await NugetUploadHelper.UploadPackagesAsync(
context,
packagePaths.Value!,
packagePaths.ValueOrDefault!,
source: "https://api.nuget.org/v3/index.json",
apiKey: _nugetSettings.Value.ApiKey,
cancellationToken);
Expand Down
4 changes: 2 additions & 2 deletions src/ModularPipelines.GitHub/GitHubMarkdownSummaryGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ private async Task<string> GetException(PipelineSummary pipelineSummary)

var exception = results
.FirstOrDefault(x => x.ModuleStatus == Status.Failed)
?.Exception
?? results.Select(x => x.Exception).FirstOrDefault();
?.ExceptionOrDefault
?? results.Select(x => x.ExceptionOrDefault).FirstOrDefault();

if (exception is null)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,5 @@ public interface IModuleEndEventReceiver
/// <param name="context">The event context providing module information and control flow.</param>
/// <param name="result">The result of the module execution.</param>
/// <returns>A task representing the async operation.</returns>
Task OnModuleEndAsync(IModuleEventContext context, ModuleResult result);
Task OnModuleEndAsync(IModuleEventContext context, IModuleResult result);
}
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ public async Task InvokeStartReceiversAsync(
public async Task InvokeEndReceiversAsync(
IEnumerable<IModuleEndEventReceiver> receivers,
IModuleEventContext context,
ModuleResult result)
IModuleResult result)
{
foreach (var receiver in receivers)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ internal interface IAttributeEventInvoker

Task InvokeStartReceiversAsync(IEnumerable<IModuleStartEventReceiver> receivers, IModuleEventContext context);

Task InvokeEndReceiversAsync(IEnumerable<IModuleEndEventReceiver> receivers, IModuleEventContext context, ModuleResult result);
Task InvokeEndReceiversAsync(IEnumerable<IModuleEndEventReceiver> receivers, IModuleEventContext context, IModuleResult result);

Task InvokeFailureReceiversAsync(IEnumerable<IModuleFailureEventReceiver> receivers, IModuleEventContext context, Exception exception);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ public async Task InvokeEndEventAsync(ModuleLifecycleContext context, Enums.Stat
_metadataRegistry,
context.CancellationToken);

await _attributeEventInvoker.InvokeEndReceiversAsync(receivers, eventContext, (ModuleResult)result).ConfigureAwait(false);
await _attributeEventInvoker.InvokeEndReceiversAsync(receivers, eventContext, result).ConfigureAwait(false);
}

/// <inheritdoc />
Expand Down
Loading
Loading