All notable changes to the Trellis project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Self-review of Trellis.Core surfaced six findings; all addressed in this release.
Result.Try<T>(Func<T>)andResult.TryAsync<T>(Func<Task<T>>)now throwArgumentNullExceptionwhenfuncis null, matching the no-payloadTry(Action)/TryAsync(Func<Task>)overloads. Previously the value-bearing variants caught the resultingNullReferenceExceptionand returnedResult.Fail(InternalServerError), hiding the programming error. Behavior change: callers that relied on the swallowing behavior (test or otherwise) need to handle null up-front. The existingTry_WithNullFunction_ShouldReturnFailureResulttest was updated to assertArgumentNullException.Maybe<T>.Map<TResult>(selector)andMaybe<T>.Match<TResult>(some, none)now throwArgumentNullExceptionwhen their delegate parameters are null. Previously the failure mode was path-dependent (NRE only when the matching branch fired, particularly bad forMatchbecause either delegate could fail depending onHasValue). Sibling methods (Bind,Where,Tap,Or(Func<>), etc.) already null-checked.NullableExtensions.ToResult<T>(Func<Error>)struct and class overloads now throwArgumentNullExceptionwhenerrorFactoryis null. Async variants inherit the fix transitively.Page<T>.Itemsnow returnsArray.Empty<T>()when accessed on a default-constructedPage<T>(previously returned null despite the non-nullable annotation). Mirrors theEquatableArray<T>.Itemspattern.DeliveredCountsimplified toItems.Countsince the property is now always non-null.Cursor.Tokennow throwsInvalidOperationExceptionwith a diagnostic message when accessed ondefault(Cursor)(previously returned null despite the non-nullable annotation and the doc'd "no empty cursor" invariant). The xmldoc invariant — "There is no empty cursor — a constructed Cursor always carries a non-empty token" — is now enforced at the property accessor.RequiredDecimal<TSelf>source generation now uses invariant culture for the plainTryCreate(string?, string?)overload even when[Range]is applied. Previously the ranged generated path used the ambient current culture while the unranged path used invariant culture, so the same string could parse differently depending on whether the type had a range constraint.- Nested required value-object source generation now preserves containing-type modifiers such as
staticandsealedwhen emitting nested partial declarations. Previously nestedRequiredString<TSelf>/RequiredGuid<TSelf>/ numeric required value objects inside those containers could produce generated partial types that did not match the user's containing type declaration. - Global-namespace required value objects now generate valid source. Previously a
partial class GlobalCode : RequiredString<GlobalCode>declared outside a namespace caused the generator to emit an invalid namespace declaration, leaving the generatedIScalarValueinterface implementation unavailable. EntityTagValue.TryParse("*")now returnsEntityTagValue.Wildcard(), so wildcard precondition tokens round-trip throughToHeaderValue()and the public parser. Previously only quoted strong and weak ETags parsed successfully.
Documented the actual performance characteristics of AddResultsInstrumentation and the per-extension using var activity = ActivitySource.StartActivity(...) pattern, backed by a new BenchmarkDotNet suite (Trellis.Benchmark/TracingOverheadBenchmarks.cs). Measured on .NET 10 / x64:
- No listener registered (production default): ~14–20 ns per
Bind/Map/Tap, 0 bytes allocated. The framework does not pay for tracing the consumer didn't ask for. AddResultsInstrumentationregistered with full sampling: ~200 ns + ~400 B per combinator. At 10k RPS × 10-step pipeline that's ~22 ms/sec CPU + 40 MB/sec GC pressure.
The new docs make the granularity guidance explicit: per-Result-extension spans add limited signal beyond the outer pipeline-behavior or HTTP-request span; for high-throughput services, instrument at the pipeline-behavior altitude (Trellis.Mediator.TracingBehavior) and reserve AddResultsInstrumentation for development/debugging or low-rate paths. Updated ResultsTraceProviderBuilderExtensions.cs xmldoc and the corresponding section in trellis-api-core.md.
IDomainEventHandler<TEvent>(new) — Implement this to handle a domain event. Dispatch matches the event's runtime type exactly; base-type and interface-type handlers are not auto-resolved. Handlers must be idempotent — non-cancellation exceptions thrown by a handler are logged at error level and swallowed so other handlers, other events, and the originating command still complete.OperationCanceledExceptionmatching the request's token is the one exception that propagates.IDomainEventPublisher(new) — Used by the framework to fan out a single event. Inject only when publishing from non-pipeline contexts (background jobs, scheduled tasks). Default implementation (MediatorDomainEventPublisher, internal) resolves handlers via DI by runtime type.DomainEventDispatchBehavior<TMessage, TResponse>(new) — Pipeline behavior constrained toICommand<TResponse>(queries pass through). After a successful command whose response isIResult<TAggregate>(typicallyResult<TAggregate>) whereTAggregate : IAggregate, drainsaggregate.UncommittedEvents()in waves with index tracking.AcceptChanges()is called once at the end of a fully successful loop; cancellation propagates above theAcceptChanges()call so undispatched (and dispatched) events stay on the aggregate, and handlers must be idempotent because a retry will re-publish events that already fired. Wave count is capped at 8; cap-exceeded paths are logged andAcceptChanges()is called defensively. Other response shapes (Result<Unit>,Result<TDto>,Result<(A,B)>) pass through untouched in v1; manual dispatch remains the option for those flows.DomainEventDispatchServiceCollectionExtensions.AddDomainEventDispatch()— Idempotent. RegistersDomainEventDispatchBehavior<,>(open-generic, scoped) and the defaultIDomainEventPublisher. AOT-friendly (no scanning).AddDomainEventHandler<TEvent, THandler>()— Explicit per-handler registration for AOT/trim scenarios. Idempotent.AddDomainEventDispatch(params Assembly[] assemblies)— Assembly-scan overload (annotated[RequiresUnreferencedCode]+[RequiresDynamicCode]) that finds every concreteIDomainEventHandler<TEvent>and registers each as scoped.- Pipeline placement — Inserts after
ValidationBehaviorand beforeTransactionalCommandBehavior(when registered), so events fire after the transaction commits and handlers see committed state. When no transactional behavior is in the pipeline (e.g., applications committing directly inside the handler via a repository), dispatch runs immediately after the handler returns success.
Failure model: handlers run as best-effort side effects. Email failures, message-bus blips, and DI activation errors are all logged and swallowed; the originating command still succeeds. The one exception that propagates is
OperationCanceledExceptionmatching the request's cancellation token — when the caller cancels, in-flight handlers that observe the token may throw OCE and the dispatcher lets it abort the remaining work. If a non-cancellation side effect must block command completion, do that work inside the command handler — not a domain-event handler.
Migration: applications dispatching events manually (e.g.,
foreach (var evt in agg.UncommittedEvents()) await _publisher.PublishAsync(...); agg.AcceptChanges();) can delete that boilerplate after wiringAddDomainEventDispatch(...). If you must run both during migration, the framework dispatcher is safe only when the manual path callsAcceptChanges()before returning — typical implementations do, but verify. If the manual code skipsAcceptChanges(), accepts conditionally, or accepts only some events, the framework dispatcher will see the remaining events and re-publish them. Recommendation: migrate fully or stay manual; don't ship a hybrid.
IMessageValidator<TMessage>(new, inTrellis.Mediator) — Extensibility seam that lets validator packages plug into the singleValidationBehaviorstage instead of occupying their own pipeline slot. Multiple validators per message are supported; theirError.UnprocessableContentfailures aggregate into one response.ValidationBehaviornow runs for every message (no longer constrained toIValidate). It composesIValidate.Validate()(when implemented) with every registeredIMessageValidator<TMessage>and merges all field violations into a singleError.UnprocessableContent. Non-UPC failures (Error.Conflict,Error.Forbidden, …) short-circuit and propagate as-is.AddTrellisFluentValidation()(Trellis.FluentValidation) — Parameterless overload registers an open-genericFluentValidationMessageValidatorAdapter<TMessage>asIMessageValidator<>. AOT-friendly (no assembly scanning, no reflection on the hot path); register eachIValidator<TCommand>explicitly viaAddScoped<IValidator<...>, ...>(). Assembly-scanning overload is annotated[RequiresUnreferencedCode]/[RequiresDynamicCode]for non-AOT scenarios.- JSON Pointer normalization —
FluentValidationMessageValidatorAdapterandvalidationResult.ToResult(value)now translate FluentValidation property names into RFC 6901 JSON Pointers:Metadata.Reference→/Metadata/Reference,Lines[0].Memo→/Lines/0/Memo. Special characters are escaped per RFC 6901 (~→~0,/→~1). - Showcase canonical demo —
POST /api/transfers/batch/{fromId}exercisesAddMediator(Scoped)+AddTrellisBehaviors()+AddTrellisFluentValidation()end-to-end with nested + indexer FluentValidation rules and anIValidatebusiness invariant. SeeExamples/Showcase/README.md.
Note:
AddMediator(...)should be called asAddMediator(opts => opts.ServiceLifetime = ServiceLifetime.Scoped)in any host with a request scope. Mediator's default Singleton lifetime conflicts with the scoped Trellis behaviors and fails ASP.NET's root-scope validation. See Mediator integration docs.
The seven extension classes deprecated by Phase 3 of the v2 redesign have been deleted. The single supported response API is now result.ToHttpResponse(...) / result.ToHttpResponseAsync(...) (returns IResult), with .AsActionResult<T>() / .AsActionResultAsync<T>() adapters for MVC.
Removed types:
ActionResultExtensions,ActionResultExtensionsAsync(MVCToActionResult,ToCreatedAtActionResult, metadata selector overloads)HttpResultExtensions,HttpResultExtensionsAsync(Minimal APIToHttpResult,ToCreatedAtRouteHttpResult,ToCreatedHttpResult,ToUpdatedHttpResult, range overloads)PageActionResultExtensions(ToPagedActionResult)PageHttpResultExtensions(ToPagedHttpResult)WriteOutcomeExtensions(WriteOutcome<T>.ToActionResult,WriteOutcome<T>.ToHttpResult,ToUpdatedActionResult)
Migration: replace every call with the single fluent builder overload of ToHttpResponse / ToHttpResponseAsync. See docs/docfx_project/articles/asp-tohttpresponse.md and MIGRATION_v3.md for the full mapping.
The Error type is now an abstract record with 18 nested sealed record cases (Error.NotFound, Error.UnprocessableContent, Error.Conflict, Error.Forbidden, …). The base type has a private constructor so the catalog is closed at the language level, and every switch over an Error reference is exhaustive at compile time.
Key changes:
- No static factory methods. Replace
Error.Validation("msg", "field")withnew Error.UnprocessableContent(EquatableArray.Create(new FieldViolation(InputPointer.ForProperty("field"), "reason_code") { Detail = "msg" })). Same pattern forError.NotFound,Error.Conflict,Error.Forbidden,Error.Unexpected, etc. - Typed payloads. Each case carries a strongly typed payload —
ResourceRefforNotFound/Gone/Conflict,EquatableArray<FieldViolation>forUnprocessableContent,PreconditionKindforPreconditionFailed, etc. No moreobject?bags. DetailandCauseon the base. Set them via object initializer; equality compares discriminator + payload +Detail(Cause excluded).Result.Erroris nowpublic Error?(null on success, never throws).Result<T>.Valuewas removed; extract success values withTryGetValue,Match,Deconstruct, orGetValueOrDefault. See ADR-001 for the full design rationale.Result<Unit>collapsed to non-genericResult.Unitis retained internally for tuple-result interop only.- Removed:
MatchError,SwitchError,FlattenValidationErrorsextensions;ValidationError/NotFoundError/ConflictError/etc. concrete subclasses;Error.Instancefield. The ASP wire layer synthesizesProblemDetails.Instancefrom request URL +ResourceRef. - Renamed wire identifiers. Default
Codevalues changed from"validation.error"/"not.found.error"/etc. to the IANA-aligned slugs"unprocessable-content"/"not-found"/etc. - TRLS005 analyzer (
UseMatchErrorAnalyzer) removed — the C# compiler now provides exhaustiveness for free.
Migration path: every Error.X(...) factory call site must be rewritten. MatchError(...) becomes result.Match(_, e => e switch { Error.X => ..., ... }). See Error Handling for the full patterns and api-results.md for the reference table.
- Removed
ResultBuilder— UseResult.Ok(value)andResult.Fail<T>(new Error.X(...))directly.ResultBuilderwas a thin wrapper that added no value over the existing API. - Removed
ValidationErrorBuilder— Construct anError.UnprocessableContentdirectly with oneFieldViolationper failure:new Error.UnprocessableContent(EquatableArray.Create(new FieldViolation(InputPointer.ForProperty(field), reasonCode) { Detail = "..." })). Combine multiple validation results viaCombine. - Removed
Trellis.Testing.Buildersnamespace — All builder types have been removed. - Removed
Trellis.Testing.Fakesnamespace —FakeRepository,FakeSharedResourceLoader,TestActorProvider, andTestActorScopenow live in theTrellis.Testingnamespace. Replaceusing Trellis.Testing.Fakes;withusing Trellis.Testing;. - New package:
Trellis.Testing.AspNetCore— ASP.NET Core integration test helpers (WebApplicationFactoryExtensions,WebApplicationFactoryTimeExtensions,ServiceCollectionExtensions,ServiceCollectionDbProviderExtensions,MsalTestTokenProvider,MsalTestOptions,TestUserCredentials) moved to this new package. Adddotnet add package Trellis.Testing.AspNetCoreand addusing Trellis.Testing.AspNetCore;for these types. Projects using both core assertions and ASP.NET helpers will need both packages. Trellis.Testingno longer depends on ASP.NET Core, EF Core, or MSAL — The core package now only depends onTrellis.Core,Trellis.Authorization, andFluentAssertions.
TrellisJsonValidationException(new, inTrellis.Core) — A marker subclass ofSystem.Text.Json.JsonExceptionthat Trellis JSON converters throw when a structured value object's invariants are violated during deserialization (e.g.,MoneyJsonConverterrejecting a negative amount). The message is treated as curated/client-safe.ScalarValueValidationMiddleware(Minimal API path) now surfaces the message of an innerTrellisJsonValidationExceptionin the Problem Details body — using itsJsonException.Pathas the error key when populated. PlainJsonExceptions continue to map to the generic"The request body contains invalid JSON."message because their text can include internal type names (audit-respecting).MoneyJsonConverterupdated to throwTrellisJsonValidationException(was: plainJsonException). Callers see"Amount cannot be negative."etc. from the framework instead of the generic "invalid JSON" placeholder. This restores DX parity with MVC's model binder, which already includes per-fieldJsonExceptionmessages.
CompositeValueObjectConvention—ApplyTrellisConventionsnow automatically registers all compositeValueObjecttypes (types extendingValueObjectbut not implementingIScalarValue) as EF Core owned types. NoOwnsOneconfiguration needed for types likeAddress,DateRange, orGeoCoordinate.Maybe<T>is also supported — for simple composites, columns are marked nullable in the owner table; for composites with nested owned types (e.g.,AddresscontainingMoney), the convention maps the optional dependent to a separate table with NOT NULL columns.Moneyretains its specialized column naming viaMoneyConvention. ExplicitOwnsOneconfiguration takes precedence.
- TRLS003, TRLS004, TRLS006 — The unsafe-access analyzers now recognize ternary conditional expressions (
? :) as valid guards. Previously,maybe.HasValue ? maybe.Value : fallbackand similar patterns forResult.Value/Result.Errorproduced false-positive diagnostics.
ReplaceResourceLoader<TMessage, TResource>— NewIServiceCollectionextension method that removes all existingIResourceLoader<TMessage, TResource>registrations and re-registers the replacement as scoped (matching the production lifetime of resource loaders). Accepts aFunc<IServiceProvider, IResourceLoader>factory. Eliminates the need to manually callRemoveAllbefore re-registering whenAddMockAntiCorruptionLayer()causes duplicate DI registrations.
[StringLength]—RequiredString<TSelf>derivatives now support[StringLength(max)]and[StringLength(max, MinimumLength = min)]for declarative length validation at creation time. The source generator emits.Ensure()length checks inTryCreatewith clear validation error messages (e.g.,"First Name must be 50 characters or fewer.").
MoneyConvention—ApplyTrellisConventionsnow automatically mapsMoneyproperties as owned types with{PropertyName}(decimal 18,3) +{PropertyName}Currency(nvarchar 3) columns. Scale 3 accommodates all ISO 4217 minor units (BHD, KWD, OMR, TND). NoOwnsOneconfiguration needed. ExplicitOwnsOnetakes precedence.
Money— Added private parameterless constructor and private setters onAmount/Currencyfor EF Core materialization support. No public API changes.
Lightweight authorization primitives with zero dependencies beyond Trellis.Core:
Actor— Sealed record representing an authenticated user (Id+Permissions) withHasPermission,HasAllPermissions,HasAnyPermissionhelpersIActorProvider— Abstraction for resolving the current actor (implement in API layer)IAuthorize— Marker interface for static permission requirements (AND logic)IAuthorizeResource<TResource>— Resource-based authorization with a loaded resource viaAuthorize(Actor, TResource)IResourceLoader<TMessage, TResource>— Loads the resource required for resource-based authorizationResourceLoaderById<TMessage, TResource, TId>— Convenience base class for ID-based resource loading
Usable with or without CQRS — no Mediator dependency.
Result-aware pipeline behaviors for martinothamar/Mediator v3:
ValidationBehavior— Short-circuits onIValidate.Validate()failureAuthorizationBehavior— ChecksIAuthorize.RequiredPermissionsviaIActorProviderResourceAuthorizationBehavior<TMessage, TResource, TResponse>— Loads resource viaIResourceLoader, delegates toIAuthorizeResource<TResource>.Authorize(Actor, TResource). Auto-discovered viaAddResourceAuthorization(Assembly)or registered explicitly for AOT.LoggingBehavior— Structured logging with duration and Result outcomeTracingBehavior— OpenTelemetry activity span with Result statusExceptionBehavior— Catches unhandled exceptions →Error.UnexpectedServiceCollectionExtensions—PipelineBehaviorsarray andAddTrellisBehaviors()DI registration
IFailureFactory<TSelf>— Static abstract interface for AOT-friendly typed failure creation in generic pipeline behaviorsResult<TValue>now implementsIFailureFactory<Result<TValue>>
Specification<T> is a new DDD building block for encapsulating business rules as composable, storage-agnostic expression trees:
Specification<T>— Abstract base class withToExpression(),IsSatisfiedBy(T), andAnd/Or/Notcomposition- Expression-tree based — Works with EF Core 8+ for server-side filtering via
IQueryable - Implicit conversion to
Expression<Func<T, bool>>for seamless LINQ integration - In-memory evaluation via
IsSatisfiedBy(T)for domain logic and testing
// Define a specification
public class HighValueOrderSpec(decimal threshold) : Specification<Order>
{
public override Expression<Func<Order, bool>> ToExpression() =>
order => order.TotalAmount > threshold;
}
// Compose specifications
var spec = new OverdueOrderSpec(now).And(new HighValueOrderSpec(500m));
var orders = await dbContext.Orders.Where(spec).ToListAsync();Maybe<T> now has a notnull constraint and new transformation methods, making it a proper domain-level optionality type:
notnullconstraint —Maybe<T> where T : notnullprevents wrapping nullable typesMap<TResult>— Transform the inner value:maybe.Map(url => url.Value)returnsMaybe<string>Match<TResult>— Pattern match:maybe.Match(url => url.Value, () => "none")- Implicit operator —
Maybe<Url> m = url;works naturally
Full support for optional value object properties in DTOs:
MaybeScalarValueJsonConverter<TValue,TPrimitive>— JSON deserialization:null→Maybe.None, valid →Maybe.From(validated), invalid → validation error collectedMaybeScalarValueJsonConverterFactory— Auto-discoversMaybe<T>properties on DTOsMaybeModelBinder<TValue,TPrimitive>— MVC model binding: absent/empty →Maybe.None, valid →Maybe.From(result), invalid → ModelState errorMaybeSuppressChildValidationMetadataProvider— Prevents MVC from requiring child properties onMaybe<T>(fixes MVC crash)ScalarValueTypeHelperadditions —IsMaybeScalarValue(),GetMaybeInnerType(),GetMaybePrimitiveType()- SampleWeb apps updated at the time —
Maybe<Url> Websiteon User/RegisterUserDto,Maybe<FirstName> AssignedToon UpdateOrderDto. (SampleWeb has since been removed; see Showcase consolidated; SampleWeb removed below.)
Maybe<T>now requireswhere T : notnull— see Migration Guide for details
The Showcase sample now hosts the same banking domain twice — once as MVC controllers and once as Minimal API endpoint groups — so users can compare hosting styles over an identical contract. This replaces the previously incoherent setup where Showcase was banking and SampleMinimalApi was a different (users/products/orders) domain with no shared code.
New project layout:
Examples/Showcase/
├── api.http Single .http file with @host toggle (works on both hosts)
├── src/
│ ├── Showcase.Domain/ (unchanged) pure domain
│ ├── Showcase.Application/ NEW — workflows, services, persistence, DTOs, seed
│ ├── Showcase.Mvc/ renamed from Showcase.Api — controllers + Program.cs
│ └── Showcase.MinimalApi/ NEW — endpoint groups + Program.cs
└── tests/
├── Showcase.Tests/ (unchanged) domain + MVC integration tests
└── Showcase.MinimalApi.Tests/ NEW — mirror of MVC integration tests against Minimal API host
The Minimal API host adds zero new application code — same DTOs, repository, BankingWorkflow, and seed. The only delta is route mapping and ToHttpResult* vs ToActionResult* for Result→HTTP conversion. Showcase.MinimalApi.Tests runs the same six integration assertions as the MVC tests against the Minimal API factory and proves identical HTTP behaviour.
Removed: the entire Examples/SampleWeb/ folder (SampleMinimalApi, SampleMinimalApi.Tests, SampleUserLibrary, four stale top-level .http files). Trellis.Benchmark no longer references the deleted SampleUserLibrary; the two VOs the benchmarks needed are now inlined in Trellis.Benchmark/BenchmarkValueObjects.cs.
The Examples/ folder was rewritten end-to-end so every kept sample passes the v2 axiom scorecard (A1–A11). Samples are the source of truth that flows into the ASP template and from there into AI-generated code; imperfections at this layer compound, so the sweep was scored against an explicit set of rules — see Examples README for the full list.
Lineup changes:
- Removed as redundant or noisy:
Examples/AuthorizationExample,Examples/BankingExample,Examples/EcommerceExample,Examples/SampleWeb/SampleWebApplication,Examples/SampleWeb/SampleMinimalApiNoAot,Examples/SampleWeb/SampleDataAccess. Their teachings are now consolidated inShowcase(auth, banking workflows, lifecycle) and the Minimal API sample (data access via in-memory repos). - Renamed
Examples/Xunit→Examples/TestingPatterns(folder name now describes the intent, not the runner). The csproj isTestingPatterns.Tests.csprojsoIsTestProjectauto-detection still applies.
Showcase (Examples/Showcase):
- Architectural fix — every state-changing use case now crosses
BankingWorkflow, which centralizesmutate aggregate → publish events → AcceptChanges → persist. PreviouslyAccountsControllermutated aggregates directly forOpen/Deposit/Withdraw/Freeze/Unfreeze/Close, so domain events from those flows were never published or accepted (onlySecureWithdrawandTransferdid the right thing). This was the canonical "boundary leak" bug. - Wire-boundary alignment —
AccountResponseexposesAccountId,CustomerId,AccountType,Money,AccountStatusdirectly instead ofGuid/string/decimal. The existingMoneyJSON converter emits{"amount", "currency"}. System.TimeProviderreplaces the ad-hocIClock/SystemClockseam (BCL standard since .NET 8). Tests useFakeTimeProviderfromMicrosoft.Extensions.TimeProvider.Testing..Valuepurged from production code. Seed-time invariants are centralized in aRequired<T>()helper that throwsInvalidOperationExceptionwith a clear message at startup.
SampleUserLibrary, SampleMinimalApi (Examples/SampleWeb/*):
- The standalone Minimal API sample and the shared
SampleUserLibrarywere folded intoExamples/Showcase/src/Showcase.MinimalApi, which now hosts the same banking domain asShowcase.Mvcover identical DTOs. The shared-VO-library teaching is preserved by Showcase'sShowcase.Domain/Showcase.Applicationsplit. ScalarValueValidationMiddlewareno longer parsesBadHttpRequestException.Messageto extract field names or invalid values for Minimal API scalar route/query binding failures. It now uses endpoint parameter metadata plus route/query raw values and re-runs Trellis scalar validation forIScalarValue<,>/Maybe<TScalar>parameters.
ConditionalRequestExample:
- Route templates use
{id:ProductId}(not{id:guid}). Handler signatures bindProductId iddirectly (generator-emittedIParsable). ProductResponseexposesProductId/ProductName/MonetaryAmountinstead ofGuid/string/decimal.- New
ConditionalRequestExample.Testscovers all six conditional-request branches (200/304/412/428/etc.).
SsoExample, EfCoreExample:
- Re-audited. New minimal
*.Testsprojects added.
A comprehensive suite of Roslyn analyzers to enforce Railway Oriented Programming best practices at compile time:
Safety Rules (Warnings):
- TRLS001: Detect unhandled Result return values
- TRLS003: Prevent unsafe
Result.Valueaccess withoutIsSuccesscheck - TRLS004: Prevent unsafe
Result.Erroraccess withoutIsFailurecheck - TRLS006: Prevent unsafe
Maybe.Valueaccess withoutHasValuecheck - TRLS007: Suggest
Create()instead ofTryCreate().Valuefor clearer intent - TRLS008: Detect
Result<Result<T>>double wrapping - TRLS009: Prevent blocking on
Task<Result<T>>with.Resultor.Wait() - TRLS011: Detect
Maybe<Maybe<T>>double wrapping - TRLS014: Detect async lambda used with sync method (Map instead of MapAsync)
- TRLS015: Don't throw exceptions in Result chains (defeats ROP purpose)
- TRLS016: Empty error messages provide no debugging context
- TRLS018: Unsafe
.Valueaccess in LINQ without filtering first
Best Practice Rules (Info):
- TRLS002: Suggest
Bindinstead ofMapwhen lambda returns Result - TRLS005: (removed in V2) — superseded by C# exhaustive
switchon the closedErrorADT - TRLS010: Suggest specific error types instead of base
Errorclass - TRLS013: Suggest
GetValueOrDefault/Matchinstead of ternary operator
Benefits:
- ✅ Catch common ROP mistakes at compile time
- ✅ Guide developers toward best practices
- ✅ Improve code quality and maintainability
- ✅ 149 comprehensive tests ensuring accuracy
Installation:
dotnet add package Trellis.AnalyzersDocumentation: Analyzer Documentation