feat: implement strangler-fig-demo Session 1 (backend + smoke script)

Builds the four-seam, three-write-path reference demo backend: case-framework
(seam D stand-in), legacy-backend/frontend (SQL Server, seams A/B/C targets),
and new-backend (Domain/Application/Infrastructure.*/Api implementing the
source resolver, take/release-ownership, write-through translator, and owned
assessment flow), wired together via docker-compose with a plain placeholder
frontend standing in for the Angular portal until Session 2.

All 11 Architecture.Tests pass and scripts/smoke.sh passes end-to-end against
a fresh `docker compose up`, covering acceptance criteria 1-3 and 7-22.

Fixes two real domain bugs found only once the stack ran for real: the BSN
eleven-proof checksum trivially passes all-zero digits, and the adoption
mapper silently treated a partial legacy address as absent instead of failing
loudly. Also fixes several environment-specific integration issues (rootless
Podman/SELinux bind-mount permissions, a buildah NuGet layer-caching bug,
SqlClient's invariant-globalization incompatibility, and an nginx path-prefix
mismatch for the legacy frontend).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-31 07:57:26 +02:00
co-authored by Claude Sonnet 5
parent 09b27173a7
commit a6a1abbe9c
129 changed files with 6379 additions and 1 deletions
@@ -0,0 +1,10 @@
using New.Domain.ValueObjects;
namespace New.Application.Assessments;
public sealed record RecordAssessmentCommand(
IReadOnlyList<string> VerifiedItems,
string? ExceptionReason,
AssessmentOutcome Outcome,
string? RejectionCategory,
string Motivation);
@@ -0,0 +1,23 @@
namespace New.Application.Assessments;
public enum RecordAssessmentResultKind
{
Success,
NotFound,
InvariantViolation,
}
public sealed record RecordAssessmentResult(
RecordAssessmentResultKind Kind,
bool ClosurePending = false,
string? Invariant = null,
string? Message = null)
{
public static RecordAssessmentResult Success(bool closurePending) =>
new(RecordAssessmentResultKind.Success, ClosurePending: closurePending);
public static readonly RecordAssessmentResult NotFound = new(RecordAssessmentResultKind.NotFound);
public static RecordAssessmentResult InvariantViolation(string invariant, string message) =>
new(RecordAssessmentResultKind.InvariantViolation, Invariant: invariant, Message: message);
}
@@ -0,0 +1,58 @@
using New.Application.Ports;
using New.Domain;
namespace New.Application.Assessments;
/// <summary>
/// Orchestrates POST /api/worklist/owned/{id}/assessment. Re-validates
/// everything server-side via the domain's own RecordAssessment method,
/// regardless of what the client already checked.
/// </summary>
public sealed class RecordOwnedAssessmentHandler(
IRegistrationApplicationRepository repository,
IUnitOfWork unitOfWork,
IOwnershipRegistry registry,
ICaseFrameworkGateway caseFrameworkGateway,
TimeProvider clock)
{
public async Task<RecordAssessmentResult> HandleAsync(Guid registrationApplicationId, RecordAssessmentCommand command, CancellationToken ct)
{
var application = await repository.GetAsync(registrationApplicationId, ct);
if (application is null)
{
return RecordAssessmentResult.NotFound;
}
try
{
application.RecordAssessment(
command.Outcome,
command.Motivation,
command.VerifiedItems,
command.ExceptionReason,
command.RejectionCategory,
DateOnly.FromDateTime(clock.GetUtcNow().Date));
}
catch (DomainInvariantViolationException ex)
{
return RecordAssessmentResult.InvariantViolation(ex.Invariant, ex.Message);
}
// One transaction for the assessment write and the domain_writes_since
// bump that gates ownership release.
await registry.IncrementDomainWritesAsync(registrationApplicationId, ct);
await unitOfWork.SaveChangesAsync(ct);
var closurePending = false;
if (application.Case is not null)
{
// A 409 (open task) here is expected and fine - the assessment
// already succeeded above and is NOT rolled back for it; we just
// report that closure is pending.
var closed = await caseFrameworkGateway.RequestClosureAsync(application.Case.FrameworkCaseId, ct);
closurePending = !closed;
}
return RecordAssessmentResult.Success(closurePending);
}
}
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<InvariantGlobalization>true</InvariantGlobalization>
<RootNamespace>New.Application</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\New.Domain\New.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<!-- Logging abstractions only - no concrete logging provider, no infra. -->
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.1" />
</ItemGroup>
<!--
Application layer: ports (interfaces) and orchestration only. No EF Core,
no HttpClient, no infrastructure project references - handlers here talk
to the outside world exclusively through the ports defined in this project.
-->
</Project>
@@ -0,0 +1,45 @@
using New.Application.Ports;
namespace New.Application.Ownership;
/// <summary>
/// Orchestrates "release ownership" (DELETE /api/worklist/owned/{id}/ownership).
/// The framework case is deliberately left as-is on release - a logged orphan,
/// not cleaned up, per the spec.
/// </summary>
public sealed class ReleaseOwnershipHandler(
IOwnershipRegistry registry,
ILegacyCaseGateway legacyGateway,
IRegistrationApplicationRepository repository,
IUnitOfWork unitOfWork)
{
public async Task<ReleaseOwnershipResult> HandleAsync(Guid registrationApplicationId, CancellationToken ct)
{
var record = await registry.GetAsync(registrationApplicationId, ct);
if (record is null)
{
return ReleaseOwnershipResult.NotOwned;
}
// Releasing would discard un-synced edits made through the owned path
// since adoption - refuse rather than silently lose them.
if (record.DomainWritesSince > 0)
{
return ReleaseOwnershipResult.Conflict(
"Releasing ownership would discard un-synced edits made since this case was taken into ownership.");
}
await legacyGateway.SetMigratedFlagAsync(record.LegacyAanvraagId, migrated: false, ct);
var application = await repository.GetAsync(registrationApplicationId, ct);
if (application is not null)
{
await repository.RemoveAsync(application, ct);
}
await registry.RemoveAsync(registrationApplicationId, ct);
await unitOfWork.SaveChangesAsync(ct);
return ReleaseOwnershipResult.Success;
}
}
@@ -0,0 +1,17 @@
namespace New.Application.Ownership;
public enum ReleaseOwnershipResultKind
{
Success,
NotOwned,
Conflict,
}
public sealed record ReleaseOwnershipResult(ReleaseOwnershipResultKind Kind, string? Message = null)
{
public static readonly ReleaseOwnershipResult Success = new(ReleaseOwnershipResultKind.Success);
public static readonly ReleaseOwnershipResult NotOwned = new(ReleaseOwnershipResultKind.NotOwned);
public static ReleaseOwnershipResult Conflict(string message) =>
new(ReleaseOwnershipResultKind.Conflict, message);
}
@@ -0,0 +1,114 @@
using Microsoft.Extensions.Logging;
using New.Application.Ports;
using New.Domain;
using New.Domain.ValueObjects;
namespace New.Application.Ownership;
/// <summary>
/// Orchestrates "take ownership" of a legacy case (POST
/// /api/worklist/legacy/{aanvraagId}/take-ownership). References only ports -
/// see Architecture.Tests rule 8 - never any New.Infrastructure.* concrete
/// type, so this handler can be unit-tested (if this demo had a test suite
/// for it) against fakes with zero HTTP/DB involved.
///
/// The step order below is load-bearing, not incidental - see the comment on
/// each step for why it can't be reordered.
/// </summary>
public sealed class TakeOwnershipHandler(
IOwnershipRegistry registry,
ILegacyCaseGateway legacyGateway,
ICaseFrameworkGateway caseFrameworkGateway,
IRegistrationApplicationRepository repository,
IUnitOfWork unitOfWork,
TimeProvider clock,
ILogger<TakeOwnershipHandler> logger)
{
private const string CaseTypeCode = "RegistrationApplication";
public async Task<TakeOwnershipResult> HandleAsync(int aanvraagId, CancellationToken ct)
{
// Step 1: guard against double adoption. Checked first and cheaply,
// before touching legacy or case-framework at all.
var existingOwnedId = await registry.LookupOwnedIdAsync(aanvraagId, ct);
if (existingOwnedId is not null)
{
return TakeOwnershipResult.AlreadyOwned;
}
// Step 2: read the legacy case (seam A).
// Step 3: map it to a RegistrationApplication. The mapper calls the
// domain's normal validating constructors/factories, so any domain
// exception here means the legacy data doesn't satisfy an invariant
// the owned side requires. That must fail as a 422 naming the
// failing invariant, and - critically - NOTHING is written anywhere:
// no case-framework call, no persistence. FetchAndMapAsync lets the
// domain exception surface as a thrown DomainInvariantViolationException,
// which we catch here and translate, rather than swallowing it inside
// the gateway - that keeps "nothing written on failure" trivially true,
// since we simply haven't called anything else yet.
LegacyFetchAndMapResult fetchResult;
try
{
fetchResult = await legacyGateway.FetchAndMapAsync(aanvraagId, ct);
}
catch (DomainInvariantViolationException ex)
{
return TakeOwnershipResult.MappingFailed(ex.Invariant, ex.Message);
}
if (fetchResult.Status == LegacyFetchStatus.NotFound || fetchResult.Application is null)
{
return TakeOwnershipResult.LegacyCaseNotFound;
}
var application = fetchResult.Application;
// Step 4: THEN create the case-framework case - done before the local
// transaction because it's an external system with no distributed
// transaction available. A failure after this step leaves an
// orphaned framework case (its externalReference matches no
// aggregate) - detectable by a reconciliation query, not rolled back
// here since case-framework has no compensating "delete case" seam.
var created = await caseFrameworkGateway.CreateCaseAsync(
CaseTypeCode,
externalReference: application.RegistrationApplicationId.ToString(),
participants: [$"{application.Applicant.Surname} {application.Applicant.Initials}"],
ct);
application.AttachCaseReference(new CaseReference(
created.CaseId,
application.RegistrationApplicationId.ToString(),
created.ProcessStatus));
// Step 5: persist the aggregate AND the legacy_ownership row in ONE
// local transaction - both ports below are backed by the same scoped
// DbContext, so the single SaveChangesAsync call is atomic across them.
await repository.AddAsync(application, ct);
await registry.RecordAsync(aanvraagId, application.RegistrationApplicationId, clock.GetUtcNow(), ct);
await unitOfWork.SaveChangesAsync(ct);
// Step 6: LAST, flip legacy's migratie-vlag. A failure here leaves the
// case owned locally but still writable in legacy (split-brain) -
// detectable by a reconciliation query comparing legacy_ownership
// against legacy's own migrated flags. We deliberately do not roll
// back steps 4/5 if this fails: the aggregate is already the
// system-of-record locally, and undoing that would be worse than a
// detectable, reconcilable split-brain window.
try
{
await legacyGateway.SetMigratedFlagAsync(aanvraagId, migrated: true, ct);
}
catch (Exception ex)
{
logger.LogWarning(
ex,
"Failed to set legacy migratie-vlag for aanvraag {AanvraagId} after taking ownership as {RegistrationApplicationId}. " +
"This is a split-brain condition: reconcile via legacy_ownership vs legacy's migrated flags.",
aanvraagId,
application.RegistrationApplicationId);
}
return TakeOwnershipResult.Success(application.RegistrationApplicationId);
}
}
@@ -0,0 +1,25 @@
namespace New.Application.Ownership;
public enum TakeOwnershipResultKind
{
Success,
AlreadyOwned,
LegacyCaseNotFound,
MappingFailed,
}
public sealed record TakeOwnershipResult(
TakeOwnershipResultKind Kind,
Guid? RegistrationApplicationId = null,
string? Invariant = null,
string? Message = null)
{
public static TakeOwnershipResult Success(Guid registrationApplicationId) =>
new(TakeOwnershipResultKind.Success, RegistrationApplicationId: registrationApplicationId);
public static readonly TakeOwnershipResult AlreadyOwned = new(TakeOwnershipResultKind.AlreadyOwned);
public static readonly TakeOwnershipResult LegacyCaseNotFound = new(TakeOwnershipResultKind.LegacyCaseNotFound);
public static TakeOwnershipResult MappingFailed(string invariant, string message) =>
new(TakeOwnershipResultKind.MappingFailed, Invariant: invariant, Message: message);
}
@@ -0,0 +1,14 @@
using New.Application.Worklist;
namespace New.Application.Ports;
/// <summary>
/// Resolves a case by its legacy id transparently, regardless of whether it
/// has been taken into ownership. Implemented by the (single) source
/// resolver - see the composition root for why that type is the only one
/// allowed to know both sources exist (Architecture.Tests rule 7).
/// </summary>
public interface IApplicationSource
{
Task<CaseDetail?> GetByLegacyIdAsync(int aanvraagId, CancellationToken ct);
}
@@ -0,0 +1,25 @@
namespace New.Application.Ports;
public sealed record CaseCreated(Guid CaseId, string? ProcessStatus);
public sealed record TaskCreated(Guid TaskId, bool Open);
/// <summary>Seam D: the case-framework client port (New.Infrastructure.CaseFramework implements this).</summary>
public interface ICaseFrameworkGateway
{
Task<CaseCreated> CreateCaseAsync(string caseTypeCode, string externalReference, IReadOnlyList<string> participants, CancellationToken ct);
Task<string?> GetProcessStatusAsync(Guid caseId, CancellationToken ct);
Task<TaskCreated> CreateTaskAsync(Guid caseId, string code, string description, CancellationToken ct);
Task CompleteTaskAsync(Guid caseId, Guid taskId, CancellationToken ct);
/// <summary>
/// POST .../closure-request. Returns true if the case closed, false if
/// the framework returned 409 (an open task) - which is an expected,
/// non-exceptional outcome for callers (e.g. the owned assessment flow
/// treats it as "closure pending", not a failure).
/// </summary>
Task<bool> RequestClosureAsync(Guid caseId, CancellationToken ct);
}
@@ -0,0 +1,39 @@
using New.Application.WriteThrough;
using New.Domain;
namespace New.Application.Ports;
public enum LegacyFetchStatus
{
Found,
NotFound,
}
public sealed record LegacyFetchAndMapResult(LegacyFetchStatus Status, RegistrationApplication? Application);
/// <summary>
/// The legacy-facing operations needed by the take-ownership flow, the
/// write-through edit seam, and ownership release - as opposed to
/// <c>LegacyCaseSource</c> (seam A read, used only by the source resolver).
/// Kept as a separate port/type from that read seam deliberately (see
/// Architecture.Tests rule 7's remarks on the resolver's exclusivity).
/// </summary>
public interface ILegacyCaseGateway
{
/// <summary>
/// Fetches the legacy case and maps it to a <see cref="RegistrationApplication"/>
/// via the internal mapper. A domain exception during mapping propagates
/// as-is (callers such as the take-ownership handler turn it into a 422) -
/// this method itself never swallows mapping failures.
/// </summary>
Task<LegacyFetchAndMapResult> FetchAndMapAsync(int aanvraagId, CancellationToken ct);
/// <summary>
/// Seam B: PUT .../gegevens. This is a pure translation - see
/// LegacyDetailsWriteThroughTranslator for the "no business rules" comment.
/// </summary>
Task<WriteThroughOutcome> UpdateDetailsAsync(int aanvraagId, ApplicantDetailsCommand command, CancellationToken ct);
/// <summary>PUT .../migratie-vlag. Used on take-ownership (true) and release-ownership (false).</summary>
Task SetMigratedFlagAsync(int aanvraagId, bool migrated, CancellationToken ct);
}
@@ -0,0 +1,9 @@
using New.Application.Worklist;
namespace New.Application.Ports;
/// <summary>Lists legacy applications (via seam A) for the merged worklist.</summary>
public interface ILegacyWorklistReader
{
Task<IReadOnlyList<WorklistItem>> ListAsync(CancellationToken ct);
}
@@ -0,0 +1,14 @@
using New.Application.Worklist;
namespace New.Application.Ports;
/// <summary>
/// Lists owned applications for the merged worklist. Deliberately a
/// different port/type than whatever the source resolver uses to fetch a
/// single owned case by id - see Architecture.Tests rule 7's remarks on the
/// resolver being the only type that reaches into both sources.
/// </summary>
public interface IOwnedWorklistReader
{
Task<IReadOnlyList<WorklistItem>> ListAsync(CancellationToken ct);
}
@@ -0,0 +1,30 @@
namespace New.Application.Ports;
/// <summary>Read-model row of the `legacy_ownership` table.</summary>
public sealed record OwnershipRecord(
int LegacyAanvraagId,
Guid RegistrationApplicationId,
DateTimeOffset TakenOverAt,
int DomainWritesSince);
/// <summary>
/// The `legacy_ownership` table - which legacy aanvraagen have been taken
/// into ownership, and how many domain writes have happened since (which
/// gates whether ownership can be released again).
/// </summary>
public interface IOwnershipRegistry
{
/// <summary>Null if <paramref name="legacyAanvraagId"/> has not been taken into ownership.</summary>
Task<Guid?> LookupOwnedIdAsync(int legacyAanvraagId, CancellationToken ct);
Task<OwnershipRecord?> GetAsync(Guid registrationApplicationId, CancellationToken ct);
/// <summary>Stages a new ownership row (flushed by <see cref="IUnitOfWork.SaveChangesAsync"/>).</summary>
Task RecordAsync(int legacyAanvraagId, Guid registrationApplicationId, DateTimeOffset takenOverAt, CancellationToken ct);
/// <summary>Increments `domain_writes_since` for a domain write against an adopted aggregate.</summary>
Task IncrementDomainWritesAsync(Guid registrationApplicationId, CancellationToken ct);
/// <summary>Stages removal of the ownership row (flushed by <see cref="IUnitOfWork.SaveChangesAsync"/>).</summary>
Task RemoveAsync(Guid registrationApplicationId, CancellationToken ct);
}
@@ -0,0 +1,15 @@
using New.Domain;
namespace New.Application.Ports;
/// <summary>Owned-side persistence port for the <see cref="RegistrationApplication"/> aggregate.</summary>
public interface IRegistrationApplicationRepository
{
Task<RegistrationApplication?> GetAsync(Guid registrationApplicationId, CancellationToken ct);
/// <summary>Stages a brand-new aggregate for insertion (flushed on the next <see cref="IUnitOfWork.SaveChangesAsync"/>).</summary>
Task AddAsync(RegistrationApplication application, CancellationToken ct);
/// <summary>Stages an aggregate for deletion (flushed on the next <see cref="IUnitOfWork.SaveChangesAsync"/>).</summary>
Task RemoveAsync(RegistrationApplication application, CancellationToken ct);
}
@@ -0,0 +1,12 @@
namespace New.Application.Ports;
/// <summary>
/// Commits everything staged through <see cref="IRegistrationApplicationRepository"/>
/// and <see cref="IOwnershipRegistry"/> in one local transaction. In the
/// Persistence adapter both ports are backed by the same scoped DbContext, so
/// a single SaveChangesAsync call is genuinely atomic across them.
/// </summary>
public interface IUnitOfWork
{
Task SaveChangesAsync(CancellationToken ct);
}
@@ -0,0 +1,10 @@
namespace New.Application.Worklist;
/// <summary>
/// Plain read-model carrier for an address - deliberately NOT the
/// New.Domain.ValueObjects.Address value object. Read models cross into
/// New.Api for JSON shaping and must stay decoupled from domain invariants
/// (e.g. a query result can legitimately be assembled straight from a
/// legacy/case-framework response before any domain validation happens).
/// </summary>
public sealed record AddressData(string Street, string Number, string PostalCode, string City);
@@ -0,0 +1,10 @@
namespace New.Application.Worklist;
/// <summary>Read-model projection of a recorded assessment, for display purposes.</summary>
public sealed record AssessmentData(
string Outcome,
string Motivation,
IReadOnlyList<string> VerifiedItems,
string? ExceptionReason,
string? RejectionCategory,
DateOnly DecidedOn);
@@ -0,0 +1,34 @@
namespace New.Application.Worklist;
/// <summary>
/// Full case detail projection, shaped the same way regardless of which
/// source it came from - New.Api layers the `actions`/`seams` blocks on top
/// based on <see cref="Origin"/>.
/// </summary>
public sealed record CaseDetail(
WorklistOrigin Origin,
int? LegacyAanvraagId,
Guid? RegistrationApplicationId,
string Surname,
string Initials,
string Bsn,
AddressData? Address,
string? Email,
string? Phone,
string PreferredChannel,
string DiplomaCode,
string DiplomaCountryOfIssue,
DateOnly DiplomaIssuedOn,
DateOnly ReceivedOn,
AssessmentData? Assessment,
string? ProcessStatus,
Guid? CaseFrameworkCaseId,
bool Migrated,
/// <summary>
/// UTC instant of the last legacy mutation, if known. Legacy's own
/// `mutDat` is a local Europe/Amsterdam timestamp with no offset - the
/// legacy mapper converts it explicitly via that time zone rather than
/// assuming UTC (which would silently shift it by 1-2 hours depending on
/// DST). Null for owned/native cases with no legacy mutation history.
/// </summary>
DateTimeOffset? LastModifiedAt = null);
@@ -0,0 +1,27 @@
namespace New.Application.Worklist;
/// <summary>
/// One row of the merged worklist (GET /api/worklist). New.Api fetches these
/// from both sources in full and merges/sorts/pages them in memory - a known
/// shortcut for this demo's seed volumes (12 legacy + 5 owned rows); a
/// production version would need keyset pagination per source or a
/// materialized index instead.
/// </summary>
public sealed record WorklistItem(
WorklistOrigin Origin,
int? LegacyAanvraagId,
Guid? RegistrationApplicationId,
string Surname,
string Initials,
string Bsn,
DateOnly ReceivedOn,
string Bucket,
string? AssessmentOutcome,
string? ProcessStatus,
DateTimeOffset? LastModifiedAt = null,
/// <summary>
/// True for a legacy row already taken into ownership. New.Api's worklist
/// merge excludes such rows from the legacy list (the owned counterpart
/// already represents them) - see the merge comment in New.Api.
/// </summary>
bool Migrated = false);
@@ -0,0 +1,8 @@
namespace New.Application.Worklist;
/// <summary>Which of the two sources a worklist item or case detail came from.</summary>
public enum WorklistOrigin
{
Legacy,
Owned,
}
@@ -0,0 +1,16 @@
using New.Application.Worklist;
namespace New.Application.WriteThrough;
/// <summary>
/// The 9-field "edit applicant details" command, shared verbatim by the
/// legacy write-through seam (PUT /api/worklist/legacy/{id}/details) and the
/// owned edit path (PUT /api/worklist/owned/{id}/details).
/// </summary>
public sealed record ApplicantDetailsCommand(
string Surname,
string Initials,
AddressData? Address,
string? Email,
string? Phone,
string PreferredChannel);
@@ -0,0 +1,60 @@
using New.Application.Ports;
using New.Domain;
using New.Domain.ValueObjects;
namespace New.Application.WriteThrough;
/// <summary>
/// Orchestrates PUT /api/worklist/owned/{id}/details - the owned-side
/// counterpart to the legacy write-through seam. Unlike the write-through
/// translator, this path re-validates through the domain's real value
/// objects (there is no external "legacy is the sole authority" constraint
/// here - this IS the authority once a case is owned).
/// </summary>
public sealed class UpdateOwnedApplicantDetailsHandler(
IRegistrationApplicationRepository repository,
IUnitOfWork unitOfWork,
IOwnershipRegistry registry)
{
public async Task<UpdateOwnedApplicantDetailsResult> HandleAsync(
Guid registrationApplicationId,
ApplicantDetailsCommand command,
CancellationToken ct)
{
var application = await repository.GetAsync(registrationApplicationId, ct);
if (application is null)
{
return UpdateOwnedApplicantDetailsResult.NotFound;
}
try
{
var applicant = new PersonName(command.Surname, command.Initials);
var address = command.Address is { } a
? new Address(a.Street, a.Number, a.PostalCode, a.City)
: null;
var channel = ParseChannel(command.PreferredChannel);
var contactDetails = new ContactDetails(command.Email, command.Phone, channel);
application.UpdateApplicantDetails(applicant, address, contactDetails);
}
catch (DomainInvariantViolationException ex)
{
return UpdateOwnedApplicantDetailsResult.InvariantViolation(ex.Invariant, ex.Message);
}
await registry.IncrementDomainWritesAsync(registrationApplicationId, ct);
await unitOfWork.SaveChangesAsync(ct);
return UpdateOwnedApplicantDetailsResult.Success;
}
private static CorrespondenceChannel ParseChannel(string preferredChannel) => preferredChannel switch
{
"Post" => CorrespondenceChannel.Post,
"Email" => CorrespondenceChannel.Email,
_ => throw new DomainInvariantViolationException(
"ContactDetails.UnrecognizedChannel",
$"'{preferredChannel}' is not a recognized preferred channel (expected 'Post' or 'Email')."),
};
}
@@ -0,0 +1,20 @@
namespace New.Application.WriteThrough;
public enum UpdateOwnedApplicantDetailsResultKind
{
Success,
NotFound,
InvariantViolation,
}
public sealed record UpdateOwnedApplicantDetailsResult(
UpdateOwnedApplicantDetailsResultKind Kind,
string? Invariant = null,
string? Message = null)
{
public static readonly UpdateOwnedApplicantDetailsResult Success = new(UpdateOwnedApplicantDetailsResultKind.Success);
public static readonly UpdateOwnedApplicantDetailsResult NotFound = new(UpdateOwnedApplicantDetailsResultKind.NotFound);
public static UpdateOwnedApplicantDetailsResult InvariantViolation(string invariant, string message) =>
new(UpdateOwnedApplicantDetailsResultKind.InvariantViolation, invariant, message);
}
@@ -0,0 +1,28 @@
namespace New.Application.WriteThrough;
/// <summary>
/// A single portal-shaped field error, after the write-through translator has
/// mapped a legacy `veld`/`code`/`melding` triple. <see cref="Detail"/> only
/// carries the raw legacy message, and only for codes the translator did not
/// recognize - see the translator's class-level comment on why it must never
/// invent business meaning for a code it doesn't know.
/// </summary>
public sealed record PortalFieldError(string Field, string Message, string? Detail = null);
public enum WriteThroughOutcomeKind
{
Success,
ValidationFailed,
Conflict,
NotFound,
}
public sealed record WriteThroughOutcome(WriteThroughOutcomeKind Kind, IReadOnlyList<PortalFieldError>? Errors = null)
{
public static readonly WriteThroughOutcome Success = new(WriteThroughOutcomeKind.Success);
public static readonly WriteThroughOutcome Conflict = new(WriteThroughOutcomeKind.Conflict);
public static readonly WriteThroughOutcome NotFound = new(WriteThroughOutcomeKind.NotFound);
public static WriteThroughOutcome ValidationFailed(IReadOnlyList<PortalFieldError> errors) =>
new(WriteThroughOutcomeKind.ValidationFailed, errors);
}