feat: Implement discriminated union pattern for ModuleResult<T>#1895
feat: Implement discriminated union pattern for ModuleResult<T>#1895
Conversation
Documents the approved design for issue #1869 - replacing exception-based result handling with a type-safe discriminated union pattern using C# records. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…ilure/Skipped variants 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…thods - Replace direct ModuleResult constructors with factory methods: - CreateSuccess for successful results - CreateFailure for failed/cancelled/timeout results - CreateSkipped for skipped results - Remove TimedOutModuleResult and SkippedModuleResult usages - Update IModuleEndEventReceiver and related interfaces to use IModuleResult - Fix init-only ModuleStatus mutation using record 'with' expression - Update PipelineSummary to use factory methods for fallback results 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Update ModuleResultAssertions to use the new safe accessors: - Replace .Value with .ValueOrDefault - Replace .Exception with .ExceptionOrDefault 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Update all unit tests to use the new safe accessors instead of throwing Value/Exception properties: - Replace .Value with .ValueOrDefault - Replace .Exception with .ExceptionOrDefault - Replace .SkipDecision with .SkipDecisionOrDefault - Replace .HasValue with .IsSuccess - Update IModuleEndEventReceiver implementations to use IModuleResult - Update ModuleResult constructor usage to use CreateSuccess factory Also fix GitHubMarkdownSummaryGenerator.cs to use ExceptionOrDefault. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Replace .Value with .ValueOrDefault and .SkipDecision with .SkipDecisionOrDefault in all Build project modules to use the new safe accessors introduced by the discriminated union pattern. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add ModuleResultJsonConverterFactory for polymorphic JSON serialization - Add ExceptionJsonConverter for serializing/deserializing Exception objects - Fix HandleSkipped to set completion source after history check - Fix IgnoredModuleResultRegistrar to update status to UsedHistory - Add ModuleResultFactory.WithStatus for type-erased status updates 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
SummaryThis PR implements a discriminated union pattern for ModuleResult, replacing the exception-throwing .Value property with type-safe pattern matching via Success/Failure/Skipped variants. Critical Issues1. Missing .Value property - Breaking Change Documentation The old
Recommendation: Verify this is intentional for a major version release. If consumers exist outside this repo, consider adding a migration guide. 2. JSON Converter Implementation - Exception Handling In the
Location: src/ModularPipelines/Models/ModuleResult.cs (the JSON converter section) Recommendation: Review the 3. Factory Method Timing Logic - Potential Bug In the factory methods ( ModuleDuration = (ctx.EndTime == DateTimeOffset.MinValue ? DateTimeOffset.Now : ctx.EndTime) -
(ctx.StartTime == DateTimeOffset.MinValue ? DateTimeOffset.Now : ctx.StartTime)Issue: If both StartTime and EndTime are MinValue, this calculates duration as 0, which might mask timing issues. If only one is MinValue, the calculation uses DateTimeOffset.Now inconsistently. Location: src/ModularPipelines/Models/ModuleResult.cs:287-289, :299-301, :311-313 Recommendation: Either throw an exception if timing data is invalid, or use a consistent fallback (e.g., always use Now for both if either is MinValue). Suggestions1. Pattern Matching Exhaustiveness The Suggestion: Consider removing the default case to get compiler exhaustiveness checking, or add a comment explaining why it's needed (e.g., for future-proofing). 2. Nullability on Success.Value The Example: ModuleResult<string?>.Success(null) // Valid, but confusingSuggestion: Consider documenting expected behavior when T itself is nullable, or constrain T to be non-nullable in Success if that's the intent. 3. Obsolete Attribute Message The obsolete messages on Suggestion: Update the message to something like: "Use pattern matching on ModuleResult instead. The .Value property has been removed in favor of .ValueOrDefault or pattern matching." Previous Review StatusCannot access previous comments due to GitHub token scope limitations. VerdictCritical issues #1, #2, and #3 should be addressed:
The overall design is solid and follows C# discriminated union best practices. The changes are well-structured and comprehensive. |
There was a problem hiding this comment.
Pull request overview
This PR implements a discriminated union pattern for ModuleResult<T>, replacing the exception-throwing Value property with three explicit sealed variants: Success, Failure, and Skipped.
Key changes:
- BREAKING:
ModuleResult<T>is now an abstract record with three sealed record variants instead of a class with subclasses - Replaced throwing
Valueproperty with safeValueOrDefault,ExceptionOrDefault, andSkipDecisionOrDefaultaccessors - Added pattern matching helpers (
MatchandSwitchmethods) and boolean checks (IsSuccess,IsFailure,IsSkipped) - Implemented custom JSON serialization via
ModuleResultJsonConverterFactoryinstead of System.Text.Json polymorphic attributes - Deprecated (not removed)
ModuleFailedExceptionandModuleSkippedExceptionwith[Obsolete]attributes - Updated all consuming code to use the new safe accessors
Reviewed changes
Copilot reviewed 43 out of 43 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
src/ModularPipelines/Models/ModuleResult.cs |
Complete rewrite as discriminated union with custom JSON converters |
src/ModularPipelines/Models/IModuleResult.cs |
Added safe accessor properties for non-throwing access |
src/ModularPipelines/Models/SkippedModuleResult.cs |
Deleted - replaced by Skipped variant |
src/ModularPipelines/Models/TimedOutModuleResult.cs |
Deleted - timeouts now use Failure variant |
src/ModularPipelines/Models/PipelineSummary.cs |
Updated to use CreateFailure factory method |
src/ModularPipelines/Engine/ModuleExecutionPipeline.cs |
Updated to use factory methods and record with expressions |
src/ModularPipelines/Engine/Execution/ModuleResultFactory.cs |
Simplified to delegate to ModuleResult<T> factory methods, added WithStatus helper |
src/ModularPipelines/Engine/Executors/IgnoredModuleResultRegistrar.cs |
Updated to use WithStatus for immutable status updates |
src/ModularPipelines/Engine/Execution/ModuleLifecycleEventInvoker.cs |
Updated to use IModuleResult interface |
src/ModularPipelines/Engine/Attributes/IAttributeEventInvoker.cs |
Changed parameter from ModuleResult to IModuleResult |
src/ModularPipelines/Engine/Attributes/AttributeEventInvoker.cs |
Updated parameter type to IModuleResult |
src/ModularPipelines/Attributes/Events/IModuleEndEventReceiver.cs |
Changed parameter from ModuleResult to IModuleResult |
src/ModularPipelines/Exceptions/ModuleFailedException.cs |
Added [Obsolete] attribute |
src/ModularPipelines/Exceptions/ModuleSkippedException.cs |
Added [Obsolete] attribute |
src/ModularPipelines.GitHub/GitHubMarkdownSummaryGenerator.cs |
Updated to use ExceptionOrDefault |
src/ModularPipelines.Build/Modules/*.cs |
Updated all build modules to use ValueOrDefault |
test/ModularPipelines.UnitTests/*.cs |
Updated tests to use safe accessors |
test/ModularPipelines.TestHelpers/Assertions/ModuleResultAssertions.cs |
Updated assertions to use safe accessors |
test/ModularPipelines.Azure.UnitTests/AzureCommandTests.cs |
Updated to use ValueOrDefault |
docs/plans/2026-01-07-module-result-discriminated-union.md |
Design document for the discriminated union pattern |
docs/plans/2026-01-07-module-result-discriminated-union-implementation.md |
Implementation plan document |
| public override Exception? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) | ||
| { | ||
| get | ||
| if (reader.TokenType == JsonTokenType.Null) | ||
| { | ||
| if (ModuleResultType == ModuleResultType.Failure) | ||
| return null; | ||
| } | ||
|
|
||
| string? typeName = null; | ||
| string? message = null; | ||
| string? stackTrace = null; | ||
|
|
||
| while (reader.Read()) | ||
| { | ||
| if (reader.TokenType == JsonTokenType.EndObject) | ||
| { | ||
| throw new ModuleFailedException(ModuleType!, Exception!); | ||
| break; | ||
| } | ||
|
|
||
| if (ModuleResultType == ModuleResultType.Skipped) | ||
| if (reader.TokenType == JsonTokenType.PropertyName) | ||
| { | ||
| throw new ModuleSkippedException(ModuleName); | ||
| } | ||
| var propertyName = reader.GetString(); | ||
| reader.Read(); | ||
|
|
||
| return _value; | ||
| switch (propertyName) | ||
| { | ||
| case "Type": | ||
| typeName = reader.GetString(); | ||
| break; | ||
| case "Message": | ||
| message = reader.GetString(); | ||
| break; | ||
| case "StackTrace": | ||
| stackTrace = reader.GetString(); | ||
| break; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private set | ||
| // Try to reconstruct the original exception type if possible | ||
| if (typeName != null) | ||
| { | ||
| _value = value; | ||
| var exceptionType = Type.GetType(typeName); | ||
| if (exceptionType != null && typeof(Exception).IsAssignableFrom(exceptionType)) | ||
| { | ||
| try | ||
| { | ||
| var ex = Activator.CreateInstance(exceptionType, message) as Exception; | ||
| if (ex != null) | ||
| { | ||
| return ex; | ||
| } | ||
| } | ||
| catch | ||
| { | ||
| // Fall through to default | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return new Exception(message ?? "Deserialized exception"); | ||
| } |
There was a problem hiding this comment.
The StackTrace property is serialized but never restored when deserializing exceptions. The deserialized exception will not have the original stack trace, which could make debugging difficult. Consider using reflection to set the stack trace field if preserving it is important, or document this limitation.
| ### Removed | ||
| - `ModuleResult<T>.Value` property (the throwing one) | ||
| - `ModuleFailedException` class | ||
| - `ModuleSkippedException` class | ||
| - `SkippedModuleResult<T>` subclass | ||
| - `TimedOutModuleResult<T>` subclass |
There was a problem hiding this comment.
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.
| .Where(x => | ||
| { | ||
| if (changedFiles.SkipDecision.ShouldSkip) | ||
| if (changedFiles.SkipDecisionOrDefault.ShouldSkip) |
There was a problem hiding this comment.
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.
| if (changedFiles.SkipDecisionOrDefault.ShouldSkip) | |
| if (changedFiles.SkipDecisionOrDefault?.ShouldSkip == true) |
| internal static Success CreateSuccess(T value, ModuleExecutionContext ctx) => new(value) | ||
| { | ||
| ModuleName = ctx.ModuleType.Name, | ||
| ModuleDuration = (ctx.EndTime == DateTimeOffset.MinValue ? DateTimeOffset.Now : ctx.EndTime) - | ||
| (ctx.StartTime == DateTimeOffset.MinValue ? DateTimeOffset.Now : ctx.StartTime), | ||
| ModuleStart = ctx.StartTime == DateTimeOffset.MinValue ? DateTimeOffset.Now : ctx.StartTime, | ||
| ModuleEnd = ctx.EndTime == DateTimeOffset.MinValue ? DateTimeOffset.Now : ctx.EndTime, | ||
| ModuleStatus = ctx.Status, | ||
| ModuleType = ctx.ModuleType | ||
| }; | ||
|
|
||
| internal static Failure CreateFailure(Exception exception, ModuleExecutionContext ctx) => new(exception) | ||
| { | ||
| ModuleName = ctx.ModuleType.Name, | ||
| ModuleDuration = (ctx.EndTime == DateTimeOffset.MinValue ? DateTimeOffset.Now : ctx.EndTime) - | ||
| (ctx.StartTime == DateTimeOffset.MinValue ? DateTimeOffset.Now : ctx.StartTime), | ||
| ModuleStart = ctx.StartTime == DateTimeOffset.MinValue ? DateTimeOffset.Now : ctx.StartTime, | ||
| ModuleEnd = ctx.EndTime == DateTimeOffset.MinValue ? DateTimeOffset.Now : ctx.EndTime, | ||
| ModuleStatus = ctx.Status, | ||
| ModuleType = ctx.ModuleType | ||
| }; | ||
|
|
||
| internal static Skipped CreateSkipped(SkipDecision decision, ModuleExecutionContext ctx) => new(decision) | ||
| { | ||
| ModuleName = ctx.ModuleType.Name, | ||
| ModuleDuration = (ctx.EndTime == DateTimeOffset.MinValue ? DateTimeOffset.Now : ctx.EndTime) - | ||
| (ctx.StartTime == DateTimeOffset.MinValue ? DateTimeOffset.Now : ctx.StartTime), | ||
| ModuleStart = ctx.StartTime == DateTimeOffset.MinValue ? DateTimeOffset.Now : ctx.StartTime, | ||
| ModuleEnd = ctx.EndTime == DateTimeOffset.MinValue ? DateTimeOffset.Now : ctx.EndTime, | ||
| ModuleStatus = ctx.Status, | ||
| ModuleType = ctx.ModuleType | ||
| }; | ||
|
|
There was a problem hiding this comment.
The duration calculation logic is duplicated across all three factory methods (CreateSuccess, CreateFailure, CreateSkipped). Consider extracting this into a private helper method to reduce code duplication and make maintenance easier.
| internal static Success CreateSuccess(T value, ModuleExecutionContext ctx) => new(value) | |
| { | |
| ModuleName = ctx.ModuleType.Name, | |
| ModuleDuration = (ctx.EndTime == DateTimeOffset.MinValue ? DateTimeOffset.Now : ctx.EndTime) - | |
| (ctx.StartTime == DateTimeOffset.MinValue ? DateTimeOffset.Now : ctx.StartTime), | |
| ModuleStart = ctx.StartTime == DateTimeOffset.MinValue ? DateTimeOffset.Now : ctx.StartTime, | |
| ModuleEnd = ctx.EndTime == DateTimeOffset.MinValue ? DateTimeOffset.Now : ctx.EndTime, | |
| ModuleStatus = ctx.Status, | |
| ModuleType = ctx.ModuleType | |
| }; | |
| internal static Failure CreateFailure(Exception exception, ModuleExecutionContext ctx) => new(exception) | |
| { | |
| ModuleName = ctx.ModuleType.Name, | |
| ModuleDuration = (ctx.EndTime == DateTimeOffset.MinValue ? DateTimeOffset.Now : ctx.EndTime) - | |
| (ctx.StartTime == DateTimeOffset.MinValue ? DateTimeOffset.Now : ctx.StartTime), | |
| ModuleStart = ctx.StartTime == DateTimeOffset.MinValue ? DateTimeOffset.Now : ctx.StartTime, | |
| ModuleEnd = ctx.EndTime == DateTimeOffset.MinValue ? DateTimeOffset.Now : ctx.EndTime, | |
| ModuleStatus = ctx.Status, | |
| ModuleType = ctx.ModuleType | |
| }; | |
| internal static Skipped CreateSkipped(SkipDecision decision, ModuleExecutionContext ctx) => new(decision) | |
| { | |
| ModuleName = ctx.ModuleType.Name, | |
| ModuleDuration = (ctx.EndTime == DateTimeOffset.MinValue ? DateTimeOffset.Now : ctx.EndTime) - | |
| (ctx.StartTime == DateTimeOffset.MinValue ? DateTimeOffset.Now : ctx.StartTime), | |
| ModuleStart = ctx.StartTime == DateTimeOffset.MinValue ? DateTimeOffset.Now : ctx.StartTime, | |
| ModuleEnd = ctx.EndTime == DateTimeOffset.MinValue ? DateTimeOffset.Now : ctx.EndTime, | |
| ModuleStatus = ctx.Status, | |
| ModuleType = ctx.ModuleType | |
| }; | |
| private static (DateTimeOffset ModuleStart, DateTimeOffset ModuleEnd, TimeSpan ModuleDuration) GetModuleTiming(ModuleExecutionContext ctx) | |
| { | |
| var moduleStart = ctx.StartTime == DateTimeOffset.MinValue ? DateTimeOffset.Now : ctx.StartTime; | |
| var moduleEnd = ctx.EndTime == DateTimeOffset.MinValue ? DateTimeOffset.Now : ctx.EndTime; | |
| var moduleDuration = moduleEnd - moduleStart; | |
| return (moduleStart, moduleEnd, moduleDuration); | |
| } | |
| internal static Success CreateSuccess(T value, ModuleExecutionContext ctx) | |
| { | |
| var timing = GetModuleTiming(ctx); | |
| return new(value) | |
| { | |
| ModuleName = ctx.ModuleType.Name, | |
| ModuleDuration = timing.ModuleDuration, | |
| ModuleStart = timing.ModuleStart, | |
| ModuleEnd = timing.ModuleEnd, | |
| ModuleStatus = ctx.Status, | |
| ModuleType = ctx.ModuleType | |
| }; | |
| } | |
| internal static Failure CreateFailure(Exception exception, ModuleExecutionContext ctx) | |
| { | |
| var timing = GetModuleTiming(ctx); | |
| return new(exception) | |
| { | |
| ModuleName = ctx.ModuleType.Name, | |
| ModuleDuration = timing.ModuleDuration, | |
| ModuleStart = timing.ModuleStart, | |
| ModuleEnd = timing.ModuleEnd, | |
| ModuleStatus = ctx.Status, | |
| ModuleType = ctx.ModuleType | |
| }; | |
| } | |
| internal static Skipped CreateSkipped(SkipDecision decision, ModuleExecutionContext ctx) | |
| { | |
| var timing = GetModuleTiming(ctx); | |
| return new(decision) | |
| { | |
| ModuleName = ctx.ModuleType.Name, | |
| ModuleDuration = timing.ModuleDuration, | |
| ModuleStart = timing.ModuleStart, | |
| ModuleEnd = timing.ModuleEnd, | |
| ModuleStatus = ctx.Status, | |
| ModuleType = ctx.ModuleType | |
| }; | |
| } |
| return discriminator switch | ||
| { | ||
| "Success" => new ModuleResult<T>.Success(value!) | ||
| { | ||
| ModuleName = moduleName!, | ||
| ModuleDuration = moduleDuration, | ||
| ModuleStart = moduleStart, | ||
| ModuleEnd = moduleEnd, | ||
| ModuleStatus = moduleStatus | ||
| }, | ||
| "Failure" => new ModuleResult<T>.Failure(exception!) | ||
| { | ||
| ModuleName = moduleName!, | ||
| ModuleDuration = moduleDuration, | ||
| ModuleStart = moduleStart, | ||
| ModuleEnd = moduleEnd, | ||
| ModuleStatus = moduleStatus | ||
| }, | ||
| "Skipped" => new ModuleResult<T>.Skipped(skipDecision!) | ||
| { | ||
| ModuleName = moduleName!, | ||
| ModuleDuration = moduleDuration, | ||
| ModuleStart = moduleStart, | ||
| ModuleEnd = moduleEnd, | ||
| ModuleStatus = moduleStatus | ||
| }, | ||
| _ => throw new JsonException($"Unknown discriminator: {discriminator}") | ||
| }; |
There was a problem hiding this comment.
The JSON deserialization uses null-forgiving operators (!) on values that might actually be null if the JSON is malformed (e.g., missing required fields like "ModuleName", "Value" for Success, "Exception" for Failure, or "Decision" for Skipped). This could lead to NullReferenceExceptions at runtime. Consider validating that required fields are present before creating the result instances.
| case ModuleResult<string>.Failure { Exception: var ex }: | ||
| Console.WriteLine($"Failed: {ex.Message}"); | ||
| break; | ||
| case ModuleResult<string>.Skipped { Decision.Reason: var reason }: |
There was a problem hiding this comment.
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<string>.Skipped { Decision: var skip }: followed by accessing skip.Reason.
| case ModuleResult<string>.Skipped { Decision.Reason: var reason }: | |
| case ModuleResult<string>.Skipped { Decision: { Reason: var reason } }: |
| ## 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" | ||
| } | ||
| ``` |
There was a problem hiding this comment.
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.
| [JsonDerivedType(typeof(Success), "Success")] | ||
| [JsonDerivedType(typeof(Failure), "Failure")] | ||
| [JsonDerivedType(typeof(Skipped), "Skipped")] |
There was a problem hiding this comment.
Documentation-code discrepancy. The implementation plan shows using [JsonDerivedType] attributes for polymorphic serialization, but the actual implementation uses a custom ModuleResultJsonConverterFactory. This inconsistency between the plan and implementation should be addressed - either update the plan to reflect the custom converter approach, or explain why it was necessary to deviate from the planned approach.
| [JsonDerivedType(typeof(Success), "Success")] | |
| [JsonDerivedType(typeof(Failure), "Failure")] | |
| [JsonDerivedType(typeof(Skipped), "Skipped")] | |
| /// NOTE: In the concrete implementation, polymorphic serialization for | |
| /// ModuleResult<T> is handled by a custom ModuleResultJsonConverterFactory | |
| /// registered in JsonSerializerOptions, rather than JsonDerivedType attributes. |
- Fix factory method timing bug: use consistent fallback when either
StartTime or EndTime is MinValue. Now uses TimeSpan.Zero for duration
instead of mixing Now values that could be called at different times.
- Improve JSON Exception security:
- Use FullName instead of AssemblyQualifiedName to avoid leaking
assembly version, culture, and public key token information
- Add security check to only allow System namespace exception types
during deserialization to prevent type injection attacks
- Add comprehensive security documentation comments
- Update obsolete attribute messages in ModuleFailedException and
ModuleSkippedException to accurately reflect that .Value property
was removed (not just that exceptions are no longer thrown)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
SummaryThis PR adds design documentation for a discriminated union refactoring and updates all code usages from Critical IssuesBLOCKING: This PR will not compile ❌ The PR changes all usages of The current
The PR adds design documents ( Required fix: Either:
The implementation plan itself is comprehensive and well-structured, but this PR appears to be incomplete - it has the documentation and the usage updates, but not the core model changes. SuggestionsNone - fix the critical issue first. Previous Review StatusCannot access previous comments due to token scope limitations. Verdict
|
- Fix null reference in PackProjectsModule.cs using null-conditional operator - Fix documentation: classes are deprecated, not removed - Fix property pattern syntax in design doc example - Update implementation plan to reflect custom JsonConverter usage - Document StackTrace serialization limitation in exception converter Addresses PR #1895 review feedback 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Addressed Review CommentsPushed commit 21bb7c3 to address the review feedback: Fixed Issues:
Not Changed (By Design):
|
SummaryThis PR adds planning documentation for a discriminated union refactoring of ModuleResult and updates existing code to use .ValueOrDefault instead of .Value. Critical Issues❌ BLOCKING: Breaking changes without implementation The PR changes dozens of call sites from Evidence from the diff:
This will cause compilation failures. The implementation changes described in the planning documents must be completed before changing call sites. Implementation order should be:
OR keep this as documentation-only (don't change call sites). SuggestionsDocumentation Quality: Minor: Task 5 Step 1 command grep -rn "result\.Value" src/ModularPipelines/Engine/ --include="*.cs"Verdict
|
Resolved conflicts: - ModuleExecutionPipeline.cs: Keep discriminated union factory methods, merge hook calls - Design doc: Keep corrected property pattern syntax and deprecated section 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
SummaryThis PR implements a discriminated union pattern for Critical IssuesNone found ✅ The PR addresses all previous review feedback comprehensively:
Suggestions1. IModuleResult interface compatibility (minor) The Current interface (lines removed):
Impact: This is a breaking change for anyone using Status: Likely acceptable as a major version change, but worth confirming. 2. Exception Message Sanitization (security hardening) In ModuleResult.cs:284-341, the Suggestion: Add a comment/documentation warning that consumers should sanitize exception messages before serialization if they plan to export results to untrusted locations. 3. StackTrace preservation comment (clarification) The JSON documentation mentions that StackTrace is serialized but notes it won't be "restored" during deserialization. This is correct behavior (StackTrace is a read-only property), but could be clearer. Current: Serialized but not restored Previous Review StatusAll critical issues from previous reviews have been addressed:
Verdict✅ APPROVE This is a well-designed discriminated union implementation following C# best practices. The security improvements and bug fixes demonstrate careful attention to review feedback. The breaking changes are clearly documented and appropriate for a major version bump. |
Update test assertions to use ExceptionOrDefault and ValueOrDefault instead of removed Exception and Value properties. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
SummaryThis PR implements a discriminated union pattern for ModuleResult, replacing exception-throwing .Value access with type-safe pattern matching using Success/Failure/Skipped variants. Critical IssuesNone found ✅ Suggestions1. Exception Type Security Restriction May Be Too StrictLocation: src/ModularPipelines/Models/ModuleResult.cs:319 The ExceptionJsonConverter only allows deserializing exceptions from the if (exceptionType \!= null &&
typeof(Exception).IsAssignableFrom(exceptionType) &&
(exceptionType.Namespace?.StartsWith("System", StringComparison.Ordinal) == true))Issue: This prevents deserializing common exceptions from other namespaces like:
Suggestion: Consider using a more permissive approach:
If the strict restriction is intentional for security, add a comment explaining the threat model. 2. Timing Information Loss Not Well DocumentedLocation: src/ModularPipelines/Models/ModuleResult.cs:198-206 The var duration = (ctx.StartTime == DateTimeOffset.MinValue || ctx.EndTime == DateTimeOffset.MinValue)
? TimeSpan.Zero
: end - start;Issue: This makes it impossible to distinguish between:
Suggestion: Consider:
3. Pattern Matching Example Could Show More Realistic UsageLocation: Documentation and code examples throughout The examples show basic pattern matching, but don't demonstrate common real-world scenarios like:
Suggestion: Add more comprehensive examples in the documentation showing these patterns, especially since this is a breaking change and users need clear migration guidance. Observations (Non-Blocking)✅ Good: Comprehensive Security DocumentationThe ExceptionJsonConverter has excellent security documentation explaining the rationale for limiting serialized exception information. This is a model for other converters. ✅ Good: Consistent RefactoringAll 60+ usages of ✅ Good: Backward Compatibility ApproachMarking ✅ Good: Null SafetyThe use of null-conditional operators (e.g., Verdict✅ APPROVE - No critical issues This is a well-executed refactoring that meaningfully improves type safety. The suggestions above are minor enhancements that could be addressed in follow-up PRs if desired. |
Update ModularPipelines.Examples Azure modules to use the new discriminated union API (.ValueOrDefault instead of .Value). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
SummaryThis PR implements a discriminated union pattern for Critical Issues1. Breaking Change in JSON Serialization - Missing JsonConverter Implementation The PR claims "JSON Serialization: Full support via custom ModuleResultJsonConverterFactory" and the plan shows this attribute: [JsonConverter(typeof(ModuleResultJsonConverterFactory))]
public abstract record ModuleResult<T> : IModuleResultHowever, I don't see 2. Obsolete Attribute Guidance Unclear From the diff: [Obsolete("Use pattern matching on ModuleResult<T>.Failure instead. This exception is no longer thrown.")]
public class ModuleFailedException : PipelineExceptionIssue: The message says "This exception is no longer thrown" but this could confuse users on older versions. Consider: "Use pattern matching on ModuleResult instead. Starting in version X.X, accessing .Value on failed results will no longer throw this exception." 3. Null Reference Risk in Factory Methods In the new ModuleResult.cs: internal static Success CreateSuccess(T value, ModuleExecutionContext ctx) => new(value)
{
ModuleName = ctx.ModuleType.Name, // ctx.ModuleType could be null
// ...
ModuleType = ctx.ModuleType
};The Suggestions1. ValueOrDefault Naming Clarity
2. Plan Documentation Cleanup The file Verdict |
Response to JSON Converter CommentThe
The The converter includes:
This was implemented as nested classes rather than separate files to keep the serialization logic co-located with the model definition. |
…security risk The TypeDiscriminatorConverter allowed arbitrary type instantiation from JSON type discriminator values, which posed a security vulnerability. Since the ModuleResult refactoring (PR #1895), nothing implements ITypeDiscriminator anymore, making this code dead. Rather than adding security restrictions to unused code, this commit removes the dead code entirely: - Removes TypeDiscriminatorConverter<T> - Removes ITypeDiscriminator interface This eliminates the security vulnerability by removing the vulnerable code path. Closes #1910 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…security risk (#1929) The TypeDiscriminatorConverter allowed arbitrary type instantiation from JSON type discriminator values, which posed a security vulnerability. Since the ModuleResult refactoring (PR #1895), nothing implements ITypeDiscriminator anymore, making this code dead. Rather than adding security restrictions to unused code, this commit removes the dead code entirely: - Removes TypeDiscriminatorConverter<T> - Removes ITypeDiscriminator interface This eliminates the security vulnerability by removing the vulnerable code path. Closes #1910 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Summary
Implements issue #1869 - Discriminated Union Result Pattern for
ModuleResult<T>.BREAKING CHANGE:
ModuleResult<T>.Valueno longer throws exceptions. Use pattern matching orIsSuccess/ValueOrDefaultinstead.Changes
Core Discriminated Union: Rewrote
ModuleResult<T>as an abstract record with three sealed variants:Success(T Value)- successful execution with a valueFailure(Exception Exception)- failed execution with an exceptionSkipped(SkipDecision Decision)- skipped execution with reasonSafe Accessors (no exceptions):
ValueOrDefault- returns value or default(T)ExceptionOrDefault- returns exception or nullSkipDecisionOrDefault- returns skip decision or nullQuick Checks:
IsSuccess,IsFailure,IsSkippedboolean propertiesPattern Matching Helpers:
Match<TResult>(onSuccess, onFailure, onSkipped)- functional matchingSwitch(onSuccess, onFailure, onSkipped)- imperative matchingJSON Serialization: Full support via custom
ModuleResultJsonConverterFactoryDeprecated:
ModuleFailedExceptionandModuleSkippedException(use pattern matching instead)Deleted:
SkippedModuleResult.csandTimedOutModuleResult.cs(replaced by variants)Migration Example
Test plan
Closes #1869
🤖 Generated with Claude Code