By Patrick McCurley

TECH-589 ClaimWorkflowBase Encapsulation Refactor

By Patrick McCurley · Created Mar 4, 2026 public

What Changed

This refactor replaces direct field access across courier workflows with property accessors and removes EmailData from hook method signatures. The goal: tighten encapsulation on ClaimWorkflowBase so subclasses interact through a well-defined surface area instead of reaching into internals.

Before → After

graph LR
    subgraph Before["Before: Direct Field Access"]
        direction TB
        B1["_config"] -.-> C1["protected field"]
        B2["_shouldComplete"] -.-> C2["protected field"]
        B3["_completionReason"] -.-> C3["protected field"]
        B4["_emailQueue"] -.-> C4["protected field"]
        B5["_isBackfill"] -.-> C5["protected field"]
    end

    subgraph After["After: Property Accessors"]
        direction TB
        A1["Config"] --> P1["private _config"]
        A2["ShouldComplete"] --> P2["auto-property"]
        A3["CompletionReason"] --> P3["auto-property"]
        A4["EmailQueue"] --> P4["private _emailQueue"]
        A5["CurrentEmail"] --> P5["private _currentEmail"]
    end

    style Before fill:#fce4ec
    style After fill:#e8f5e9

Field → Accessor Mapping

Old (protected field) New (accessor) Access Notes
_config Config { get; private protected set; } Read everywhere, write from base + derived Setter needed for continue-as-new
_shouldComplete ShouldComplete { get; set; } Read + Write Auto-property replaces field
_completionReason CompletionReason { get; set; } Read + Write Auto-property replaces field
_emailQueue EmailQueue (readonly property) Read + Dequeue Backing field stays private readonly
_isBackfill (no accessor) Private only Not accessed by any courier
(new) CurrentEmail Read only Throws if accessed outside email processing

Visibility Changes

Two methods that were unnecessarily protected virtual are now correctly scoped:

Method Before After Reason
RunEmailLoopAsync() protected virtual private No courier overrides this — they either use the base template or own their entire loop
IsFinalClassification() protected virtual private Classification finality is a base concern, not courier-specific

Hook Signature Cleanup

The 4 email processing hooks previously passed EmailData through their signatures. Since the base already tracks which email is being processed, hooks can access it via CurrentEmail instead.

sequenceDiagram
    participant Base as ClaimWorkflowBase
    participant Hook as Courier Override

    Note over Base: _currentEmail = email
    Base->>Hook: OnPreEmailProcessedAsync()
    Note over Hook: Access via CurrentEmail if needed

    Base->>Base: Classify + Process

    Base->>Hook: OnPreClaimStatusUpdateAsync(classification, processingData)
    Note over Hook: DHL uses CurrentEmail here

    Base->>Base: Update claim status

    Base->>Hook: OnPostClaimStatusUpdateAsync(classification, result)
    Base->>Hook: OnPostEmailProcessedAsync(classification)
    Note over Base: _currentEmail = null (finally block)

Before

protected virtual Task OnPreEmailProcessedAsync(EmailData email) { ... }
protected virtual Task OnPostEmailProcessedAsync(EmailData email, ClassificationResult classification) { ... }
protected virtual Task OnPreClaimStatusUpdateAsync(EmailData email, ClassificationResult classification, ProcessingResult processingData) { ... }
protected virtual Task OnPostClaimStatusUpdateAsync(EmailData email, ClassificationResult classification, UpdateClaimStatusResponse result) { ... }

After

protected virtual Task OnPreEmailProcessedAsync() { ... }
protected virtual Task OnPostEmailProcessedAsync(ClassificationResult classification) { ... }
protected virtual Task OnPreClaimStatusUpdateAsync(ClassificationResult classification, ProcessingResult processingData) { ... }
protected virtual Task OnPostClaimStatusUpdateAsync(ClassificationResult classification, UpdateClaimStatusResponse result) { ... }

DHL Override (only real override)

// Before
protected override Task OnPreClaimStatusUpdateAsync(
    EmailData email, ClassificationResult classification, ProcessingResult processingData)
{
    if (classification.PredictedCategory.Equals("Credit", ...))
        CheckForBankDetailsRequest(email, processingData);  // ← email parameter
    return Task.CompletedTask;
}

// After
protected override Task OnPreClaimStatusUpdateAsync(
    ClassificationResult classification, ProcessingResult processingData)
{
    if (classification.PredictedCategory.Equals("Credit", ...))
        CheckForBankDetailsRequest(CurrentEmail, processingData);  // ← property
    return Task.CompletedTask;
}

Files Changed

All paths relative to backend/Temporal/EmailProcessing/EmailProcessing.Workflows/Couriers/

graph TD
    subgraph Base["Common/"]
        CWB["ClaimWorkflowBase.cs<br/>+accessors, +CurrentEmail<br/>hook signatures, _field→Property"]
    end

    subgraph Couriers["Courier Workflows"]
        DHL["DHLClaimWorkflow.cs<br/>hook signature + field renames"]
        DPD["DPD/DPDClaimWorkflow.cs<br/>field renames"]
        IP["InPostClaimWorkflow.cs<br/>field renames"]
        RM["RoyalMail/RoyalMailClaimWorkflow.cs<br/>field renames"]
        AS["AsendiaClaimWorkflow.cs<br/>field renames"]
    end

    CWB --> DHL
    CWB --> DPD
    CWB --> IP
    CWB --> RM
    CWB --> AS

    style CWB fill:#e1f5fe
    style DHL fill:#fff3e0
    style DPD fill:#e8f5e9
    style IP fill:#e8f5e9
    style RM fill:#e8f5e9
    style AS fill:#e8f5e9

Change summary per file

File What changed
ClaimWorkflowBase.cs Fields → private + accessors, CurrentEmail property, hook signatures cleaned, _config.Config. throughout, _shouldCompleteShouldComplete, _completionReasonCompletionReason, RunEmailLoopAsync / IsFinalClassification → private
DHLClaimWorkflow.cs Hook signature change (email param removed), emailCurrentEmail, _emailQueueEmailQueue, _shouldCompleteShouldComplete, _completionReasonCompletionReason, _configConfig
DPDClaimWorkflow.cs _emailQueueEmailQueue, _shouldCompleteShouldComplete, _completionReasonCompletionReason, _configConfig
InPostClaimWorkflow.cs Same field renames as DPD
RoyalMailClaimWorkflow.cs _configConfig, _shouldCompleteShouldComplete, _completionReasonCompletionReason
AsendiaClaimWorkflow.cs _configConfig

What's Next

Planned: AwaitResponseAsync primitive

A general-purpose "wait for reply, escalate if none" method to be added to ClaimWorkflowBase:

protected async Task<bool> AwaitResponseAsync(
    TimeSpan[] intervals, Func<ReminderContext, Task> onElapsed)

This gives couriers a declarative way to express chase patterns in their submission hooks:

// In AsendiaClaimWorkflow.OnSubmissionAsync, after submission:
await AwaitResponseAsync(
    _reminderHandler.GetIntervals(),
    async reminder => await _reminderHandler.RequestApprovalAndSendAsync(reminder.Number));

RunEmailLoopAsync stays as-is — AwaitResponseAsync is additive, not a replacement.

Verification