From bdc3e5814e8b627ce61e6a898781f8f7d1f362e3 Mon Sep 17 00:00:00 2001 From: Sylvain Aurat Date: Tue, 14 Jul 2026 15:28:04 +0200 Subject: [PATCH 01/10] docs: clarify error usage patterns --- doc/UsagePatterns.en.md | 235 +++++++++++++++++----------------------- 1 file changed, 98 insertions(+), 137 deletions(-) diff --git a/doc/UsagePatterns.en.md b/doc/UsagePatterns.en.md index 9d42d60..1422d03 100644 --- a/doc/UsagePatterns.en.md +++ b/doc/UsagePatterns.en.md @@ -3,18 +3,30 @@ 🌍 **Languages:** 🇬🇧 English (this file) | đŸ‡«đŸ‡· [Français](./UsagePatterns.fr.md) -FirstClassErrors is most useful when errors are not just technical failures, but **meaningful events in the life of the system**. -Below are common patterns where the library brings clarity and structure. +FirstClassErrors supports both exceptions and errors carried as data. The important decision is not which syntax you prefer, but **what the failure means in the current flow**. -## đŸ§± 1. Value Object invariants +This guide helps you choose the appropriate pattern. For the complete `Outcome` API and pipeline composition, see [Composing with Outcome](OutcomeGuide.en.md). -When creating a value object, invalid states must be rejected. +## 🧭 Choose from the meaning of the failure + +| Situation | Recommended representation | Why | +| --- | --- | --- | +| A domain invariant is violated and the operation cannot continue | throw a `DomainError` through `.ToException()` | the failure interrupts the current operation and belongs to the domain | +| Invalid input is an expected result of validation or parsing | return `Outcome.Failure(...)` | callers can handle the failure without exception-based control flow | +| One item fails inside a batch | return an `Outcome` for each item | one failure does not need to abort the whole batch | +| An incoming boundary rejects an interaction | use a `PrimaryPortError` | it preserves the incoming direction and any underlying domain cause | +| An outgoing dependency fails | use a `SecondaryPortError` | it preserves outgoing direction and transience | +| A failure must cross an application boundary as an exception | call `ThrowIfFailure()` or `GetResultOrThrow()` at that boundary | the error remains data until the chosen escalation point | + +The same documented error factory may be used in more than one transport. The error describes **what happened**; throwing or returning it describes **how this caller chooses to propagate it**. + +## đŸ§± Domain invariants + +A value object or entity must not enter an invalid state. When construction or an operation cannot continue, throw the documented domain error. ```csharp -public static Amount From(decimal value, Currency currency) -{ - if (value < 0) - { +public static Amount From(decimal value, Currency currency) { + if (value < 0) { throw InvalidAmountOperationError.NegativeAmount(value).ToException(); } @@ -22,69 +34,56 @@ public static Amount From(decimal value, Currency currency) } ``` -Here: - -* the domain rule is explicit -* the exception represents a precise invariant violation -* documentation describes the rule and diagnostics +The happy path stays readable, while the factory centralizes the error code, messages, context, and documentation. -This keeps domain code expressive and self-explanatory. +## 🧼 Domain operations -## đŸ“„ 2. Input validation (API / UI) - -User or external inputs may be invalid, but not exceptional in the technical sense. +Operations between valid domain objects may still violate a rule. ```csharp -public Outcome TryCreateAmount(decimal value, string currencyCode) -{ - if (!Currency.TryParse(currencyCode, out var currency)) - { - return Outcome.Failure( - InvalidAmountOperationError.UnknownCurrency(currencyCode)); +public Amount Add(Amount other) { + if (Currency != other.Currency) { + throw InvalidAmountOperationError.CurrencyMismatch(this, other).ToException(); } - return Outcome.Success(new Amount(value, currency)); + return new Amount(Value + other.Value, Currency); } ``` -Errors are: - -* captured -* transportable -* diagnosable +Use a precise factory name rather than a generic exception such as `InvalidOperationException`. The code should say which domain situation occurred. -without interrupting the flow. +## đŸ“„ Expected validation failures -## 🧼 3. Domain operations - -Operations between domain objects often have semantic constraints. +User input, parsing, and business validation often fail as part of normal application flow. Return the error as data when the caller is expected to decide what happens next. ```csharp -public Amount Add(Amount other) -{ - if (Currency != other.Currency) - { - throw InvalidAmountOperationError.CurrencyMismatch(this, other).ToException(); +public Outcome TryCreateAmount(decimal value, string currencyCode) { + if (!Currency.TryParse(currencyCode, out Currency currency)) { + return Outcome.Failure( + InvalidAmountOperationError.UnknownCurrency(currencyCode)); } - return new Amount(Value + other.Value, Currency); + if (value < 0) { + return Outcome.Failure( + InvalidAmountOperationError.NegativeAmount(value)); + } + + return Outcome.Success(new Amount(value, currency)); } ``` -The code reads like domain language, while the error remains structured and documented. +The failure still carries the same structured `Error`; it is simply not thrown at this stage. -## 📩 4. Batch or file processing +## 📩 Batch and file processing -In batch processing, many items may fail independently. +In a batch, each item can fail independently. Handle each `Outcome` and continue when that matches the business requirement. ```csharp -foreach (var line in file) -{ - var result = TryParseAmount(line); +foreach (string line in file) { + Outcome result = TryParseAmount(line); - if (result.IsFailure) - { - Log(result.Error); + if (result.IsFailure) { + Log(result.Error!); continue; } @@ -92,125 +91,87 @@ foreach (var line in file) } ``` -Errors are: - -* collected -* logged with full diagnostics -* not disruptive to the entire process - -## 🌐 5. Integration boundaries - -When interacting with external systems: - -* data may be inconsistent -* formats may change -* assumptions may break - -Using first-class errors helps distinguish: - -* domain issues -* input issues -* system or transformation issues +This pattern is appropriate only when continuing is intentional. If one invalid item invalidates the entire file, return or throw a file-level error instead. -Diagnostics guide where investigation should start. +## 🌐 Incoming boundaries -## 🔁 6. Validation pipelines - -Complex validations often involve multiple checks. +An incoming adapter may reject an interaction because mapping, validation, or domain construction failed. ```csharp -var result = ValidateAmount(amount) - .Then(CheckCurrency) - .Then(CheckLimits); +DomainError invalidAmount = InvalidAmountOperationError.NegativeAmount(request.Amount); + +return PrimaryPortError.Create( + Code.RequestRejected, + diagnosticMessage: $"Request {request.Id} contains an invalid amount.", + new PrimaryPortInnerErrors().Add(invalidAmount), + configureContext: ctx => ctx.Add(ErrCtxKey.RequestId, request.Id)) + .WithPublicMessage( + shortMessage: "The request contains invalid data."); ``` -Each failure carries an `Error`, keeping the model consistent while avoiding uncontrolled throwing. - -## đŸ§© 7. Support-oriented logging +The domain error explains the violated rule. The primary-port error explains the boundary-level outcome. See [Error Taxonomy and Composition](ErrorTaxonomy.en.md) for the nesting rules. -Because errors carry structured diagnostics, logs become more useful: +## 🔌 Outgoing dependencies -* stable error codes -* meaningful short messages -* documented causes +A database, broker, filesystem, or remote API failure is an outgoing interaction. -Support teams can relate runtime events to documented error cases. +```csharp +return SecondaryPortError.Create( + Code.PaymentProviderUnavailable, + diagnosticMessage: "The payment provider timed out after 5 seconds.", + transience: Transience.Transient) + .WithPublicMessage( + shortMessage: "The payment service is temporarily unavailable."); +``` -## đŸ› ïž 8. Composing with the `Outcome` pipeline +`Transience` indicates whether trying the same operation later may help. It does not itself implement a retry policy. -`Outcome` and `Outcome` let you compose success and failure paths without throwing. -A failure carries an `Error` (never an `Exception`), so the whole chain stays diagnosable. +## 🔁 Multi-step application flows -* **`Then(...)`** — chain the next step only when the previous one succeeded (short-circuits on failure). -* **`To(...)`** — map the carried value to another value (`Outcome` only), preserving any failure. -* **`Recover(...)`** — provide a fallback when the chain has failed. -* **`Finally(...)`** — run terminal handling for both success and failure. +When several expected-failure operations must run in sequence, compose their outcomes rather than repeatedly checking and unpacking them. ```csharp -Outcome outcome = - TryCreateAmount(value, currencyCode) // Outcome - .Then(amount => CheckLimits(amount)) // Outcome, runs only on success - .To(amount => amount.WithVat()) // map the value, failures pass through - .Recover(error => Amount.Zero) // fallback value if the chain failed - .Then(amount => Charge(amount)) // Outcome - .Finally( - onSuccess: receipt => Log($"Charged {receipt}"), - onFailure: error => Log(error)); // error is an Error, fully diagnosable +Outcome result = + TryCreateAmount(value, currencyCode) + .Then(CheckLimits) + .Then(Charge); ``` -### Escape hatches +The first failure short-circuits the remaining steps and is propagated unchanged. Use a fluent chain only when it reads more clearly than ordinary branching. -When you need to leave the `Outcome` world (e.g. at an application boundary), two escape hatches turn a failure back into a throw: +The full behavior of `Then`, `To`, `Recover`, `Finally`, async overloads, and escape hatches is documented in [Composing with Outcome](OutcomeGuide.en.md). -* **`ThrowIfFailure()`** — throws the failure's exception (via `error.ToException()`) when the outcome failed; otherwise does nothing. -* **`GetResultOrThrow()`** — returns the carried value on success, or throws the failure's exception (`Outcome` only). +## đŸ§© Logging and support -```csharp -Outcome outcome = TryCreateAmount(value, currencyCode); +At the point where a failure is handled, log the structured error rather than only a public message. -outcome.ThrowIfFailure(); // throws error.ToException() on failure -Amount amount = outcome.GetResultOrThrow(); // value on success, otherwise throws -``` - -### Async composition +Useful fields include: -For asynchronous flows, `OutcomeTaskExtensions` provides `Then` / `To` / `Recover` / `Finally` -overloads over `Task` and `Task>`. Each overload accepts an optional -`CancellationToken`, so you can await the whole pipeline: +- `Code` for grouping and dashboards; +- `InstanceId` for correlating one occurrence; +- `OccurredAt` for timing; +- `DiagnosticMessage` for internal analysis; +- `Context` for occurrence-specific facts; +- `InnerErrors` for the causal chain. -```csharp -Outcome outcome = - await TryLoadAmountAsync(orderId, cancellationToken) // Task> - .Then(amount => CheckLimitsAsync(amount), cancellationToken) - .To(amount => amount.WithVat()) - .Recover(error => Amount.Zero) - .Then(amount => ChargeAsync(amount), cancellationToken) - .Finally( - onSuccess: receipt => LogAsync(receipt), - onFailure: error => LogAsync(error), - cancellationToken); -``` +Public messages are for callers. Diagnostic information is for logs, support, and developers. -## 🎯 Summary +## 📌 Decision checklist -FirstClassErrors shines when: +Before choosing a pattern, ask: -| Situation | Benefit | -| ----------------- | --------------------------- | -| Domain invariants | Clear semantic violations | -| Validation | Errors as data | -| Operations | Readable domain code | -| Batch processing | Non-blocking error handling | -| Integration | Better troubleshooting | -| Support | Structured knowledge | +1. Is this failure expected in the normal flow? +2. Must the current operation stop immediately? +3. Is the failure a domain rule, an incoming boundary condition, or an outgoing dependency failure? +4. Who is expected to make the next decision: this method or its caller? +5. Would a fluent `Outcome` chain be clearer than explicit branching? -The library helps you express not just that something failed — -but **what it means, why it might have happened, and where to look**. +Choose the representation from those answers, not from a blanket rule that every error must be thrown or every error must be returned. --- --- \ No newline at end of file From 20f33bb8f3d9fb71e8db83db0a2d0d44f9f4636d Mon Sep 17 00:00:00 2001 From: Sylvain Aurat Date: Tue, 14 Jul 2026 15:28:45 +0200 Subject: [PATCH 02/10] docs: clarify error usage patterns --- doc/UsagePatterns.fr.md | 227 ++++++++++++++++++---------------------- 1 file changed, 101 insertions(+), 126 deletions(-) diff --git a/doc/UsagePatterns.fr.md b/doc/UsagePatterns.fr.md index 27a7e7d..917decb 100644 --- a/doc/UsagePatterns.fr.md +++ b/doc/UsagePatterns.fr.md @@ -1,77 +1,89 @@ # Patterns d’utilisation -🌍 **Langues:** +🌍 **Langues :** 🇬🇧 [English](./UsagePatterns.en.md) | đŸ‡«đŸ‡· Français (ce fichier) -FirstClassErrors est particuliĂšrement utile lorsque les erreurs ne sont pas de simples dĂ©faillances techniques, mais des **Ă©vĂ©nements porteurs de sens dans la vie du systĂšme**. -Voici des patterns courants oĂč la bibliothĂšque apporte clartĂ© et structure. +FirstClassErrors permet aussi bien de lever une exception que de transporter une erreur comme une donnĂ©e. La dĂ©cision importante ne dĂ©pend pas de la syntaxe prĂ©fĂ©rĂ©e, mais de **ce que signifie l’échec dans le flux courant**. -## đŸ§± 1. Invariants de Value Object +Ce guide aide Ă  choisir le bon pattern. Pour l’API complĂšte d’`Outcome` et la composition des pipelines, consultez [Composer avec Outcome](OutcomeGuide.fr.md). -Lors de la crĂ©ation d’un value object, les Ă©tats invalides doivent ĂȘtre rejetĂ©s. +## 🧭 Choisir selon le sens de l’échec + +| Situation | ReprĂ©sentation conseillĂ©e | Pourquoi | +| --- | --- | --- | +| Un invariant mĂ©tier est violĂ© et l’opĂ©ration ne peut pas continuer | lever une `DomainError` via `.ToException()` | l’échec interrompt l’opĂ©ration et appartient au domaine | +| Une entrĂ©e invalide est un rĂ©sultat attendu de validation ou de parsing | retourner `Outcome.Failure(...)` | l’appelant peut traiter l’échec sans utiliser les exceptions comme contrĂŽle de flux | +| Un Ă©lĂ©ment Ă©choue dans un batch | retourner un `Outcome` par Ă©lĂ©ment | un Ă©chec ne doit pas forcĂ©ment interrompre tout le lot | +| Une frontiĂšre entrante rejette une interaction | utiliser une `PrimaryPortError` | elle conserve la direction entrante et l’éventuelle cause mĂ©tier | +| Une dĂ©pendance sortante Ă©choue | utiliser une `SecondaryPortError` | elle conserve la direction sortante et la transience | +| Un Ă©chec doit franchir une frontiĂšre applicative sous forme d’exception | appeler `ThrowIfFailure()` ou `GetResultOrThrow()` Ă  cette frontiĂšre | l’erreur reste une donnĂ©e jusqu’au point d’escalade choisi | + +La mĂȘme factory documentĂ©e peut servir Ă  plusieurs transports. L’erreur dĂ©crit **ce qui s’est produit** ; la lever ou la retourner dĂ©crit **comment cet appelant choisit de la propager**. + +## đŸ§± Invariants mĂ©tier + +Un value object ou une entitĂ© ne doit jamais entrer dans un Ă©tat invalide. Lorsque la construction ou l’opĂ©ration ne peut pas continuer, levez l’erreur mĂ©tier documentĂ©e. ```csharp public static Amount From(decimal value, Currency currency) { - if (value < 0) { throw InvalidAmountOperationError.NegativeAmount(value).ToException(); } + if (value < 0) { + throw InvalidAmountOperationError.NegativeAmount(value).ToException(); + } return new Amount(value, currency); } ``` -Ici : +Le happy path reste lisible tandis que la factory centralise le code, les messages, le contexte et la documentation. -* la rĂšgle mĂ©tier est explicite -* l’exception reprĂ©sente une violation prĂ©cise d’invariant -* la documentation dĂ©crit la rĂšgle et les diagnostics +## 🧼 OpĂ©rations mĂ©tier -Le code mĂ©tier reste expressif et auto-explicatif. - -## đŸ“„ 2. Validation d’entrĂ©e (API / UI) - -Les entrĂ©es utilisateur ou externes peuvent ĂȘtre invalides, sans ĂȘtre exceptionnelles au sens technique. +Des objets mĂ©tier valides peuvent nĂ©anmoins participer Ă  une opĂ©ration qui viole une rĂšgle. ```csharp -public Outcome TryCreateAmount(decimal value, string currencyCode){ - if (!Currency.TryParse(currencyCode, out var currency)) { - return Outcome.Failure(InvalidAmountOperationError.UnknownCurrency(currencyCode)); } +public Amount Add(Amount other) { + if (Currency != other.Currency) { + throw InvalidAmountOperationError.CurrencyMismatch(this, other).ToException(); + } - return Outcome.Success(new Amount(value, currency)); + return new Amount(Value + other.Value, Currency); } ``` -Les erreurs sont : - -* capturĂ©es -* transportables -* diagnostiquables - -sans interrompre le flux. +PrĂ©fĂ©rez une factory prĂ©cise Ă  une exception gĂ©nĂ©rique comme `InvalidOperationException`. Le code doit indiquer quelle situation mĂ©tier s’est produite. -## 🧼 3. OpĂ©rations mĂ©tier +## đŸ“„ Échecs de validation attendus -Les opĂ©rations entre objets mĂ©tier comportent souvent des contraintes sĂ©mantiques. +Les entrĂ©es utilisateur, le parsing et la validation mĂ©tier Ă©chouent souvent dans le cadre du flux normal. Retournez l’erreur comme donnĂ©e lorsque l’appelant est censĂ© dĂ©cider de la suite. ```csharp -public Amount Add(Amount other) { - if (Currency != other.Currency) { throw InvalidAmountOperationError.CurrencyMismatch(this, other).ToException(); } +public Outcome TryCreateAmount(decimal value, string currencyCode) { + if (!Currency.TryParse(currencyCode, out Currency currency)) { + return Outcome.Failure( + InvalidAmountOperationError.UnknownCurrency(currencyCode)); + } - return new Amount(Value + other.Value, Currency); + if (value < 0) { + return Outcome.Failure( + InvalidAmountOperationError.NegativeAmount(value)); + } + + return Outcome.Success(new Amount(value, currency)); } ``` -Le code se lit comme un langage mĂ©tier, tandis que l’erreur reste structurĂ©e et documentĂ©e. +L’échec transporte toujours la mĂȘme `Error` structurĂ©e ; elle n’est simplement pas levĂ©e Ă  ce stade. -## 📩 4. Traitement par lots ou fichiers +## 📩 Traitement par lots et fichiers -En traitement batch, de nombreux Ă©lĂ©ments peuvent Ă©chouer indĂ©pendamment. +Dans un batch, chaque Ă©lĂ©ment peut Ă©chouer indĂ©pendamment. Traitez chaque `Outcome` et poursuivez lorsque cela correspond Ă  la rĂšgle mĂ©tier. ```csharp -foreach (var line in file) { - var result = TryParseAmount(line); +foreach (string line in file) { + Outcome result = TryParseAmount(line); if (result.IsFailure) { - Log(result.Error); - + Log(result.Error!); continue; } @@ -79,124 +91,87 @@ foreach (var line in file) { } ``` -Les erreurs sont : - -* collectĂ©es -* loguĂ©es avec diagnostics complets -* non bloquantes pour l’ensemble du traitement - -## 🌐 5. FrontiĂšres d’intĂ©gration - -Lors d’interactions avec des systĂšmes externes : - -* les donnĂ©es peuvent ĂȘtre incohĂ©rentes -* les formats peuvent Ă©voluer -* les hypothĂšses peuvent ĂȘtre invalides - -Les erreurs de premiĂšre classe aident Ă  distinguer : - -* les problĂšmes mĂ©tier -* les problĂšmes d’entrĂ©e -* les problĂšmes systĂšme ou de transformation - -Les diagnostics orientent l’investigation. +Ce pattern n’est pertinent que si continuer est intentionnel. Si un Ă©lĂ©ment invalide rend tout le fichier invalide, retournez ou levez plutĂŽt une erreur au niveau du fichier. -## 🔁 6. Pipelines de validation +## 🌐 FrontiĂšres entrantes -Les validations complexes impliquent souvent plusieurs contrĂŽles. +Un adapter entrant peut rejeter une interaction parce que le mapping, la validation ou la construction mĂ©tier a Ă©chouĂ©. ```csharp -var result = ValidateAmount(amount) - .Then(CheckCurrency) - .Then(CheckLimits); +DomainError invalidAmount = InvalidAmountOperationError.NegativeAmount(request.Amount); + +return PrimaryPortError.Create( + Code.RequestRejected, + diagnosticMessage: $"La requĂȘte {request.Id} contient un montant invalide.", + new PrimaryPortInnerErrors().Add(invalidAmount), + configureContext: ctx => ctx.Add(ErrCtxKey.RequestId, request.Id)) + .WithPublicMessage( + shortMessage: "La requĂȘte contient des donnĂ©es invalides."); ``` -Chaque Ă©chec porte une `Error`, ce qui garde un modĂšle cohĂ©rent tout en Ă©vitant des levĂ©es d’exception incontrĂŽlĂ©es. +L’erreur de domaine explique la rĂšgle violĂ©e. L’erreur de port primaire explique le rĂ©sultat Ă  la frontiĂšre. Consultez [Taxonomie et composition des erreurs](ErrorTaxonomy.fr.md) pour les rĂšgles d’imbrication. -## đŸ§© 7. Logging orientĂ© support +## 🔌 DĂ©pendances sortantes -Comme les erreurs portent des diagnostics structurĂ©s, les logs deviennent plus exploitables : +Une panne de base de donnĂ©es, broker, systĂšme de fichiers ou API distante est une interaction sortante. -* codes d’erreur stables -* messages courts porteurs de sens -* causes documentĂ©es - -Les Ă©quipes support peuvent relier les Ă©vĂ©nements runtime Ă  des cas d’erreur documentĂ©s. +```csharp +return SecondaryPortError.Create( + Code.PaymentProviderUnavailable, + diagnosticMessage: "Le fournisseur de paiement a dĂ©passĂ© le dĂ©lai de 5 secondes.", + transience: Transience.Transient) + .WithPublicMessage( + shortMessage: "Le service de paiement est temporairement indisponible."); +``` -## đŸ› ïž 8. Composer avec le pipeline `Outcome` +`Transience` indique si retenter plus tard peut ĂȘtre utile. Elle n’implĂ©mente pas elle-mĂȘme une politique de retry. -`Outcome` et `Outcome` permettent de composer les chemins de succĂšs et d’échec sans lever d’exception. -Un Ă©chec porte une `Error` (jamais une `Exception`), si bien que toute la chaĂźne reste diagnostiquable. +## 🔁 Flux applicatifs en plusieurs Ă©tapes -* **`Then(...)`** — enchaĂźne l’étape suivante uniquement si la prĂ©cĂ©dente a rĂ©ussi (court-circuite en cas d’échec). -* **`To(...)`** — transforme la valeur portĂ©e en une autre valeur (`Outcome` uniquement), en prĂ©servant un Ă©ventuel Ă©chec. -* **`Recover(...)`** — fournit une valeur de repli lorsque la chaĂźne a Ă©chouĂ©. -* **`Finally(...)`** — exĂ©cute un traitement terminal pour le succĂšs comme pour l’échec. +Lorsque plusieurs opĂ©rations susceptibles d’échouer doivent s’enchaĂźner, composez leurs outcomes au lieu de vĂ©rifier et dĂ©baller chaque rĂ©sultat manuellement. ```csharp -Outcome outcome = - TryCreateAmount(value, currencyCode) // Outcome - .Then(amount => CheckLimits(amount)) // Outcome, exĂ©cutĂ© seulement en cas de succĂšs - .To(amount => amount.WithVat()) // transforme la valeur, les Ă©checs passent au travers - .Recover(error => Amount.Zero) // valeur de repli si la chaĂźne a Ă©chouĂ© - .Then(amount => Charge(amount)) // Outcome - .Finally( - onSuccess: receipt => Log($"Charged {receipt}"), - onFailure: error => Log(error)); // error est une Error, pleinement diagnostiquable +Outcome result = + TryCreateAmount(value, currencyCode) + .Then(CheckLimits) + .Then(Charge); ``` -### Échappatoires - -Lorsqu’il faut sortir du monde `Outcome` (par exemple Ă  une frontiĂšre applicative), deux Ă©chappatoires retransforment un Ă©chec en levĂ©e d’exception : +Le premier Ă©chec court-circuite les Ă©tapes suivantes et est propagĂ© sans modification. Utilisez une chaĂźne fluide uniquement lorsqu’elle est plus lisible qu’un branchement explicite. -* **`ThrowIfFailure()`** — lĂšve l’exception de l’échec (via `error.ToException()`) lorsque l’outcome a Ă©chouĂ© ; sinon ne fait rien. -* **`GetResultOrThrow()`** — retourne la valeur portĂ©e en cas de succĂšs, ou lĂšve l’exception de l’échec (`Outcome` uniquement). +Le comportement complet de `Then`, `To`, `Recover`, `Finally`, des surcharges async et des Ă©chappatoires est documentĂ© dans [Composer avec Outcome](OutcomeGuide.fr.md). -```csharp -Outcome outcome = TryCreateAmount(value, currencyCode); +## đŸ§© Logging et support -outcome.ThrowIfFailure(); // lĂšve error.ToException() en cas d’échec -Amount amount = outcome.GetResultOrThrow(); // valeur en cas de succĂšs, sinon lĂšve -``` +Au point oĂč l’échec est traitĂ©, loguez l’erreur structurĂ©e plutĂŽt que seulement un message public. -### Composition asynchrone +Les champs utiles comprennent : -Pour les flux asynchrones, `OutcomeTaskExtensions` fournit des surcharges `Then` / `To` / `Recover` / `Finally` -sur `Task` et `Task>`. Chaque surcharge accepte un `CancellationToken` optionnel, -ce qui permet d’attendre l’ensemble du pipeline : +- `Code` pour les regroupements et dashboards ; +- `InstanceId` pour corrĂ©ler une occurrence ; +- `OccurredAt` pour l’horodatage ; +- `DiagnosticMessage` pour l’analyse interne ; +- `Context` pour les faits propres Ă  l’occurrence ; +- `InnerErrors` pour la chaĂźne causale. -```csharp -Outcome outcome = - await TryLoadAmountAsync(orderId, cancellationToken) // Task> - .Then(amount => CheckLimitsAsync(amount), cancellationToken) - .To(amount => amount.WithVat()) - .Recover(error => Amount.Zero) - .Then(amount => ChargeAsync(amount), cancellationToken) - .Finally( - onSuccess: receipt => LogAsync(receipt), - onFailure: error => LogAsync(error), - cancellationToken); -``` +Les messages publics sont destinĂ©s aux appelants. Les informations de diagnostic sont destinĂ©es aux logs, au support et aux dĂ©veloppeurs. -## 🎯 RĂ©sumĂ© +## 📌 Checklist de dĂ©cision -FirstClassErrors brille lorsque : +Avant de choisir un pattern, demandez-vous : -| Situation | BĂ©nĂ©fice | -| ----------------- | ------------------------------- | -| Invariants mĂ©tier | Violations sĂ©mantiques claires | -| Validation | Erreurs comme donnĂ©es | -| OpĂ©rations | Code mĂ©tier lisible | -| Traitement batch | Gestion d’erreurs non bloquante | -| IntĂ©gration | Meilleur dĂ©pannage | -| Support | Connaissance structurĂ©e | +1. Cet Ă©chec est-il attendu dans le flux normal ? +2. L’opĂ©ration courante doit-elle s’arrĂȘter immĂ©diatement ? +3. S’agit-il d’une rĂšgle mĂ©tier, d’une condition de frontiĂšre entrante ou d’une dĂ©faillance de dĂ©pendance sortante ? +4. Qui doit prendre la prochaine dĂ©cision : cette mĂ©thode ou son appelant ? +5. Une chaĂźne `Outcome` serait-elle plus lisible qu’un branchement explicite ? -La bibliothĂšque vous aide Ă  exprimer non seulement qu’un Ă©chec s’est produit — mais **ce que cela signifie, pourquoi cela a pu arriver et oĂč chercher**. +Choisissez la reprĂ©sentation Ă  partir de ces rĂ©ponses, et non d’une rĂšgle absolue imposant de toujours lever ou toujours retourner les erreurs. --- --- \ No newline at end of file From eb560a72f5524d6851a14afe06c30c2ab582c1cb Mon Sep 17 00:00:00 2001 From: Sylvain Aurat Date: Tue, 14 Jul 2026 15:29:31 +0200 Subject: [PATCH 03/10] docs: add outcome composition guide --- doc/OutcomeGuide.en.md | 255 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 doc/OutcomeGuide.en.md diff --git a/doc/OutcomeGuide.en.md b/doc/OutcomeGuide.en.md new file mode 100644 index 0000000..96601f7 --- /dev/null +++ b/doc/OutcomeGuide.en.md @@ -0,0 +1,255 @@ +# Composing with Outcome + +🌍 **Languages:** +🇬🇧 English (this file) | đŸ‡«đŸ‡· [Français](./OutcomeGuide.fr.md) + +`Outcome` and `Outcome` carry a structured `Error` without throwing it. Use them when failure is expected and the caller should decide how to handle it. + +This page is the focused guide to creating, inspecting, composing, recovering, and escalating outcomes. For help deciding when to use them, start with [Usage Patterns](UsagePatterns.en.md). + +## 🧭 The model in one minute + +```mermaid +flowchart LR + A[Operation] --> B{Success?} + B -->|yes| C[Outcome or Outcome success] + B -->|no| D[Outcome or Outcome failure carrying Error] + C --> E[Continue or read Value] + D --> F[Handle, recover, propagate, or throw] +``` + +An outcome never carries an exception. A failure carries an `Error`, preserving its code, messages, context, occurrence identity, and inner errors. + +## `Outcome` or `Outcome`? + +| Type | Use it when | +| --- | --- | +| `Outcome` | success produces no value, such as reserving inventory or validating a command | +| `Outcome` | success produces a value, such as parsing an amount or loading an order | + +```csharp +Outcome reservation = Inventory.Reserve(sku); +Outcome lookup = Orders.Find(orderId); +``` + +## Creating outcomes + +### Success without a value + +```csharp +return Outcome.Success; +``` + +### Success with a value + +```csharp +return Outcome.Success(amount); +``` + +### Failure + +```csharp +return Outcome.Failure( + InvalidAmountOperationError.UnknownCurrency(currencyCode)); +``` + +`Failure(...)` requires an `Error`. Keep error creation in a named factory so the same situation remains consistent whether it is returned or thrown. + +## Inspecting an outcome + +Use `IsSuccess` and `IsFailure` when explicit branching is clearest. + +```csharp +Outcome result = TryCreateAmount(value, currencyCode); + +if (result.IsFailure) { + Log(result.Error!); + return; +} + +Process(result.Value); +``` + +On success, `Outcome.Value` contains the result. On failure, `Error` contains the structured failure. + +Do not read `Value` before establishing success. Prefer a pipeline or `GetResultOrThrow()` when that makes the control flow clearer. + +## `Then`: continue only after success + +`Then(...)` runs the next operation only when the current outcome succeeded. A failure short-circuits the chain and is propagated unchanged. + +```csharp +Outcome result = + TryCreateAmount(value, currencyCode) + .Then(CheckLimits) + .Then(Charge); +``` + +The example has three possible failure points, but the caller receives one `Outcome`. The first error remains the result. + +`Then` is appropriate when the next step may itself fail and therefore returns another `Outcome` or `Outcome`. + +## `To`: transform a successful value + +`To(...)` maps the value carried by a successful `Outcome`. It does not run after a failure. + +```csharp +Outcome total = + TryCreateAmount(value, currencyCode) + .To(amount => amount.WithVat()); +``` + +Use `To` for a transformation that cannot fail. Use `Then` when the transformation returns an outcome because it may fail. + +```csharp +Outcome mapped = amountOutcome.To(AddVat); +Outcome validated = amountOutcome.Then(CheckLimits); +``` + +## `Recover`: replace a failure deliberately + +`Recover(...)` runs only after failure and receives the current `Error`. + +```csharp +Outcome rate = + LoadLiveRate(currency) + .Recover(error => LoadCachedRate(currency)); +``` + +Recovery may produce a success or a new failure. If the fallback fails, its error becomes the outcome's error. + +Use recovery for a real alternative strategy, compensation, or fallback. Do not use it merely to hide an error. + +A value fallback can be returned as a successful outcome: + +```csharp +Outcome amount = + TryCreateAmount(value, currencyCode) + .Recover(error => Outcome.Success(Amount.Zero)); +``` + +## `Finally`: handle both terminal cases + +`Finally(...)` selects one terminal action or value based on success or failure. + +```csharp +string message = result.Finally( + onSuccess: receipt => $"Charged {receipt.Id}", + onFailure: error => $"Failed with {error.Code}"); +``` + +It is useful at application boundaries where both cases must be translated into another representation, such as an API response, a CLI exit result, or a log entry. + +```csharp +result.Finally( + onSuccess: receipt => LogInformation(receipt), + onFailure: error => LogError(error)); +``` + +`Finally` is terminal: use it to consume or translate an outcome, not as a hidden intermediate step in a long chain. + +## Leaving the outcome flow + +Two methods convert a failure back into exception flow. + +### `ThrowIfFailure()` + +Use it for `Outcome` or when the successful value is not needed at that point. + +```csharp +Outcome reservation = Inventory.Reserve(sku); +reservation.ThrowIfFailure(); +``` + +It does nothing on success and throws `error.ToException()` on failure. + +### `GetResultOrThrow()` + +Use it with `Outcome` when the boundary requires a value or an exception. + +```csharp +Amount amount = TryCreateAmount(value, currencyCode).GetResultOrThrow(); +``` + +The exception is created and thrown at this point. The stack trace therefore begins at the escalation point, not where the `Outcome` failure was created. + +Keep this conversion at an intentional boundary. Calling it immediately after every operation removes the reason for using `Outcome`. + +## Asynchronous composition + +The same operations are available for asynchronous steps. Pass the cancellation token through the chain. + +```csharp +Outcome result = + await LoadOrderAsync(orderId, cancellationToken) + .Then( + (order, ct) => ValidateOrderAsync(order, ct), + cancellationToken) + .Then( + (order, ct) => ChargeAsync(order, ct), + cancellationToken); +``` + +`OutcomeTaskExtensions` also lets a `Task` or `Task>` continue directly with `Then`, `To`, `Recover`, and `Finally`. + +Prefer one visible cancellation token for the whole application flow. Cancellation remains cancellation; do not translate it into a domain error unless the application explicitly models that situation as one. + +## Fluent chain or ordinary branching? + +A fluent chain is useful when: + +- the flow is a sequence of dependent steps; +- every step follows the same success/failure model; +- short-circuiting is the intended behavior; +- the chain reads from left to right without hiding business decisions. + +Prefer explicit branching when: + +- different errors require substantially different actions; +- several independent operations must all run; +- partial results must be collected; +- the chain becomes harder to read than a few `if` statements. + +FirstClassErrors does not require railway-oriented code everywhere. `Outcome` serves the model; the model does not serve the fluent syntax. + +## Complete example + +```csharp +public async Task> CheckoutAsync( + decimal value, + string currencyCode, + CancellationToken cancellationToken) { + + return await TryCreateAmount(value, currencyCode) + .Then(CheckLimits) + .Then( + (amount, ct) => ChargeAsync(amount, ct), + cancellationToken) + .Recover( + (error, ct) => TryAlternativeProviderAsync(error, ct), + cancellationToken); +} +``` + +The flow keeps one structured error model from validation through payment. No exception is needed until a caller deliberately chooses to escalate. + +## 📌 Review checklist + +Before approving an `Outcome` flow, verify that: + +- failure is expected and belongs in normal control flow; +- failures carry an `Error`, never an exception or string; +- factories remain the single source of error construction; +- `Then` is used for fallible steps and `To` for infallible mapping; +- recovery is intentional and does not silently discard useful failures; +- exception conversion occurs only at a clear boundary; +- a fluent chain is genuinely clearer than explicit branching; +- cancellation tokens are propagated through asynchronous operations. + +--- + + + +--- \ No newline at end of file From f539d27ca748cdc498afddefb662e7299da04e94 Mon Sep 17 00:00:00 2001 From: Sylvain Aurat Date: Tue, 14 Jul 2026 15:30:34 +0200 Subject: [PATCH 04/10] docs: add outcome composition guide --- doc/OutcomeGuide.fr.md | 256 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 256 insertions(+) create mode 100644 doc/OutcomeGuide.fr.md diff --git a/doc/OutcomeGuide.fr.md b/doc/OutcomeGuide.fr.md new file mode 100644 index 0000000..5973820 --- /dev/null +++ b/doc/OutcomeGuide.fr.md @@ -0,0 +1,256 @@ +# Composer avec Outcome + +🌍 **Langues :** +🇬🇧 [English](./OutcomeGuide.en.md) | đŸ‡«đŸ‡· Français (ce fichier) + +`Outcome` et `Outcome` transportent une `Error` structurĂ©e sans la lever. Utilisez-les lorsque l’échec est attendu et que l’appelant doit dĂ©cider comment le traiter. + +Cette page est le guide dĂ©diĂ© Ă  la crĂ©ation, l’inspection, la composition, la rĂ©cupĂ©ration et l’escalade des outcomes. Pour choisir quand les utiliser, commencez par [Patterns d’utilisation](UsagePatterns.fr.md). + +## 🧭 Le modĂšle en une minute + +```mermaid +flowchart LR + A[OpĂ©ration] --> B{SuccĂšs ?} + B -->|oui| C[SuccĂšs Outcome ou Outcome] + B -->|non| D[Échec Outcome ou Outcome portant Error] + C --> E[Continuer ou rĂ©cupĂ©rer le rĂ©sultat] + D --> F[Traiter, rĂ©cupĂ©rer, propager ou lever] +``` + +Un outcome ne transporte jamais une exception. Un Ă©chec porte une `Error` et conserve son code, ses messages, son contexte, son identitĂ© d’occurrence et ses erreurs internes. + +## `Outcome` ou `Outcome` ? + +| Type | À utiliser lorsque | +| --- | --- | +| `Outcome` | le succĂšs ne produit aucune valeur, par exemple rĂ©server un stock ou valider une commande | +| `Outcome` | le succĂšs produit une valeur, par exemple parser un montant ou charger une commande | + +```csharp +Outcome reservation = Inventory.Reserve(sku); +Outcome lookup = Orders.Find(orderId); +``` + +## CrĂ©er des outcomes + +### SuccĂšs sans valeur + +```csharp +return Outcome.Success; +``` + +### SuccĂšs avec une valeur + +```csharp +return Outcome.Success(amount); +``` + +### Échec + +```csharp +return Outcome.Failure( + InvalidAmountOperationError.UnknownCurrency(currencyCode)); +``` + +`Failure(...)` exige une `Error`. Gardez la crĂ©ation des erreurs dans une factory nommĂ©e afin qu’une mĂȘme situation reste cohĂ©rente, qu’elle soit retournĂ©e ou levĂ©e. + +## Inspecter un outcome + +Utilisez `IsSuccess` et `IsFailure` lorsqu’un branchement explicite est le plus lisible. + +```csharp +Outcome result = TryCreateAmount(value, currencyCode); + +if (result.IsFailure) { + Log(result.Error!); + return; +} + +Amount amount = result.GetResultOrThrow(); +Process(amount); +``` + +En cas d’échec, `Error` contient l’erreur structurĂ©e. AprĂšs avoir Ă©tabli le succĂšs, `GetResultOrThrow()` retourne le rĂ©sultat sans lever. + +PrĂ©fĂ©rez un pipeline lorsque cela rend le contrĂŽle de flux plus clair. + +## `Then` : continuer seulement aprĂšs un succĂšs + +`Then(...)` exĂ©cute l’opĂ©ration suivante uniquement lorsque l’outcome courant a rĂ©ussi. Un Ă©chec court-circuite la chaĂźne et est propagĂ© sans modification. + +```csharp +Outcome result = + TryCreateAmount(value, currencyCode) + .Then(CheckLimits) + .Then(Charge); +``` + +L’exemple comporte trois points d’échec possibles, mais l’appelant reçoit un seul `Outcome`. La premiĂšre erreur reste le rĂ©sultat. + +`Then` convient lorsque l’étape suivante peut elle-mĂȘme Ă©chouer et retourne donc un autre `Outcome` ou `Outcome`. + +## `To` : transformer une valeur rĂ©ussie + +`To(...)` transforme la valeur portĂ©e par un `Outcome` rĂ©ussi. Il ne s’exĂ©cute pas aprĂšs un Ă©chec. + +```csharp +Outcome total = + TryCreateAmount(value, currencyCode) + .To(amount => amount.WithVat()); +``` + +Utilisez `To` pour une transformation qui ne peut pas Ă©chouer. Utilisez `Then` lorsque la transformation retourne un outcome parce qu’elle peut Ă©chouer. + +```csharp +Outcome mapped = amountOutcome.To(AddVat); +Outcome validated = amountOutcome.Then(CheckLimits); +``` + +## `Recover` : remplacer volontairement un Ă©chec + +`Recover(...)` s’exĂ©cute uniquement aprĂšs un Ă©chec et reçoit l’`Error` courante. + +```csharp +Outcome rate = + LoadLiveRate(currency) + .Recover(error => LoadCachedRate(currency)); +``` + +La rĂ©cupĂ©ration peut produire un succĂšs ou un nouvel Ă©chec. Si le fallback Ă©choue, son erreur devient l’erreur de l’outcome. + +Utilisez la rĂ©cupĂ©ration pour une vĂ©ritable stratĂ©gie alternative, compensation ou valeur de repli. Ne l’utilisez pas simplement pour masquer une erreur. + +Une valeur de repli peut ĂȘtre retournĂ©e sous forme de succĂšs : + +```csharp +Outcome amount = + TryCreateAmount(value, currencyCode) + .Recover(error => Outcome.Success(Amount.Zero)); +``` + +## `Finally` : traiter les deux cas terminaux + +`Finally(...)` sĂ©lectionne une action ou une valeur terminale selon le succĂšs ou l’échec. + +```csharp +string message = result.Finally( + onSuccess: receipt => $"Paiement {receipt.Id} effectuĂ©", + onFailure: error => $"Échec avec {error.Code}"); +``` + +Il est utile aux frontiĂšres applicatives oĂč les deux cas doivent ĂȘtre traduits vers une autre reprĂ©sentation : rĂ©ponse API, rĂ©sultat CLI ou entrĂ©e de log. + +```csharp +result.Finally( + onSuccess: receipt => LogInformation(receipt), + onFailure: error => LogError(error)); +``` + +`Finally` est terminal : utilisez-le pour consommer ou traduire un outcome, pas comme une Ă©tape intermĂ©diaire cachĂ©e dans une longue chaĂźne. + +## Sortir du flux Outcome + +Deux mĂ©thodes reconvertissent un Ă©chec en flux par exception. + +### `ThrowIfFailure()` + +Utilisez-la avec `Outcome`, ou lorsque la valeur rĂ©ussie n’est pas nĂ©cessaire Ă  cet endroit. + +```csharp +Outcome reservation = Inventory.Reserve(sku); +reservation.ThrowIfFailure(); +``` + +Elle ne fait rien en cas de succĂšs et lĂšve `error.ToException()` en cas d’échec. + +### `GetResultOrThrow()` + +Utilisez-la avec `Outcome` lorsqu’une frontiĂšre exige une valeur ou une exception. + +```csharp +Amount amount = TryCreateAmount(value, currencyCode).GetResultOrThrow(); +``` + +L’exception est créée et levĂ©e Ă  cet endroit. La stack trace commence donc au point d’escalade, pas lĂ  oĂč l’échec `Outcome` a Ă©tĂ© créé. + +Gardez cette conversion Ă  une frontiĂšre intentionnelle. L’appeler immĂ©diatement aprĂšs chaque opĂ©ration supprimerait l’intĂ©rĂȘt d’utiliser `Outcome`. + +## Composition asynchrone + +Les mĂȘmes opĂ©rations existent pour les Ă©tapes asynchrones. Propagez le token d’annulation dans la chaĂźne. + +```csharp +Outcome result = + await LoadOrderAsync(orderId, cancellationToken) + .Then( + (order, ct) => ValidateOrderAsync(order, ct), + cancellationToken) + .Then( + (order, ct) => ChargeAsync(order, ct), + cancellationToken); +``` + +`OutcomeTaskExtensions` permet Ă©galement Ă  un `Task` ou `Task>` de continuer directement avec `Then`, `To`, `Recover` et `Finally`. + +PrĂ©fĂ©rez un token d’annulation visible pour tout le flux applicatif. Une annulation reste une annulation ; ne la traduisez pas en erreur mĂ©tier sauf si l’application modĂ©lise explicitement cette situation. + +## ChaĂźne fluide ou branchement classique ? + +Une chaĂźne fluide est utile lorsque : + +- le flux est une sĂ©quence d’étapes dĂ©pendantes ; +- chaque Ă©tape suit le mĂȘme modĂšle succĂšs/Ă©chec ; +- le court-circuit est le comportement voulu ; +- la chaĂźne se lit de gauche Ă  droite sans masquer les dĂ©cisions mĂ©tier. + +PrĂ©fĂ©rez un branchement explicite lorsque : + +- des erreurs diffĂ©rentes exigent des actions trĂšs diffĂ©rentes ; +- plusieurs opĂ©rations indĂ©pendantes doivent toutes s’exĂ©cuter ; +- des rĂ©sultats partiels doivent ĂȘtre collectĂ©s ; +- la chaĂźne devient moins lisible que quelques `if`. + +FirstClassErrors n’impose pas du railway-oriented programming partout. `Outcome` sert le modĂšle ; le modĂšle ne sert pas la syntaxe fluide. + +## Exemple complet + +```csharp +public async Task> CheckoutAsync( + decimal value, + string currencyCode, + CancellationToken cancellationToken) { + + return await TryCreateAmount(value, currencyCode) + .Then(CheckLimits) + .Then( + (amount, ct) => ChargeAsync(amount, ct), + cancellationToken) + .Recover( + (error, ct) => TryAlternativeProviderAsync(error, ct), + cancellationToken); +} +``` + +Le flux conserve un seul modĂšle d’erreur structurĂ© de la validation jusqu’au paiement. Aucune exception n’est nĂ©cessaire tant qu’un appelant ne choisit pas explicitement d’escalader. + +## 📌 Checklist de revue + +Avant de valider un flux `Outcome`, vĂ©rifiez que : + +- l’échec est attendu et appartient au contrĂŽle de flux normal ; +- les Ă©checs portent une `Error`, jamais une exception ou une chaĂźne ; +- les factories restent la source unique de construction des erreurs ; +- `Then` est utilisĂ© pour les Ă©tapes susceptibles d’échouer et `To` pour les transformations infaillibles ; +- la rĂ©cupĂ©ration est intentionnelle et ne supprime pas silencieusement des erreurs utiles ; +- la conversion en exception n’a lieu qu’à une frontiĂšre claire ; +- une chaĂźne fluide est rĂ©ellement plus lisible qu’un branchement explicite ; +- les tokens d’annulation sont propagĂ©s dans les opĂ©rations asynchrones. + +--- + + + +--- \ No newline at end of file From 664e1d05cac53389f1336914d51c73227e421b84 Mon Sep 17 00:00:00 2001 From: Sylvain Aurat Date: Tue, 14 Jul 2026 15:31:12 +0200 Subject: [PATCH 05/10] docs: correct outcome result access --- doc/UsagePatterns.en.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/UsagePatterns.en.md b/doc/UsagePatterns.en.md index 1422d03..582749a 100644 --- a/doc/UsagePatterns.en.md +++ b/doc/UsagePatterns.en.md @@ -87,11 +87,11 @@ foreach (string line in file) { continue; } - Process(result.Value); + Process(result.GetResultOrThrow()); } ``` -This pattern is appropriate only when continuing is intentional. If one invalid item invalidates the entire file, return or throw a file-level error instead. +After `IsFailure` has been ruled out, `GetResultOrThrow()` returns the value without throwing. This pattern is appropriate only when continuing is intentional. If one invalid item invalidates the entire file, return or throw a file-level error instead. ## 🌐 Incoming boundaries From 8bb9ba6da770f8b9f112bf715243f981a918682c Mon Sep 17 00:00:00 2001 From: Sylvain Aurat Date: Tue, 14 Jul 2026 15:31:57 +0200 Subject: [PATCH 06/10] docs: correct outcome result access --- doc/UsagePatterns.fr.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/UsagePatterns.fr.md b/doc/UsagePatterns.fr.md index 917decb..28b4827 100644 --- a/doc/UsagePatterns.fr.md +++ b/doc/UsagePatterns.fr.md @@ -87,11 +87,11 @@ foreach (string line in file) { continue; } - Process(result.Value); + Process(result.GetResultOrThrow()); } ``` -Ce pattern n’est pertinent que si continuer est intentionnel. Si un Ă©lĂ©ment invalide rend tout le fichier invalide, retournez ou levez plutĂŽt une erreur au niveau du fichier. +AprĂšs avoir Ă©cartĂ© `IsFailure`, `GetResultOrThrow()` retourne la valeur sans lever. Ce pattern n’est pertinent que si continuer est intentionnel. Si un Ă©lĂ©ment invalide rend tout le fichier invalide, retournez ou levez plutĂŽt une erreur au niveau du fichier. ## 🌐 FrontiĂšres entrantes From 38ae50177443b754ed241b21ef6d116d5267e1a2 Mon Sep 17 00:00:00 2001 From: Sylvain Aurat Date: Tue, 14 Jul 2026 15:32:54 +0200 Subject: [PATCH 07/10] docs: correct outcome result access --- doc/OutcomeGuide.en.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/doc/OutcomeGuide.en.md b/doc/OutcomeGuide.en.md index 96601f7..80b5d80 100644 --- a/doc/OutcomeGuide.en.md +++ b/doc/OutcomeGuide.en.md @@ -14,7 +14,7 @@ flowchart LR A[Operation] --> B{Success?} B -->|yes| C[Outcome or Outcome success] B -->|no| D[Outcome or Outcome failure carrying Error] - C --> E[Continue or read Value] + C --> E[Continue or retrieve the result] D --> F[Handle, recover, propagate, or throw] ``` @@ -67,12 +67,13 @@ if (result.IsFailure) { return; } -Process(result.Value); +Amount amount = result.GetResultOrThrow(); +Process(amount); ``` -On success, `Outcome.Value` contains the result. On failure, `Error` contains the structured failure. +On failure, `Error` contains the structured failure. After success has been established, `GetResultOrThrow()` returns the result without throwing. -Do not read `Value` before establishing success. Prefer a pipeline or `GetResultOrThrow()` when that makes the control flow clearer. +Prefer a pipeline when that makes the control flow clearer. ## `Then`: continue only after success From 49f2b7117cde6c36c3f79724f02b9ba86bb6c2db Mon Sep 17 00:00:00 2001 From: Sylvain Aurat Date: Tue, 14 Jul 2026 15:33:40 +0200 Subject: [PATCH 08/10] docs: clarify how error context travels --- doc/ErrorContext.en.md | 165 +++++++++++++++++++++++++++++------------ 1 file changed, 116 insertions(+), 49 deletions(-) diff --git a/doc/ErrorContext.en.md b/doc/ErrorContext.en.md index f02fa6b..114ce56 100644 --- a/doc/ErrorContext.en.md +++ b/doc/ErrorContext.en.md @@ -3,98 +3,165 @@ 🌍 **Languages:** 🇬🇧 English (this file) | đŸ‡«đŸ‡· [Français](./ErrorContext.fr.md) -`ErrorContext` lets you attach **structured, typed, and stable** metadata to an `Error` (via `Error.Context`), reached from a thrown exception through `exception.Error.Context`. +`ErrorContext` attaches **structured, typed, and stable** metadata to an `Error`. -It complements the error code and messages by answering: +It answers one question: -> What was true at the moment this specific error happened? +> What was true when this specific occurrence happened? + +The error code identifies the situation. The context records the facts that make this occurrence diagnosable. ## ✅ When to use `ErrorContext` -Use it when the information is useful for diagnosis and observability, and varies per occurrence. +Use it when the information: + +- varies from one occurrence to another; +- helps diagnosis, correlation, or observability; +- is safe to record in logs; +- can be represented as a small, stable value. -Typical examples: +Typical examples include: -* a transaction date that is outside an allowed period -* a business identifier useful for investigation (`OrderId`, `StatementId`, `CustomerId`) -* a measured value that violated a rule (`ProvidedTemperature`, `DeclaredAmount`) +- a business identifier used during investigation (`OrderId`, `StatementId`, `CustomerId`); +- a value that violated a rule (`ProvidedTemperature`, `DeclaredAmount`); +- a relevant date or boundary (`TransactionDate`, `PeriodStart`, `PeriodEnd`); +- an external correlation identifier. -In short: use context for **instance-level facts**, not for generic error semantics. +In short: use context for **instance-level facts**, not for the stable meaning of the error. ## ❌ When not to use it Do not put in context: -* information that already belongs to the stable error definition (title, rule, diagnostics) -* large payloads (full files, huge objects, full request/response bodies) -* secrets or sensitive data (passwords, tokens, full personal data) -* operational instructions (“open ticket”, “contact team X”) - -If data is unstable, noisy, sensitive, or not actionable, keep it out. - -## 🎯 Why it improves observability +- information that belongs to the stable error definition, such as its title, rule, or diagnostics; +- full request or response bodies; +- large objects or files; +- passwords, tokens, secrets, or unnecessary personal data; +- operational instructions such as “open a ticket” or “contact team X”; +- values that nobody can use during investigation. -With `ErrorCode` you can group errors by type. -With `ErrorContext` you can understand **why this occurrence** happened. +If data is noisy, sensitive, unstable, oversized, or not actionable, keep it out. -This enables: +## 🎯 Code, messages, and context have different roles -* better triage in logs -* faster correlation across systems -* easier dashboards and filtering (by context key) -* less back-and-forth between dev and support +| Element | Question it answers | +| --- | --- | +| `ErrorCode` | Which recognized error situation occurred? | +| public messages | What may be explained safely to the caller? | +| `DiagnosticMessage` | What internal detail explains this occurrence? | +| `ErrorContext` | Which structured facts should logs and tooling be able to query? | -Think of it this way: +Do not duplicate every value from the diagnostic message into context. Add a context entry when the value should be searchable, filterable, correlated, or consumed by tooling. -* `ErrorCode` = *which error category is this?* -* `ErrorContext` = *what are the key facts for this occurrence?* +## đŸ§± Define named, reusable keys -## đŸ§± Design guidelines - -### 1) Use named, reusable keys - -Define context keys once in a central place: +Define keys once in a stable location: ```csharp internal static class ErrCtxKey { + public static readonly ErrorContextKey OrderId = + ErrorContextKey.Create( + "ORDER_ID", + "Identifier of the order being processed."); + public static readonly ErrorContextKey TransactionDate = - ErrorContextKey.Create("TRANSACTION_DATE", "Date of the transaction being processed."); + ErrorContextKey.Create( + "TRANSACTION_DATE", + "Date of the transaction being processed."); } ``` -### 2) Add context at factory level +A named key gives the value a stable identity and type. Dashboards, log queries, and generated documentation can rely on that contract. -Attach context where the error is created, so every occurrence is consistent: +## 🏭 Add context in the error factory + +Attach context where the error is created so every occurrence uses the same keys. ```csharp return PrimaryPortError.Create( Code.DateOutOfStatementPeriod, - diagnosticMessage: $"Transaction dated {transactionDate} is outside the statement period.", + diagnosticMessage: $"Transaction dated {transactionDate} is outside [{periodStart}; {periodEnd}].", transience: Transience.NonTransient, - configureContext: ctx => ctx.Add(ErrCtxKey.TransactionDate, transactionDate)) + configureContext: ctx => ctx + .Add(ErrCtxKey.TransactionDate, transactionDate) + .Add(ErrCtxKey.StatementId, statementId)) .WithPublicMessage( - shortMessage: "Transaction date is outside the statement period.", - detailedMessage: "The transaction date falls outside the allowed statement period."); + shortMessage: "The transaction date is outside the statement period.", + detailedMessage: "The transaction cannot be accepted for this statement period."); ``` -### 3) Keep values simple and serializable +Adding context in an adapter, catch block, or logging middleware after the fact risks inconsistent keys and missing data. Prefer the factory whenever the information is available there. + +## 🔁 Context travels with the error + +Context belongs to the `Error`, not to a particular transport. + +```mermaid +flowchart LR + A[Error factory] --> B[Error with Context] + B --> C[Outcome] + B --> D[error.ToException()] + C --> E[result.Error.Context] + D --> F[exception.Error.Context] +``` + +The same context is preserved when the error: + +- is returned inside `Outcome` or `Outcome`; +- is propagated through `Then`, `To`, or other outcome operations; +- is converted into an exception through `ToException()`; +- is nested as an inner error. -Prefer primitives and small value objects that log cleanly. +This is why context should describe the occurrence itself rather than one transport such as HTTP or exceptions. See [Usage Patterns](UsagePatterns.en.md) and [Composing with Outcome](OutcomeGuide.en.md). -### 4) Keep key names stable +## 📩 Keep values small and serializable -Context keys become part of your operational vocabulary. Renaming them frequently hurts dashboards and queries. +Prefer primitives, enums, identifiers, dates, and small value objects that serialize predictably. + +Good context: + +```text +ORDER_ID = 7f7a7f30-3b28-44d6-b956-f85ef8f70b03 +TRANSACTION_DATE = 2026-07-14 +PROVIDED_AMOUNT = 127.33 +``` + +Poor context: + +```text +ORDER = +REQUEST_BODY = +CUSTOMER = +``` + +If several values describe the same failure, use several explicit keys rather than one opaque object. + +## 🔒 Treat context as log data + +Even when context is not public API data, assume it may appear in logs, traces, support tools, or exported telemetry. + +Before adding a value, consider: + +- data-protection requirements; +- retention duration; +- access to operational tooling; +- whether hashing or partial redaction is sufficient; +- whether the value is truly necessary. + +A technically useful value is not automatically appropriate to record. ## 📌 Practical checklist Before adding a context entry, ask: -* Does it help someone diagnose this error faster? -* Is it safe to expose in logs? -* Is it specific to this occurrence? -* Can support/ops act on it? +1. Does it vary per occurrence? +2. Does it help someone investigate faster? +3. Should logs or tooling be able to query it independently? +4. Is the key name stable and reusable? +5. Is the value small and predictably serializable? +6. Is it safe to retain in operational systems? -If yes to most of these, it is a good candidate. +If the answer is yes to all six, it is a strong context candidate. --- @@ -102,4 +169,4 @@ If yes to most of these, it is a good candidate. ← Core Concepts · ↑ Table of contents · Writing Errors Guide → ---- +--- \ No newline at end of file From 9d493efb976f4f7205bbee4d51de5659b0417735 Mon Sep 17 00:00:00 2001 From: Sylvain Aurat Date: Tue, 14 Jul 2026 15:34:27 +0200 Subject: [PATCH 09/10] docs: clarify how error context travels --- doc/ErrorContext.fr.md | 170 +++++++++++++++++++++++++++++------------ 1 file changed, 121 insertions(+), 49 deletions(-) diff --git a/doc/ErrorContext.fr.md b/doc/ErrorContext.fr.md index a0c97db..762ee85 100644 --- a/doc/ErrorContext.fr.md +++ b/doc/ErrorContext.fr.md @@ -1,100 +1,172 @@ # Contexte d’erreur : quand et pourquoi l’utiliser -🌍 **Langues:** +🌍 **Langues :** 🇬🇧 [English](./ErrorContext.en.md) | đŸ‡«đŸ‡· Français (ce fichier) -`ErrorContext` permet d’attacher des mĂ©tadonnĂ©es **structurĂ©es, typĂ©es et stables** Ă  une `Error` (via `Error.Context`), accessibles depuis une exception levĂ©e via `exception.Error.Context`. +`ErrorContext` attache des mĂ©tadonnĂ©es **structurĂ©es, typĂ©es et stables** Ă  une `Error`. -Il complĂšte le code d’erreur et les messages en rĂ©pondant Ă  : +Il rĂ©pond Ă  une question : -> Qu’est-ce qui Ă©tait vrai au moment oĂč cette erreur prĂ©cise s’est produite ? +> Qu’est-ce qui Ă©tait vrai lorsque cette occurrence prĂ©cise s’est produite ? + +Le code d’erreur identifie la situation. Le contexte enregistre les faits qui rendent cette occurrence diagnostiquable. ## ✅ Quand utiliser `ErrorContext` -Utilisez-le lorsque l’information est utile pour le diagnostic et l’observabilitĂ©, et qu’elle varie selon les occurrences. +Utilisez-le lorsque l’information : + +- varie d’une occurrence Ă  l’autre ; +- aide le diagnostic, la corrĂ©lation ou l’observabilitĂ© ; +- peut ĂȘtre enregistrĂ©e sans risque dans les logs ; +- peut ĂȘtre reprĂ©sentĂ©e par une valeur petite et stable. -Exemples typiques : +Exemples courants : -* une date de transaction hors pĂ©riode autorisĂ©e -* un identifiant mĂ©tier utile Ă  l’investigation (`OrderId`, `StatementId`, `CustomerId`) -* une valeur mesurĂ©e qui viole une rĂšgle (`ProvidedTemperature`, `DeclaredAmount`) +- un identifiant mĂ©tier utilisĂ© pendant l’investigation (`OrderId`, `StatementId`, `CustomerId`) ; +- une valeur ayant violĂ© une rĂšgle (`ProvidedTemperature`, `DeclaredAmount`) ; +- une date ou une borne pertinente (`TransactionDate`, `PeriodStart`, `PeriodEnd`) ; +- un identifiant de corrĂ©lation externe. -En bref : utilisez le contexte pour des **faits liĂ©s Ă  l’occurrence**, pas pour la sĂ©mantique gĂ©nĂ©rale de l’erreur. +En bref : utilisez le contexte pour des **faits liĂ©s Ă  l’occurrence**, pas pour la signification stable de l’erreur. ## ❌ Quand ne pas l’utiliser N’ajoutez pas dans le contexte : -* des informations qui appartiennent dĂ©jĂ  Ă  la dĂ©finition stable de l’erreur (titre, rĂšgle, diagnostics) -* des payloads volumineux (fichiers complets, objets massifs, corps de requĂȘte/rĂ©ponse complets) -* des secrets ou donnĂ©es sensibles (mots de passe, tokens, donnĂ©es personnelles complĂštes) -* des instructions opĂ©rationnelles (« ouvrir un ticket », « contacter l’équipe X ») - -Si l’information est instable, bruyante, sensible ou non actionnable, ne l’ajoutez pas. - -## 🎯 Pourquoi cela amĂ©liore l’observabilitĂ© +- des informations appartenant Ă  la dĂ©finition stable de l’erreur, comme son titre, sa rĂšgle ou ses diagnostics ; +- des corps de requĂȘte ou de rĂ©ponse complets ; +- de gros objets ou fichiers ; +- des mots de passe, tokens, secrets ou donnĂ©es personnelles inutiles ; +- des instructions opĂ©rationnelles comme « ouvrir un ticket » ou « contacter l’équipe X » ; +- des valeurs que personne ne peut exploiter pendant l’investigation. -Avec `ErrorCode`, vous regroupez les erreurs par type. -Avec `ErrorContext`, vous comprenez **pourquoi cette occurrence** est arrivĂ©e. +Si une donnĂ©e est bruyante, sensible, instable, volumineuse ou non actionnable, laissez-la de cĂŽtĂ©. -Cela permet : +## 🎯 Le code, les messages et le contexte ont des rĂŽles diffĂ©rents -* un triage plus rapide dans les logs -* une corrĂ©lation plus simple entre systĂšmes -* des dashboards et filtres plus prĂ©cis (par clĂ© de contexte) -* moins d’allers-retours entre dev et support +| ÉlĂ©ment | Question Ă  laquelle il rĂ©pond | +| --- | --- | +| `ErrorCode` | Quelle situation d’erreur reconnue s’est produite ? | +| messages publics | Que peut-on expliquer sans risque Ă  l’appelant ? | +| `DiagnosticMessage` | Quel dĂ©tail interne explique cette occurrence ? | +| `ErrorContext` | Quels faits structurĂ©s les logs et les outils doivent-ils pouvoir interroger ? | -À retenir : +Ne dupliquez pas chaque valeur du message de diagnostic dans le contexte. Ajoutez une entrĂ©e lorsque la valeur doit ĂȘtre recherchable, filtrable, corrĂ©lable ou consommĂ©e par un outil. -* `ErrorCode` = *de quelle catĂ©gorie d’erreur parle-t-on ?* -* `ErrorContext` = *quels sont les faits clĂ©s de cette occurrence ?* +## đŸ§± DĂ©finir des clĂ©s nommĂ©es et rĂ©utilisables -## đŸ§± RĂšgles de conception - -### 1) Utiliser des clĂ©s nommĂ©es et rĂ©utilisables - -DĂ©finissez les clĂ©s de contexte une fois dans un emplacement central : +DĂ©finissez les clĂ©s une seule fois dans un emplacement stable : ```csharp internal static class ErrCtxKey { + public static readonly ErrorContextKey OrderId = + ErrorContextKey.Create( + "ORDER_ID", + "Identifiant de la commande en cours de traitement."); + + public static readonly ErrorContextKey StatementId = + ErrorContextKey.Create( + "STATEMENT_ID", + "Identifiant du relevĂ© en cours de traitement."); + public static readonly ErrorContextKey TransactionDate = - ErrorContextKey.Create("TRANSACTION_DATE", "Date de la transaction en cours de traitement."); + ErrorContextKey.Create( + "TRANSACTION_DATE", + "Date de la transaction en cours de traitement."); } ``` -### 2) Ajouter le contexte au niveau des factories +Une clĂ© nommĂ©e donne Ă  la valeur une identitĂ© et un type stables. Les dashboards, requĂȘtes de logs et la documentation gĂ©nĂ©rĂ©e peuvent s’appuyer sur ce contrat. + +## 🏭 Ajouter le contexte dans la factory -Attachez le contexte lĂ  oĂč l’erreur est créée, pour garantir la cohĂ©rence de chaque occurrence : +Attachez le contexte lĂ  oĂč l’erreur est créée afin que chaque occurrence utilise les mĂȘmes clĂ©s. ```csharp return PrimaryPortError.Create( Code.DateOutOfStatementPeriod, - diagnosticMessage: $"Transaction datĂ©e du {transactionDate} hors pĂ©riode du relevĂ©.", + diagnosticMessage: $"Transaction datĂ©e du {transactionDate} hors pĂ©riode [{periodStart}; {periodEnd}].", transience: Transience.NonTransient, - configureContext: ctx => ctx.Add(ErrCtxKey.TransactionDate, transactionDate)) + configureContext: ctx => ctx + .Add(ErrCtxKey.TransactionDate, transactionDate) + .Add(ErrCtxKey.StatementId, statementId)) .WithPublicMessage( shortMessage: "La date de transaction est hors pĂ©riode du relevĂ©.", - detailedMessage: "La date de transaction se situe hors de la pĂ©riode du relevĂ© autorisĂ©e."); + detailedMessage: "La transaction ne peut pas ĂȘtre acceptĂ©e pour cette pĂ©riode de relevĂ©."); +``` + +Ajouter le contexte aprĂšs coup dans un adapter, un bloc `catch` ou un middleware de logging risque de produire des clĂ©s incohĂ©rentes et des donnĂ©es manquantes. PrĂ©fĂ©rez la factory lorsque l’information y est disponible. + +## 🔁 Le contexte voyage avec l’erreur + +Le contexte appartient Ă  l’`Error`, pas Ă  un transport particulier. + +```mermaid +flowchart LR + A[Factory d’erreur] --> B[Error avec Context] + B --> C[Outcome] + B --> D[error.ToException()] + C --> E[result.Error.Context] + D --> F[exception.Error.Context] ``` -### 3) Garder des valeurs simples et sĂ©rialisables +Le mĂȘme contexte est conservĂ© lorsque l’erreur : -PrivilĂ©giez les primitives et petits value objects faciles Ă  logger. +- est retournĂ©e dans `Outcome` ou `Outcome` ; +- est propagĂ©e via `Then`, `To` ou d’autres opĂ©rations d’outcome ; +- est transformĂ©e en exception via `ToException()` ; +- est imbriquĂ©e comme erreur interne. -### 4) Garder des noms de clĂ©s stables +Le contexte doit donc dĂ©crire l’occurrence elle-mĂȘme, et non un transport comme HTTP ou les exceptions. Consultez [Patterns d’utilisation](UsagePatterns.fr.md) et [Composer avec Outcome](OutcomeGuide.fr.md). -Les clĂ©s de contexte deviennent une partie de votre vocabulaire opĂ©rationnel. Les renommer souvent casse les requĂȘtes et dashboards. +## 📩 Garder des valeurs petites et sĂ©rialisables + +PrivilĂ©giez les primitives, enums, identifiants, dates et petits value objects qui se sĂ©rialisent de façon prĂ©visible. + +Bon contexte : + +```text +ORDER_ID = 7f7a7f30-3b28-44d6-b956-f85ef8f70b03 +TRANSACTION_DATE = 2026-07-14 +PROVIDED_AMOUNT = 127.33 +``` + +Mauvais contexte : + +```text +ORDER = +REQUEST_BODY = +CUSTOMER = +``` + +Si plusieurs valeurs dĂ©crivent le mĂȘme Ă©chec, utilisez plusieurs clĂ©s explicites plutĂŽt qu’un objet opaque. + +## 🔒 Traiter le contexte comme une donnĂ©e de log + +MĂȘme lorsque le contexte ne fait pas partie d’une API publique, supposez qu’il peut apparaĂźtre dans les logs, traces, outils de support ou exports de tĂ©lĂ©mĂ©trie. + +Avant d’ajouter une valeur, considĂ©rez : + +- les exigences de protection des donnĂ©es ; +- la durĂ©e de conservation ; +- les accĂšs aux outils opĂ©rationnels ; +- la possibilitĂ© d’un hachage ou d’une occultation partielle ; +- la nĂ©cessitĂ© rĂ©elle de cette valeur. + +Une valeur techniquement utile n’est pas automatiquement appropriĂ©e Ă  enregistrer. ## 📌 Checklist pratique Avant d’ajouter une entrĂ©e de contexte, demandez-vous : -* Est-ce que cela aide Ă  diagnostiquer plus vite ? -* Est-ce sĂ»r Ă  exposer dans les logs ? -* Est-ce spĂ©cifique Ă  cette occurrence ? -* Est-ce actionnable pour le support / l’exploitation ? +1. Varie-t-elle selon l’occurrence ? +2. Aide-t-elle quelqu’un Ă  investiguer plus vite ? +3. Les logs ou les outils doivent-ils pouvoir l’interroger indĂ©pendamment ? +4. Le nom de la clĂ© est-il stable et rĂ©utilisable ? +5. La valeur est-elle petite et sĂ©rialisable de façon prĂ©visible ? +6. Peut-elle ĂȘtre conservĂ©e sans risque dans les systĂšmes opĂ©rationnels ? -Si la majoritĂ© des rĂ©ponses est oui, c’est un bon candidat. +Si la rĂ©ponse est oui aux six questions, c’est une excellente candidate. --- @@ -102,4 +174,4 @@ Si la majoritĂ© des rĂ©ponses est oui, c’est un bon candidat. ← Concepts fondamentaux · ↑ Table des matiĂšres · Guide d’écriture des erreurs → ---- +--- \ No newline at end of file From 0c14e89335a96eb0fa5f2a6d02f885d44f676120 Mon Sep 17 00:00:00 2001 From: Sylvain Aurat Date: Tue, 14 Jul 2026 15:35:08 +0200 Subject: [PATCH 10/10] docs: complete error context key example --- doc/ErrorContext.en.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/doc/ErrorContext.en.md b/doc/ErrorContext.en.md index 114ce56..a3fc567 100644 --- a/doc/ErrorContext.en.md +++ b/doc/ErrorContext.en.md @@ -64,6 +64,11 @@ internal static class ErrCtxKey { "ORDER_ID", "Identifier of the order being processed."); + public static readonly ErrorContextKey StatementId = + ErrorContextKey.Create( + "STATEMENT_ID", + "Identifier of the statement being processed."); + public static readonly ErrorContextKey TransactionDate = ErrorContextKey.Create( "TRANSACTION_DATE",