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,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&lt;T&gt;()`'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;
}
}