On this page

Transaction review: a complete handler tour

The opening handler prepares a review report using injected dependencies, owned resources and concurrent work. Its input models and calling scope are below.

The input models

import System
import System.Collections.Generic

public class PrepareTransactionReviews
    Batches as List <of TransactionBatch> = default
end class

public class Transaction
    public properties
        Id as Guid
        Amount as Decimal
        Status as String = "Pending"
        Enabled as Boolean = true
        Reference as String = default
    end properties
end class

public class TransactionBatch
    Transactions as List <of Transaction> = default
end class

The caller supplies Id, Amount and a payment Reference such as PAY-2026-0042; Enabled and Status have useful defaults. Each batch receives its own empty List through default. The handler skips absent, disabled, nonpending, EUR 100-or-less and non-PAY-reference rows. Only eligible rows require a nonempty identifier. is not empty rejects Guid.Empty without spelling the CLR constant.

The condition transaction is not nothing and .Enabled and .Amount > 100 and ... establishes one receiver and short-circuits later member tests. Case-insensitive is like "PAY-*" accepts a prefix with any suffix, including an empty one; it is not a strict reference-format validator. String equality also ignores case, so Pending matches pending. Neither comparison trims whitespace. Freeze inputs while the command runs.

Construct, call and clean up

Inside an async host action, with established gatewayClient, reviewLogger and batches values; the logger has type ILogger<TransactionReviewHandler> from Microsoft.Extensions.Logging:

let using handler = new TransactionReviewHandler("review-audit.txt") with
    manualReviewLimit: 10000
    http: gatewayClient
    logger: reviewLogger
end with

let command = new PrepareTransactionReviews()
command.Batches = batches

try
    let summary = await handler.HandleAsync(command)
    Console.WriteLine(summary)
    Console.WriteLine("Reviewed {{handler.ProcessedCount}} transactions")
catch Global.System.OperationCanceledException
    throw
catch error as Global.System.Exception
    reviewLogger.LogError(error, "Review stopped; earlier audit entries may already exist")
    throw
end try

The named block supplies the promoted ManualReviewLimit input and the two borrowed needs. Constructor-local with properties contributes a readonly property, an optional effective constructor parameter and its initialization; it is not a setter call after construction. There is exactly one declared constructor, as this shorthand requires. The ordinary auditPath argument comes first. The constructor rejects a limit at or below EUR 100 before opening the file. The host owns HttpClient and sets BaseAddress and authentication. Audit creation can fail during construction. Once construction succeeds, let using owns the handler through the enclosing scope, including failure and cancellation. Audit disposal can itself fail and follows the ordinary cleanup exception rules. Cancellation propagates without an error log. Other failures are logged and rethrown, including an AggregateException when several owned producers fail. The logger is assumed not to throw. Single-brace logging placeholders such as {TransactionId} belong to ILogger; double braces such as {{transaction.Id}} are DirectSharp interpolation. The catch does not manufacture a successful report.

Explicit handler contract

The opening declares ITransactionReviewHandler and places HandleAsync inside implements ITransactionReviewHandler ... end implements. The block declares interface intent and maps the compatible member without repeating a member-level marker. Callers can use the interface; cleanup remains owned through the concrete handler variable. Normal async and cancellation signatures must still match the interface. See interface mapping.

Gateway helper actions

Add these actions inside TransactionReviewHandler, with System.Net and System.Threading.Tasks imported. They complete the opening handler; the logger is its borrowed need, and the totals Gate is local to HandleAsync and is not involved in these network helpers.

    private async action ReadCustomerAsync(id as Guid)
        returns String
        description "Use the first successful customer replica; settle owned losing work."

        // A fast failure must not beat a later successful response.
        concurrent first
            returns customer as String
            customer = run ReadGatewayAsync("replicas/primary/transactions/{{id}}/customer")
            customer = run ReadGatewayAsync("replicas/backup/transactions/{{id}}/customer")
        end concurrent

        return customer
    end action

    private async action ReadGatewayAsync(path as String)
        returns String
        description "Read the gateway, retrying HTTP 503 at most twice."

        try
            return await http.GetStringAsync(path, Cancellation.Token)
        catch error as HttpRequestException when error.StatusCode = HttpStatusCode.ServiceUnavailable
            // The lexical attempt record is one-based; allow three total attempts.
            if LastAttempt.Number < 3 then
                logger.LogWarning(error, "Gateway busy; retrying {Path} after attempt {Attempt}", path, LastAttempt.Number)
                // A retry delay holds no totals permit.
                await Task.Delay(TimeSpan.FromMilliseconds(200), Cancellation.Token)
                retry
            end if
            throw
        end try
    end action

Each description documents an action's contract. Comments explain the lifetime and failure decisions at the point where they matter. Logging uses native ILogger templates, not an invented logging language.

Both required results, first successful replica

The outer concurrent all publishes customer and risk together after both operations settle. The customer branch owns a nested concurrent first with two read-only GET requests producing the same result binding. This chooses the first success, not the first completion: a quick HTTP failure does not win. If neither replica succeeds, the documented failure/aggregation rules apply.

After selection, first requests cooperative cancellation of unfinished losers and joins them. A losing request that ignores cancellation can delay publication. There is no promise of immediate return, a killed thread or rollback of remote work. Both replicas must have equivalent authorization, response meaning and acceptable freshness. These routes read data; do not race payment submission or other non-idempotent commands.

Protect totals, not requests

The outer loop visits batches sequentially. Within each batch, concurrent for_each ... with limit: 4 admits up to four transaction workers. Each worker can start a risk GET and two customer-replica GETs. There is no separate global HTTP-capacity limit in this example.

The local new Gate(1) has a different job: all workers share one permit around writes to report, audit, ProcessedCount and ProcessedAmount. Those are genuine shared mutations. No HTTP await or retry delay occurs while holding it. Count and amount calculations precede writes; this mutual exclusion still supplies no rollback for a writer or logging failure. Logger calls outside the Gate require a thread-safe provider.

Rows appear in worker completion order, not input order. A task requiring input-order output should collect per-iteration results and format after the join; see ordered parallel invoice processing. The command joins its workers before reading report.ToString or disposing its Gate. A second concurrent call on the same handler would have a different Gate and is outside this sample's usage contract.

The HTTP 503 filter retries only that outcome, for at most three attempts numbered from one. Delay cancellation prevents the next retry. Non-503 HTTP failures, independent cancellation, exhausted attempts and other failures propagate normally. A new invocation has its own retry state. LastAttempt belongs to the helper's try, not to a shared handler field.

ProcessedCount is cumulative across sequential calls. Count and amount advance inside the Gate after each row is formatted and its audit WriteLine returns; this does not promise durable storage. Each invocation has a separate report buffer, totals Gate and lazy instructions value. Empty input returns an empty String and never reads the instructions file. A failure returns no report; it does not retract earlier side effects. The gateway supplies raw text here for presentation, not a parsed authorization or risk decision.

Cleanup and finalization

The handler owns managed writers, so dispose ... end dispose is sufficient. There is no separate destructor declaration in the frozen syntax. An unmanaged section inside dispose also supplies the CLR finalizer fallback for a genuinely owned native resource. This independent interop example illustrates the distinction:

import System
import System.Runtime.InteropServices

public class NativeScratchBuffer
    private handle as IntPtr = default

    constructor(byteCount as Integer)
        description "Allocate the native memory owned by this buffer."
        require byteCount > 0
        handle = Marshal.AllocHGlobal(byteCount)
    end constructor

    dispose
        // Only this section is eligible for finalization fallback.
        unmanaged
            if handle <> IntPtr.Zero then
                Marshal.FreeHGlobal(handle)
                handle = IntPtr.Zero
            end if
        end unmanaged
    end dispose
end class

Successful explicit disposal frees the allocation and suppresses finalization. The generated finalizer runs only unmanaged cleanup; managed cleanup belongs outside that section. There is no usable buffer pointer exposed here and no finalization timing guarantee. Normal application code should use existing managed owners or SafeHandle-based .NET APIs when available. The lifecycle guard is compiler-managed; a handwritten Boolean is not needed to prevent repeated successful cleanup. This does not make ordinary method calls after disposal valid or supply a public finalized predicate. Call the handler only inside its owned lifetime. See the exact cleanup lifecycle.

What the notation buys you

Need in the handler DirectSharp notation
Visible HTTP and logging dependencies needs ... end needs
Grouped public state with controlled writes public properties, with private set
Validated readonly construction input Constructor-local with properties and named construction
Explicit contract implementation implements ITransactionReviewHandler ... end implements
Construction and owned lifetime constructor, let using, dispose
Fresh empty input list and zero counter Effective default
Readable status comparison transaction.Status = "pending", .Reference is like "PAY-*"
Nested application traversal Sequential batches and bounded concurrent transactions with explicit endings
Both required inputs concurrent all, run, result publication at the join
First successful replica Nested concurrent first, cooperative cancellation and settlement
Bounded transient GET retries Filtered try, LastAttempt.Number, cancellable delay and retry
Shared count, amount and writer updates enter totals ... leave over a command-owned one-permit Gate
Optional work on first demand lazy with cached result or failure
Deliberate fallback for optional text try_get ... ?? ...
Multiple policy cases and a chosen value select, case, take
Branch-specific note Expression if with lazy branch evaluation
Report text and line separators {{...}}, {{Chars.NewLine}}

For repeated validator headers, see class kinds with ordinary FluentValidation. For parallel processing of many independent items, see bounded invoice processing. The handler visits batches sequentially and processes eligible transactions concurrently, with nested owned requests for each item.