Imported from ardalis/Result (
AGENTS.md). Install upstream withnpx skills add ardalis/Result. Copyright stays with the author.
AGENTS.md
Guidance for AI coding agents (and human contributors) working in this repository.
What This Is
Ardalis.Result implements the Result pattern: a way for service/domain methods to return success or one of many non-success states (NotFound, Invalid, Conflict, Forbidden, etc.) without throwing exceptions, in a form that maps cleanly onto HTTP status codes. The repo ships four independently versioned NuGet packages:
Ardalis.Result(src/Ardalis.Result) — the core abstraction. No third-party dependencies. The other packages and any consumer depend on this.Ardalis.Result.AspNetCore(src/Ardalis.Result.AspNetCore) — translatesResult/Result<T>into MVCActionResultand Minimal APIIResult. Depends on the ASP.NET Core shared framework.Ardalis.Result.FluentValidation(src/Ardalis.Result.FluentValidation) — a single extension (ValidationResult.AsErrors()) bridging FluentValidation failures intoValidationErrors.Ardalis.Result.FluentAssertions(src/Ardalis.Result.FluentAssertions) —ShouldBe*test assertions for results.
Core/AspNetCore/FluentValidation are versioned together (currently 10.1.0, set in each .csproj); FluentAssertions is on its own track (1.0.0). Release notes live inline in the <PackageReleaseNotes> of each .csproj.
Common Commands
Run from the repo root.
dotnet restore
dotnet build --configuration Release --no-restore
dotnet test # all test projects, all target frameworks
Local runtime gotcha: the projects target
net6.0/net8.0(andnet48), but this machine only has the .NET 10 SDK/runtime installed. Tests do not auto-roll-forward —dotnet testaborts with "You must install or update .NET to run this application." Either install the .NET 6 and .NET 8 runtimes (CI does this) or setDOTNET_ROLL_FORWARD=LatestMajorto run the existing targets on .NET 10:DOTNET_ROLL_FORWARD=LatestMajor dotnet test --framework net8.0
Run one test project:
dotnet test tests/Ardalis.Result.UnitTests/Ardalis.Result.UnitTests.csproj
Run a single test or class with a filter (xUnit; filter on the fully-qualified name or method):
dotnet test tests/Ardalis.Result.UnitTests/Ardalis.Result.UnitTests.csproj --filter "FullyQualifiedName~ResultBind"
Pin to one framework when a project multi-targets (avoids running the same test under net6.0/net8.0/net48):
dotnet test tests/Ardalis.Result.UnitTests/Ardalis.Result.UnitTests.csproj --framework net8.0
Run the sample apps:
dotnet run --project sample/Ardalis.Result.SampleWeb # MVC + ApiEndpoints + MediatR sample, has Swagger
dotnet run --project sample/Ardalis.Result.SampleMinimalApi # Minimal API sample (net8.0 only)
Multi-targeting and Toolchain (read before touching .csproj/Directory.Build.props)
Target frameworks are not set in individual project files. They come from per-area Directory.Build.props, each of which defines NetCoreFrameworks (currently net6.0;net8.0) and a TargetFrameworks built from it:
src/Directory.Build.props→netstandard2.0;net6.0;net8.0, plus packaging metadata (GeneratePackageOnBuild, authors, SourceLink, etc.).tests/Directory.Build.props→net48;net6.0;net8.0.sample/Directory.Build.props→net48;net6.0;net8.0,Nullableenabled.
To change the supported .NET versions across the whole repo, edit NetCoreFrameworks in these three files — not the individual projects. Projects that must pin a framework override TargetFrameworks locally (e.g. the Minimal API sample and its tests are net8.0 only; Ardalis.Result.AspNetCore uses $(NetCoreFrameworks) so it never targets net48/netstandard2.0).
Other cross-cutting build facts:
- Central Package Management is on (
Directory.Packages.props,ManagePackageVersionsCentrally=true). Add/upgrade dependency versions there with<PackageVersion>; reference them in.csprojwith a bare<PackageReference Include="..." />(noVersion). UseVersionOverrideonly for per-framework pins (the samples do this for the net6.0 ASP.NET Core packages). - PolySharp is a global package reference, so modern C# syntax (
initaccessors,record, collection expressions like[],required) compiles even onnetstandard2.0andnet48. Don't add manual polyfills for these. - SourceLink (
Microsoft.SourceLink.GitHub) is added only for packable projects onnet8.0+. - net48 caveat:
net48is kept in tests/samples purely to verify the core library stays usable from .NET Framework. On non-Windows (CI and macOS), runningnet48tests requires Mono (mono-devel).dotnet testwithout Mono will skip/fail those targets — pin--framework net8.0(or net6.0) locally to avoid them. The active branch history shows net48 support is sensitive; verify net48 still builds when changing targets.
CI and Publishing
.github/workflows/dotnetcore.ymlis the PR/main build: installs .NET 6 + 8 and Mono, thenrestore→build -c Release→dotnet testwithXPlat Code Coverage. It has a dedicated step that runs the three test projects explicitly against--framework net48— if you touch multi-targeting, expect this step to catch regressions. Coverage is summarized and posted back to the PR bycomment-on-pr.yml.publish-result.ymlandpublish-result-related.ymlpack and push to NuGet on every push tomain(with--skip-duplicate, so the version only publishes when bumped in the.csproj).jekyll-gh-pages.ymlpublishesdocs/to https://result.ardalis.com on changes underdocs/.
Architecture
Core result types (src/Ardalis.Result)
ResultStatus(enum) is the spine of the whole library:Ok, Created, Error, Forbidden, Unauthorized, Invalid, NotFound, NoContent, Conflict, CriticalError, Unavailable. Almost every behavior is aswitchover this enum, so adding a new status means updating every such switch — the translation maps inResultStatusMap/MinimalApiResultExtensionsand theHandleNonSuccessStatushelpers inResultExtensionswill all need a new arm (some throwNotSupportedExceptionon unknown statuses).Result<T>is the central type. Instances are created through static factory methods (Result<T>.Success(value),.NotFound(),.Invalid(...),.Created(...),.Conflict(...), etc.), not the constructors.IsSuccessis true forOk,Created, andNoContent. CarriesValue,Errors(strings),ValidationErrors,SuccessMessage,CorrelationId, andLocation(forCreated).Result(non-generic,Result.Void.cs) inherits fromResult<Result>— a deliberate quirk so the void result flows through the same generic machinery. It re-declares the factory methods withnew(e.g.Result.NotFound()returnsResult, whileResult<T>.NotFound()returnsResult<T>). When adding a factory/status, add it in bothResult.csandResult.Void.cs.IResultis the non-generic, value-type-erased contract (Status,Errors,ValidationErrors,ValueType,GetValue(),Location). The ASP.NET Core layer translates againstIResultso it works uniformly forResultandResult<T>.- Implicit conversions make the happy path terse:
T → Result<T>(auto-success),Result<T> → T(unwrapValue), andResult → Result<T>(carry a non-successResultinto a typed context). Be aware these exist when reasoning about overload resolution. Map/Bind(ResultExtensions.cs) implement Railway-Oriented Programming.Map(func)transformsValueon success and passes non-success states through unchanged;Bind(func)chains anotherResult-returning call and short-circuits on failure. Both have full sync +Task-based async overloads (MapAsync/BindAsync) coveringResult/Result<T>in every position. All non-success propagation funnels through the privateHandleNonSuccessStatushelpers, which rebuild the equivalent failed result of the destination type — keep these in sync with each other and withResultStatus.- Supporting types:
ValidationError(Identifier,ErrorMessage,ErrorCode,Severity) +ValidationSeverity(Error/Warning/Info);ErrorList(record bundling error messages + optionalCorrelationId);PagedResult<T> : Result<T>addsPagedInfo(useresult.ToPagedResult(pagedInfo));IResultExtensionsaddsIsOk()/IsNotFound()/etc. predicates overIResult.
ASP.NET Core translation (src/Ardalis.Result.AspNetCore)
Two independent translation paths, both keyed off ResultStatus:
- MVC →
ActionResult. Either apply[TranslateResultToActionResult](anActionFilterAttributethat rewrites the action'sObjectResultinOnActionExecuted) or callresult.ToActionResult(this)/this.ToActionResult(result)inside an action. Both funnel into the internalToActionResult(this ControllerBase, IResult)inActionResultExtensions.cs. - Minimal API →
Microsoft.AspNetCore.Http.IResult. Callresult.ToMinimalApiResult(). Implemented inMinimalApiResultExtensions.cs, guarded by#if NET6_0_OR_GREATER, mapping each status toResults.Ok/NotFound/Conflict/Problem/...withProblemDetailspayloads.
The status→HTTP mapping for the MVC path is configurable through ResultStatusMap / ResultStatusOptions. AddDefaultMap() defines the baseline (e.g. Error → 422, Invalid → 400 ValidationProblemDetails, NotFound → 404 ProblemDetails). Consumers customize it via MvcOptionsExtensions: services.AddControllers(o => o.AddDefaultResultConvention()) or o.AddResultConvention(map => map.For(...).Remove(...)). The same map also drives API metadata: ResultConvention emits [ProducesResponseType] for each mapped status on every [TranslateResultToActionResult] endpoint (so Swagger/NSwag see them), and [ExpectedFailures(...)] narrows which statuses a given endpoint advertises. A status listed in ExpectedFailures must exist in the configured ResultStatusMap.
Samples (sample/)
Ardalis.Result.Sample.Core holds framework-agnostic domain services/validators reused by the web samples and their tests. SampleWeb demonstrates the MVC/ApiEndpoints/MediatR usage (including a BadApproaches/ folder showing the anti-patterns the library replaces); SampleMinimalApi demonstrates ToMinimalApiResult. The *.FunctionalTests projects spin these up via Microsoft.AspNetCore.Mvc.Testing.
Testing Conventions
- xUnit throughout, with FluentAssertions for assertions, Moq where mocking is needed, and coverlet for coverage.
- Test files/classes are named
<TypeUnderTest><Member>— one file per method/behavior under test (e.g.ResultConstructor.cs,ResultBind.cs,ResultMapAsync.cs,PersonServiceCreate.cs). Follow this pattern when adding tests rather than one large file per class. Ardalis.Result.FluentAssertions.UnitTeststests the assertion helpers themselves; itsFailureResults/andSuccessFullResults/folders mirror eachResultStatus.- Public API and behavior changes that span frameworks should be validated against
net48as well (see the CI net48 step), since that target is the reason several compatibility constraints exist.