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:
@@ -0,0 +1,19 @@
|
||||
namespace New.Infrastructure.Legacy;
|
||||
|
||||
/// <summary>
|
||||
/// Legacy's `mutDat` is a local Europe/Amsterdam DATETIME2 with no offset -
|
||||
/// converting it to a UTC-backed DateTimeOffset requires explicitly applying
|
||||
/// this time zone (including DST), never assuming it's already UTC (which
|
||||
/// would silently shift every audit timestamp by 1-2 hours).
|
||||
/// </summary>
|
||||
internal static class AmsterdamClock
|
||||
{
|
||||
private static readonly TimeZoneInfo Amsterdam = TimeZoneInfo.FindSystemTimeZoneById("Europe/Amsterdam");
|
||||
|
||||
public static DateTimeOffset ToUtcOffset(DateTime localUnspecified)
|
||||
{
|
||||
var unspecified = DateTime.SpecifyKind(localUnspecified, DateTimeKind.Unspecified);
|
||||
var utc = TimeZoneInfo.ConvertTimeToUtc(unspecified, Amsterdam);
|
||||
return new DateTimeOffset(utc, TimeSpan.Zero);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace New.Infrastructure.Legacy.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// Legacy's own row shape, exactly as documented in the migration design
|
||||
/// notes, plus an `id` field: the quoted contract doesn't list it explicitly,
|
||||
/// but GET /api/aanvragen/{id} is id-addressed, so the row necessarily
|
||||
/// carries its own id. Internal to this project - nothing outside
|
||||
/// New.Infrastructure.Legacy may reference this type (Architecture.Tests rule 3).
|
||||
/// </summary>
|
||||
internal sealed record LegacyAanvraagDto(
|
||||
[property: JsonPropertyName("id")] int Id,
|
||||
[property: JsonPropertyName("bsn")] string Bsn,
|
||||
[property: JsonPropertyName("naam")] string Naam,
|
||||
[property: JsonPropertyName("voorl")] string Voorl,
|
||||
[property: JsonPropertyName("adresStr")] string? AdresStr,
|
||||
[property: JsonPropertyName("adresNr")] string? AdresNr,
|
||||
[property: JsonPropertyName("adresPc")] string? AdresPc,
|
||||
[property: JsonPropertyName("adresPl")] string? AdresPl,
|
||||
[property: JsonPropertyName("email")] string? Email,
|
||||
[property: JsonPropertyName("telnr")] string? Telnr,
|
||||
[property: JsonPropertyName("corrKanaal")] string CorrKanaal,
|
||||
[property: JsonPropertyName("statCd")] string StatCd,
|
||||
[property: JsonPropertyName("diplCd")] string DiplCd,
|
||||
[property: JsonPropertyName("diplLand")] string DiplLand,
|
||||
[property: JsonPropertyName("diplDat")] DateOnly DiplDat,
|
||||
[property: JsonPropertyName("datOntv")] DateOnly DatOntv,
|
||||
[property: JsonPropertyName("datBeoord")] DateOnly? DatBeoord,
|
||||
[property: JsonPropertyName("beoordRes")] string? BeoordRes,
|
||||
[property: JsonPropertyName("beoordMotiv")] string? BeoordMotiv,
|
||||
[property: JsonPropertyName("migrated")] bool Migrated,
|
||||
[property: JsonPropertyName("mutDat")] DateTime MutDat,
|
||||
[property: JsonPropertyName("mutUser")] string? MutUser);
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace New.Infrastructure.Legacy.Dtos;
|
||||
|
||||
/// <summary>Seam B request body - legacy was told to accept exactly this portal-facing shape.</summary>
|
||||
internal sealed record LegacyDetailsWriteRequest(
|
||||
[property: JsonPropertyName("surname")] string Surname,
|
||||
[property: JsonPropertyName("initials")] string Initials,
|
||||
[property: JsonPropertyName("address")] LegacyAddressWriteRequest? Address,
|
||||
[property: JsonPropertyName("email")] string? Email,
|
||||
[property: JsonPropertyName("phone")] string? Phone,
|
||||
[property: JsonPropertyName("preferredChannel")] string PreferredChannel);
|
||||
|
||||
internal sealed record LegacyAddressWriteRequest(
|
||||
[property: JsonPropertyName("street")] string Street,
|
||||
[property: JsonPropertyName("number")] string Number,
|
||||
[property: JsonPropertyName("postalCode")] string PostalCode,
|
||||
[property: JsonPropertyName("city")] string City);
|
||||
|
||||
internal sealed record LegacyValidationErrorResponse(
|
||||
[property: JsonPropertyName("errors")] List<LegacyValidationError> Errors);
|
||||
|
||||
internal sealed record LegacyValidationError(
|
||||
[property: JsonPropertyName("veld")] string Veld,
|
||||
[property: JsonPropertyName("code")] string Code,
|
||||
[property: JsonPropertyName("melding")] string Melding);
|
||||
|
||||
internal sealed record MigratieVlagRequest(
|
||||
[property: JsonPropertyName("migrated")] bool Migrated);
|
||||
@@ -0,0 +1,132 @@
|
||||
using New.Domain;
|
||||
using New.Domain.ValueObjects;
|
||||
using New.Infrastructure.Legacy.Dtos;
|
||||
|
||||
namespace New.Infrastructure.Legacy;
|
||||
|
||||
/// <summary>
|
||||
/// Maps a legacy row to a <see cref="RegistrationApplication"/>. Every
|
||||
/// invariant is enforced by calling straight into the domain's own
|
||||
/// validating constructors/factories - none of this mapping logic lives in
|
||||
/// New.Domain, and a domain exception thrown here is exactly the signal the
|
||||
/// take-ownership handler needs to fail adoption with a named invariant.
|
||||
///
|
||||
/// Each numbered comment below is a deliberate defect trap this mapper must
|
||||
/// not fall into.
|
||||
/// </summary>
|
||||
internal static class LegacyAanvraagMapper
|
||||
{
|
||||
public static RegistrationApplication ToDomain(LegacyAanvraagDto dto)
|
||||
{
|
||||
// 1) bsn is a space-padded CHAR(9) in the source. The padding is
|
||||
// invisible in JSON output but breaks the eleven-proof check if not
|
||||
// trimmed - Bsn's constructor deliberately does NOT trim, so this
|
||||
// Trim() is load-bearing, not defensive fluff.
|
||||
var bsn = new Bsn(dto.Bsn.Trim());
|
||||
|
||||
var applicant = new PersonName(dto.Naam, dto.Voorl);
|
||||
|
||||
// 2) statCd must map to a named enum; an unrecognized code throws
|
||||
// rather than silently defaulting. Not stored on the domain aggregate
|
||||
// (it has no business meaning there - see New.Infrastructure.Legacy.LegacyAanvraagStatus)
|
||||
// but still validated here as a data-quality gate before adoption proceeds.
|
||||
LegacyAanvraagStatusMapper.Parse(dto.StatCd);
|
||||
|
||||
// 3) corrKanaal's legacy 'P' default is a "nobody actively chose"
|
||||
// sentinel, not evidence of a real preference - it still maps to
|
||||
// Post for display, we just never treat its mere presence as proof
|
||||
// of anything. An unrecognized channel throws rather than defaulting.
|
||||
var channel = dto.CorrKanaal switch
|
||||
{
|
||||
"P" => CorrespondenceChannel.Post,
|
||||
"E" => CorrespondenceChannel.Email,
|
||||
_ => throw new DomainInvariantViolationException(
|
||||
"Legacy.UnrecognizedCorrKanaal", $"Unrecognized legacy corrKanaal '{dto.CorrKanaal}'."),
|
||||
};
|
||||
var contactDetails = new ContactDetails(dto.Email, dto.Telnr, channel);
|
||||
|
||||
// 4) four flat adres* columns -> Address?. Unlike the read-only
|
||||
// projection (LegacyCaseDetailProjection, which just displays legacy
|
||||
// data as-is), ADOPTION must fail loudly on a partial address rather
|
||||
// than silently treating it as "no address" - a partial address is a
|
||||
// real data-quality problem this row has, not a display nuance.
|
||||
Address? address = BuildAddressOrThrow(dto);
|
||||
|
||||
var diploma = new DiplomaEvidence(dto.DiplCd, dto.DiplLand, dto.DiplDat);
|
||||
var receivedOn = dto.DatOntv;
|
||||
|
||||
// 5) migrated is a bool - no implicit int conversion assumed (the
|
||||
// DTO already binds it as `bool` from JSON, so there is nothing to
|
||||
// coerce here; this comment documents that the trap was considered,
|
||||
// not skipped).
|
||||
_ = dto.Migrated;
|
||||
|
||||
if (dto.BeoordRes is null)
|
||||
{
|
||||
return RegistrationApplication.Create(
|
||||
Guid.NewGuid(), bsn, applicant, address, contactDetails, diploma, receivedOn);
|
||||
}
|
||||
|
||||
var outcome = dto.BeoordRes switch
|
||||
{
|
||||
"G" => AssessmentOutcome.Approved,
|
||||
"A" => AssessmentOutcome.Rejected,
|
||||
_ => throw new DomainInvariantViolationException(
|
||||
"Legacy.UnrecognizedBeoordRes", $"Unrecognized legacy beoordRes '{dto.BeoordRes}'."),
|
||||
};
|
||||
|
||||
if (dto.DatBeoord is null)
|
||||
{
|
||||
throw new DomainInvariantViolationException(
|
||||
"Legacy.MissingBeoordelingsdatum", "A recorded beoordRes requires a datBeoord.");
|
||||
}
|
||||
|
||||
// Legacy has no granular per-item verification checklist and no
|
||||
// rejection-category taxonomy - both are owned-side-only concepts.
|
||||
// We synthesize the minimum the domain requires to represent "this
|
||||
// was already assessed, verified through legacy's own (unmodeled)
|
||||
// process": an exception reason standing in for verifiedItems, and -
|
||||
// only for a Rejected outcome - a rejection category that is
|
||||
// deliberately NOT "Other"/"anders", so beoordMotiv is held to the
|
||||
// domain's normal 20-char minimum rather than the 50-char "Other"
|
||||
// minimum. (6) beoordMotiv may be shorter than that minimum - that's
|
||||
// expected, and Assessment.Create below will throw for it, which is
|
||||
// exactly the "surface as an adoption failure" behavior required.
|
||||
const string legacyVerificationNote = "Migrated from legacy system; verification recorded in legacy's own audit trail.";
|
||||
var rejectionCategory = outcome == AssessmentOutcome.Rejected ? "LegacyRejection" : null;
|
||||
|
||||
var application = RegistrationApplication.Create(
|
||||
Guid.NewGuid(), bsn, applicant, address, contactDetails, diploma, receivedOn);
|
||||
|
||||
application.RecordAssessment(
|
||||
outcome,
|
||||
dto.BeoordMotiv ?? string.Empty,
|
||||
verifiedItems: [],
|
||||
exceptionReason: legacyVerificationNote,
|
||||
rejectionCategory,
|
||||
dto.DatBeoord.Value);
|
||||
|
||||
return application;
|
||||
}
|
||||
|
||||
private static Address? BuildAddressOrThrow(LegacyAanvraagDto dto)
|
||||
{
|
||||
var parts = new[] { dto.AdresStr, dto.AdresNr, dto.AdresPc, dto.AdresPl };
|
||||
var presentCount = parts.Count(p => !string.IsNullOrWhiteSpace(p));
|
||||
|
||||
if (presentCount == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (presentCount < parts.Length)
|
||||
{
|
||||
throw new DomainInvariantViolationException(
|
||||
"Address.AllPartsRequired",
|
||||
"This legacy row has a partial address (some but not all of street/number/postal code/city). " +
|
||||
"Adoption requires a complete address or none at all.");
|
||||
}
|
||||
|
||||
return new Address(dto.AdresStr!, dto.AdresNr!, dto.AdresPc!, dto.AdresPl!);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using New.Domain;
|
||||
|
||||
namespace New.Infrastructure.Legacy;
|
||||
|
||||
/// <summary>
|
||||
/// Legacy's own `statCd` vocabulary ('O'|'B'|'A'|'X'), named - never left as
|
||||
/// bare characters. Used only for display (worklist bucket / process
|
||||
/// status), never as part of the domain aggregate: New.Domain has no
|
||||
/// business rules keyed on legacy's process stage, only on its own
|
||||
/// AssessmentOutcome once an assessment is actually recorded.
|
||||
/// </summary>
|
||||
internal enum LegacyAanvraagStatus
|
||||
{
|
||||
Open,
|
||||
Beoordeeld,
|
||||
Afgerond,
|
||||
Ingetrokken,
|
||||
}
|
||||
|
||||
internal static class LegacyAanvraagStatusMapper
|
||||
{
|
||||
public static LegacyAanvraagStatus Parse(string statCd) => statCd switch
|
||||
{
|
||||
"O" => LegacyAanvraagStatus.Open,
|
||||
"B" => LegacyAanvraagStatus.Beoordeeld,
|
||||
"A" => LegacyAanvraagStatus.Afgerond,
|
||||
"X" => LegacyAanvraagStatus.Ingetrokken,
|
||||
_ => throw new DomainInvariantViolationException("Legacy.UnrecognizedStatCd", $"Unrecognized legacy statCd '{statCd}'."),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using New.Infrastructure.Legacy.Dtos;
|
||||
|
||||
namespace New.Infrastructure.Legacy;
|
||||
|
||||
/// <summary>Thin wrapper around the legacy-backend HttpClient - the one place that knows its exact routes.</summary>
|
||||
internal sealed class LegacyBackendClient(HttpClient httpClient)
|
||||
{
|
||||
public async Task<LegacyAanvraagDto?> GetAanvraagAsync(int aanvraagId, CancellationToken ct)
|
||||
{
|
||||
using var response = await httpClient.GetAsync($"/api/aanvragen/{aanvraagId}", ct);
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadFromJsonAsync<LegacyAanvraagDto>(ct);
|
||||
}
|
||||
|
||||
public async Task<List<LegacyAanvraagDto>> ListAanvragenAsync(CancellationToken ct)
|
||||
{
|
||||
var result = await httpClient.GetFromJsonAsync<List<LegacyAanvraagDto>>("/api/aanvragen", ct);
|
||||
return result ?? [];
|
||||
}
|
||||
|
||||
public async Task<LegacyDetailsWriteResponse> UpdateDetailsAsync(int aanvraagId, LegacyDetailsWriteRequest request, CancellationToken ct)
|
||||
{
|
||||
using var response = await httpClient.PutAsJsonAsync($"/api/aanvragen/{aanvraagId}/gegevens", request, ct);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NoContent)
|
||||
{
|
||||
return LegacyDetailsWriteResponse.Success();
|
||||
}
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return LegacyDetailsWriteResponse.NotFound();
|
||||
}
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.Conflict)
|
||||
{
|
||||
return LegacyDetailsWriteResponse.Conflict();
|
||||
}
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.BadRequest)
|
||||
{
|
||||
var body = await response.Content.ReadFromJsonAsync<LegacyValidationErrorResponse>(ct);
|
||||
return LegacyDetailsWriteResponse.ValidationFailed(body?.Errors ?? []);
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
throw new InvalidOperationException("Unreachable - EnsureSuccessStatusCode throws for any non-2xx status.");
|
||||
}
|
||||
|
||||
public async Task SetMigratieVlagAsync(int aanvraagId, bool migrated, CancellationToken ct)
|
||||
{
|
||||
using var response = await httpClient.PutAsJsonAsync(
|
||||
$"/api/aanvragen/{aanvraagId}/migratie-vlag", new MigratieVlagRequest(migrated), ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record LegacyDetailsWriteResponse(
|
||||
LegacyDetailsWriteOutcome Outcome,
|
||||
List<LegacyValidationError>? Errors = null)
|
||||
{
|
||||
public static LegacyDetailsWriteResponse Success() => new(LegacyDetailsWriteOutcome.Success);
|
||||
public static LegacyDetailsWriteResponse NotFound() => new(LegacyDetailsWriteOutcome.NotFound);
|
||||
public static LegacyDetailsWriteResponse Conflict() => new(LegacyDetailsWriteOutcome.Conflict);
|
||||
|
||||
public static LegacyDetailsWriteResponse ValidationFailed(List<LegacyValidationError> errors) =>
|
||||
new(LegacyDetailsWriteOutcome.ValidationFailed, errors);
|
||||
}
|
||||
|
||||
internal enum LegacyDetailsWriteOutcome
|
||||
{
|
||||
Success,
|
||||
NotFound,
|
||||
Conflict,
|
||||
ValidationFailed,
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace New.Infrastructure.Legacy;
|
||||
|
||||
/// <summary>
|
||||
/// In-process counter of actual legacy HTTP calls, backing GET
|
||||
/// /api/diagnostics/legacy-call-count. Incremented exclusively by
|
||||
/// <see cref="LegacyCallCountingHandler"/> - a DelegatingHandler on the
|
||||
/// legacy-backend HttpClient - so every call through this client counts,
|
||||
/// with no risk of a call site forgetting to increment it by hand.
|
||||
/// </summary>
|
||||
public sealed class LegacyCallCounter
|
||||
{
|
||||
private long _count;
|
||||
|
||||
public long Count => Interlocked.Read(ref _count);
|
||||
|
||||
internal void Increment() => Interlocked.Increment(ref _count);
|
||||
}
|
||||
|
||||
internal sealed class LegacyCallCountingHandler(LegacyCallCounter counter) : DelegatingHandler
|
||||
{
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
counter.Increment();
|
||||
return await base.SendAsync(request, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using New.Application.Worklist;
|
||||
using New.Infrastructure.Legacy.Dtos;
|
||||
|
||||
namespace New.Infrastructure.Legacy;
|
||||
|
||||
/// <summary>Shared legacy-row -> read-model projection, used by both LegacyCaseSource and LegacyWorklistReader.</summary>
|
||||
internal static class LegacyCaseDetailProjection
|
||||
{
|
||||
public static CaseDetail ToCaseDetail(LegacyAanvraagDto dto)
|
||||
{
|
||||
var status = LegacyAanvraagStatusMapper.Parse(dto.StatCd);
|
||||
|
||||
AddressData? address = HasAllFourAddressParts(dto)
|
||||
? new AddressData(dto.AdresStr!, dto.AdresNr!, dto.AdresPc!, dto.AdresPl!)
|
||||
: null;
|
||||
|
||||
AssessmentData? assessment = dto.BeoordRes is not null
|
||||
? new AssessmentData(
|
||||
dto.BeoordRes == "G" ? "Approved" : "Rejected",
|
||||
dto.BeoordMotiv ?? string.Empty,
|
||||
VerifiedItems: [],
|
||||
ExceptionReason: null,
|
||||
RejectionCategory: null,
|
||||
dto.DatBeoord ?? dto.DatOntv)
|
||||
: null;
|
||||
|
||||
var preferredChannel = dto.CorrKanaal == "E" ? "Email" : "Post";
|
||||
|
||||
return new CaseDetail(
|
||||
WorklistOrigin.Legacy,
|
||||
dto.Id,
|
||||
RegistrationApplicationId: null,
|
||||
dto.Naam,
|
||||
dto.Voorl,
|
||||
dto.Bsn.Trim(),
|
||||
address,
|
||||
dto.Email,
|
||||
dto.Telnr,
|
||||
preferredChannel,
|
||||
dto.DiplCd,
|
||||
dto.DiplLand,
|
||||
dto.DiplDat,
|
||||
dto.DatOntv,
|
||||
assessment,
|
||||
ProcessStatus: status.ToString(),
|
||||
CaseFrameworkCaseId: null,
|
||||
Migrated: dto.Migrated,
|
||||
LastModifiedAt: AmsterdamClock.ToUtcOffset(dto.MutDat));
|
||||
}
|
||||
|
||||
public static WorklistItem ToWorklistItem(LegacyAanvraagDto dto)
|
||||
{
|
||||
var status = LegacyAanvraagStatusMapper.Parse(dto.StatCd);
|
||||
var outcome = dto.BeoordRes switch { "G" => "Approved", "A" => "Rejected", _ => null };
|
||||
|
||||
return new WorklistItem(
|
||||
WorklistOrigin.Legacy,
|
||||
dto.Id,
|
||||
RegistrationApplicationId: null,
|
||||
dto.Naam,
|
||||
dto.Voorl,
|
||||
dto.Bsn.Trim(),
|
||||
dto.DatOntv,
|
||||
Bucket: status.ToString(),
|
||||
AssessmentOutcome: outcome,
|
||||
ProcessStatus: status.ToString(),
|
||||
LastModifiedAt: AmsterdamClock.ToUtcOffset(dto.MutDat),
|
||||
Migrated: dto.Migrated);
|
||||
}
|
||||
|
||||
private static bool HasAllFourAddressParts(LegacyAanvraagDto dto) =>
|
||||
!string.IsNullOrWhiteSpace(dto.AdresStr) &&
|
||||
!string.IsNullOrWhiteSpace(dto.AdresNr) &&
|
||||
!string.IsNullOrWhiteSpace(dto.AdresPc) &&
|
||||
!string.IsNullOrWhiteSpace(dto.AdresPl);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using New.Application.Ports;
|
||||
using New.Application.WriteThrough;
|
||||
using New.Domain;
|
||||
|
||||
namespace New.Infrastructure.Legacy;
|
||||
|
||||
/// <summary>
|
||||
/// Implements <see cref="ILegacyCaseGateway"/>: the legacy-facing operations
|
||||
/// used by take-ownership, the write-through edit seam, and ownership
|
||||
/// release. Kept separate from <see cref="LegacyCaseSource"/> (seam A read,
|
||||
/// used only by the source resolver) - see that class's remarks.
|
||||
/// </summary>
|
||||
public sealed class LegacyCaseGateway : ILegacyCaseGateway
|
||||
{
|
||||
private readonly LegacyBackendClient _client;
|
||||
private readonly LegacyDetailsWriteThroughTranslator _translator;
|
||||
|
||||
// Internal constructor: see LegacyCaseSource's remarks. Registered via an
|
||||
// explicit factory in ServiceCollectionExtensions, not auto-construction.
|
||||
internal LegacyCaseGateway(LegacyBackendClient client, LegacyDetailsWriteThroughTranslator translator)
|
||||
{
|
||||
_client = client;
|
||||
_translator = translator;
|
||||
}
|
||||
|
||||
public async Task<LegacyFetchAndMapResult> FetchAndMapAsync(int aanvraagId, CancellationToken ct)
|
||||
{
|
||||
var dto = await _client.GetAanvraagAsync(aanvraagId, ct);
|
||||
if (dto is null)
|
||||
{
|
||||
return new LegacyFetchAndMapResult(LegacyFetchStatus.NotFound, null);
|
||||
}
|
||||
|
||||
// Any DomainInvariantViolationException thrown by the mapper is
|
||||
// deliberately NOT caught here - it propagates to the caller
|
||||
// (TakeOwnershipHandler), which is exactly what "nothing is written
|
||||
// anywhere on a mapping failure" requires: this method only reads.
|
||||
var application = LegacyAanvraagMapper.ToDomain(dto);
|
||||
return new LegacyFetchAndMapResult(LegacyFetchStatus.Found, application);
|
||||
}
|
||||
|
||||
public async Task<WriteThroughOutcome> UpdateDetailsAsync(int aanvraagId, ApplicantDetailsCommand command, CancellationToken ct)
|
||||
{
|
||||
var request = _translator.ToLegacyRequest(command);
|
||||
var response = await _client.UpdateDetailsAsync(aanvraagId, request, ct);
|
||||
|
||||
return response.Outcome switch
|
||||
{
|
||||
LegacyDetailsWriteOutcome.Success => WriteThroughOutcome.Success,
|
||||
LegacyDetailsWriteOutcome.NotFound => WriteThroughOutcome.NotFound,
|
||||
LegacyDetailsWriteOutcome.Conflict => WriteThroughOutcome.Conflict,
|
||||
LegacyDetailsWriteOutcome.ValidationFailed => WriteThroughOutcome.ValidationFailed(
|
||||
_translator.ToPortalErrors(response.Errors ?? [])),
|
||||
_ => throw new InvalidOperationException($"Unhandled legacy write-through outcome '{response.Outcome}'."),
|
||||
};
|
||||
}
|
||||
|
||||
public Task SetMigratedFlagAsync(int aanvraagId, bool migrated, CancellationToken ct) =>
|
||||
_client.SetMigratieVlagAsync(aanvraagId, migrated, ct);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using New.Application.Worklist;
|
||||
|
||||
namespace New.Infrastructure.Legacy;
|
||||
|
||||
/// <summary>
|
||||
/// Seam A single-case read: GET /api/aanvragen/{id}. Deliberately a concrete
|
||||
/// class with no interface of its own - like OwnedApplicationSource
|
||||
/// (New.Infrastructure.Persistence), it exists only to be injected into the
|
||||
/// source resolver (New.Api), which is the only type allowed to reference
|
||||
/// both of them (Architecture.Tests rule 7).
|
||||
/// </summary>
|
||||
public sealed class LegacyCaseSource
|
||||
{
|
||||
private readonly LegacyBackendClient _client;
|
||||
|
||||
// Internal constructor: LegacyBackendClient's own API surface uses the
|
||||
// internal LegacyAanvraagDto, so it can't be a public constructor
|
||||
// parameter on this public class. DI can still call an internal
|
||||
// constructor from another assembly via reflection.
|
||||
internal LegacyCaseSource(LegacyBackendClient client) => _client = client;
|
||||
|
||||
public async Task<CaseDetail?> GetAsync(int aanvraagId, CancellationToken ct)
|
||||
{
|
||||
var dto = await _client.GetAanvraagAsync(aanvraagId, ct);
|
||||
return dto is null ? null : LegacyCaseDetailProjection.ToCaseDetail(dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using New.Application.WriteThrough;
|
||||
using New.Infrastructure.Legacy.Dtos;
|
||||
|
||||
namespace New.Infrastructure.Legacy;
|
||||
|
||||
/// <summary>
|
||||
/// Seam B's write-through translator (PUT .../gegevens).
|
||||
///
|
||||
/// CRITICAL CONSTRAINT (this becomes ADR-002): this translator must contain
|
||||
/// NO business rules. No conditionals on request values, no validation
|
||||
/// beyond null/shape checks, no derived values, no defaulting. Legacy is the
|
||||
/// sole authority on these rules - every "is this actually valid" decision
|
||||
/// happens on the other side of the HTTP call, and this class only reshapes
|
||||
/// the request/response, it never second-guesses them.
|
||||
/// </summary>
|
||||
internal sealed class LegacyDetailsWriteThroughTranslator(ILogger<LegacyDetailsWriteThroughTranslator> logger)
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, string> FieldPathsByLegacyVeld = new Dictionary<string, string>
|
||||
{
|
||||
["NAAM"] = "surname",
|
||||
["ADRES_PC"] = "address.postalCode",
|
||||
["ADRES_NR"] = "address.number",
|
||||
["EMAIL"] = "email",
|
||||
["TELNR"] = "phone",
|
||||
};
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, string> MessagesByLegacyCode = new Dictionary<string, string>
|
||||
{
|
||||
// Pure lookup table, not a rule engine - the message text is
|
||||
// presentation only, the pass/fail decision already happened in legacy.
|
||||
};
|
||||
|
||||
public LegacyDetailsWriteRequest ToLegacyRequest(ApplicantDetailsCommand command) => new(
|
||||
command.Surname,
|
||||
command.Initials,
|
||||
command.Address is { } a ? new LegacyAddressWriteRequest(a.Street, a.Number, a.PostalCode, a.City) : null,
|
||||
command.Email,
|
||||
command.Phone,
|
||||
command.PreferredChannel);
|
||||
|
||||
public IReadOnlyList<PortalFieldError> ToPortalErrors(IEnumerable<LegacyValidationError> legacyErrors) =>
|
||||
legacyErrors.Select(ToPortalError).ToList();
|
||||
|
||||
private PortalFieldError ToPortalError(LegacyValidationError error)
|
||||
{
|
||||
if (!FieldPathsByLegacyVeld.TryGetValue(error.Veld, out var fieldPath))
|
||||
{
|
||||
// Unrecognized `veld` - never throw/crash, just fall back to a
|
||||
// generic field path and surface legacy's own message verbatim
|
||||
// via `detail`, plus a warning so it gets noticed and the lookup
|
||||
// table above extended.
|
||||
logger.LogWarning(
|
||||
"Unrecognized legacy validation veld '{Veld}' (code '{Code}'): {Melding}",
|
||||
error.Veld, error.Code, error.Melding);
|
||||
|
||||
return new PortalFieldError(
|
||||
Field: error.Veld,
|
||||
Message: "This field could not be saved; see detail for the legacy system's message.",
|
||||
Detail: error.Melding);
|
||||
}
|
||||
|
||||
var message = MessagesByLegacyCode.TryGetValue(error.Code, out var known)
|
||||
? known
|
||||
: error.Melding;
|
||||
|
||||
return new PortalFieldError(fieldPath, message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using New.Application.Ports;
|
||||
using New.Application.Worklist;
|
||||
|
||||
namespace New.Infrastructure.Legacy;
|
||||
|
||||
/// <summary>Seam A list read: GET /api/aanvragen, for the merged worklist.</summary>
|
||||
public sealed class LegacyWorklistReader : ILegacyWorklistReader
|
||||
{
|
||||
private readonly LegacyBackendClient _client;
|
||||
|
||||
// Internal constructor: see LegacyCaseSource's remarks. Registered via an
|
||||
// explicit factory in ServiceCollectionExtensions, not auto-construction.
|
||||
internal LegacyWorklistReader(LegacyBackendClient client) => _client = client;
|
||||
|
||||
public async Task<IReadOnlyList<WorklistItem>> ListAsync(CancellationToken ct)
|
||||
{
|
||||
var rows = await _client.ListAanvragenAsync(ct);
|
||||
return rows.Select(LegacyCaseDetailProjection.ToWorklistItem).ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<RootNamespace>New.Infrastructure.Legacy</RootNamespace>
|
||||
<!--
|
||||
Legacy DTOs in this project are `internal` on purpose (Architecture.Tests
|
||||
rule 3). No InternalsVisibleTo is granted anywhere: Architecture.Tests
|
||||
inspects the compiled assembly's metadata (NetArchTest/Mono.Cecil), which
|
||||
sees internal types regardless of visibility to the caller, so nothing
|
||||
outside this project can ever reference these DTOs by design.
|
||||
-->
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\New.Domain\New.Domain.csproj" />
|
||||
<ProjectReference Include="..\New.Application\New.Application.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="9.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace New.Infrastructure.Legacy.Options;
|
||||
|
||||
public sealed class LegacyBackendOptions
|
||||
{
|
||||
public const string SectionName = "Services:LegacyBackend";
|
||||
|
||||
public string BaseUrl { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.Net.Http;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using New.Application.Ports;
|
||||
using New.Infrastructure.Legacy.Options;
|
||||
|
||||
namespace New.Infrastructure.Legacy;
|
||||
|
||||
/// <summary>
|
||||
/// Composition-root entry point for this project - see
|
||||
/// New.Infrastructure.Persistence.ServiceCollectionExtensions for why
|
||||
/// Program.cs only ever calls extension methods like this one, never names
|
||||
/// LegacyCaseSource/OwnedApplicationSource directly itself.
|
||||
///
|
||||
/// Every service below that depends on an `internal` type (LegacyBackendClient,
|
||||
/// LegacyDetailsWriteThroughTranslator - both internal because their APIs are
|
||||
/// shaped by legacy DTOs, see Architecture.Tests rule 3) is registered via an
|
||||
/// explicit factory delegate rather than `services.AddScoped<T>()`'s
|
||||
/// automatic constructor discovery. That auto-discovery goes through
|
||||
/// reflection in a different assembly and is not guaranteed to see
|
||||
/// non-public constructors; a factory delegate compiled here, in the same
|
||||
/// assembly, calls the constructor directly under ordinary C# accessibility
|
||||
/// rules - no reflection involved, so there's nothing to be uncertain about.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
private const string HttpClientName = "LegacyBackend";
|
||||
|
||||
public static IServiceCollection AddLegacyInfrastructure(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.Configure<LegacyBackendOptions>(configuration.GetSection(LegacyBackendOptions.SectionName));
|
||||
|
||||
services.AddSingleton<LegacyCallCounter>();
|
||||
services.AddTransient<LegacyCallCountingHandler>();
|
||||
|
||||
services.AddHttpClient(HttpClientName, (sp, http) =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<LegacyBackendOptions>>().Value;
|
||||
http.BaseAddress = new Uri(options.BaseUrl);
|
||||
}).AddHttpMessageHandler<LegacyCallCountingHandler>();
|
||||
|
||||
services.AddScoped(sp =>
|
||||
new LegacyBackendClient(sp.GetRequiredService<IHttpClientFactory>().CreateClient(HttpClientName)));
|
||||
|
||||
services.AddScoped(sp =>
|
||||
new LegacyDetailsWriteThroughTranslator(sp.GetRequiredService<Microsoft.Extensions.Logging.ILogger<LegacyDetailsWriteThroughTranslator>>()));
|
||||
|
||||
services.AddScoped(sp =>
|
||||
new LegacyCaseSource(sp.GetRequiredService<LegacyBackendClient>()));
|
||||
|
||||
services.AddScoped<ILegacyWorklistReader>(sp =>
|
||||
new LegacyWorklistReader(sp.GetRequiredService<LegacyBackendClient>()));
|
||||
|
||||
services.AddScoped<ILegacyCaseGateway>(sp =>
|
||||
new LegacyCaseGateway(
|
||||
sp.GetRequiredService<LegacyBackendClient>(),
|
||||
sp.GetRequiredService<LegacyDetailsWriteThroughTranslator>()));
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user