Added three new documents with nine Mermaid diagrams to make the strangler fig strategy visible: - README: container topology diagram at the start, with the proxy entry point and three seams labelled - docs/architecture.md: five diagrams tracing the exact implementation: - The four seams and who holds authority at each boundary - How by-id read goes through the resolver, but list-read bypasses it - Case lifecycle state machine (the strategy in one picture) - Take-ownership sequence with failure windows annotated - Write-through error round-trip showing zero validation logic crossed - docs/playbook.md: how to apply this to a production system: - Write-path decision tree (five read/write patterns) - Cutover ordering diagram (side-effects-free first, least recoverable last) - Seven transferable rules with pointers to the files that demonstrate them - Scope diagram of what's proven vs. left as your decisions Resolved all 13 dangling § citations (to an absent spec doc) by linking to the actual files or dropping them. Replaced portal-frontend/README.md boilerplate with accurate content. All diagrams parse and link-check clean. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
204 lines
8.7 KiB
C#
204 lines
8.7 KiB
C#
using System.Reflection;
|
|
using NetArchTest.Rules;
|
|
using New.Application.Ownership;
|
|
using New.Infrastructure.CaseFramework;
|
|
using New.Infrastructure.Legacy;
|
|
using New.Infrastructure.Persistence;
|
|
using Xunit;
|
|
|
|
namespace Architecture.Tests;
|
|
|
|
/// <summary>
|
|
/// Encodes the design's architecture rules as build-failing assertions. A demo that
|
|
/// passes the smoke script but fails these has demonstrated nothing - the
|
|
/// seam boundaries are the point, not an implementation detail.
|
|
/// </summary>
|
|
public class ArchitectureTests
|
|
{
|
|
private static readonly Assembly DomainAssembly = typeof(New.Domain.RegistrationApplication).Assembly;
|
|
private static readonly Assembly ApplicationAssembly = typeof(New.Application.Ports.IApplicationSource).Assembly;
|
|
private static readonly Assembly PersistenceAssembly = typeof(NewDbContext).Assembly;
|
|
private static readonly Assembly LegacyAssembly = typeof(LegacyCaseSource).Assembly;
|
|
private static readonly Assembly CaseFrameworkAssembly = typeof(CaseFrameworkGateway).Assembly;
|
|
private static readonly Assembly ApiAssembly = typeof(New.Api.Endpoints.WorklistEndpoints).Assembly;
|
|
|
|
private static readonly Assembly[] AllNewAssemblies =
|
|
[
|
|
DomainAssembly, ApplicationAssembly, PersistenceAssembly, LegacyAssembly, CaseFrameworkAssembly, ApiAssembly,
|
|
];
|
|
|
|
[Fact]
|
|
public void Rule1_Domain_And_Application_Have_No_Dependency_On_CaseFramework()
|
|
{
|
|
var result = Types.InAssembly(DomainAssembly).Should().NotHaveDependencyOn("New.Infrastructure.CaseFramework").GetResult();
|
|
Assert.True(result.IsSuccessful, Describe(result));
|
|
|
|
result = Types.InAssembly(ApplicationAssembly).Should().NotHaveDependencyOn("New.Infrastructure.CaseFramework").GetResult();
|
|
Assert.True(result.IsSuccessful, Describe(result));
|
|
}
|
|
|
|
[Fact]
|
|
public void Rule2_Domain_And_Application_Have_No_Dependency_On_Legacy()
|
|
{
|
|
var result = Types.InAssembly(DomainAssembly).Should().NotHaveDependencyOn("New.Infrastructure.Legacy").GetResult();
|
|
Assert.True(result.IsSuccessful, Describe(result));
|
|
|
|
result = Types.InAssembly(ApplicationAssembly).Should().NotHaveDependencyOn("New.Infrastructure.Legacy").GetResult();
|
|
Assert.True(result.IsSuccessful, Describe(result));
|
|
}
|
|
|
|
[Fact]
|
|
public void Rule3_Legacy_Dtos_Are_Internal()
|
|
{
|
|
var result = Types.InAssembly(LegacyAssembly)
|
|
.That().ResideInNamespace("New.Infrastructure.Legacy.Dtos")
|
|
.Should().NotBePublic()
|
|
.GetResult();
|
|
Assert.True(result.IsSuccessful, Describe(result));
|
|
}
|
|
|
|
[Fact]
|
|
public void Rule4_CaseFramework_Dtos_Are_Internal()
|
|
{
|
|
var result = Types.InAssembly(CaseFrameworkAssembly)
|
|
.That().ResideInNamespace("New.Infrastructure.CaseFramework.Dtos")
|
|
.Should().NotBePublic()
|
|
.GetResult();
|
|
Assert.True(result.IsSuccessful, Describe(result));
|
|
}
|
|
|
|
[Fact]
|
|
public void Rule5_No_Legacy_Or_CaseFramework_Connection_String_In_New_Config()
|
|
{
|
|
// Config-file concern, not code. Verified by inspection: the
|
|
// only connection string anywhere under New.* is ConnectionStrings:New
|
|
// (New.Infrastructure.Persistence.ServiceCollectionExtensions), and
|
|
// docker-compose.yml only ever injects ConnectionStrings__New into
|
|
// new-backend. Nothing to assert against compiled IL here.
|
|
Assert.True(true);
|
|
}
|
|
|
|
[Fact]
|
|
public void Rule6_No_Public_Member_Named_Status_In_Domain()
|
|
{
|
|
var offendingMembers = DomainAssembly.GetTypes()
|
|
.Where(t => t.IsPublic || t.IsNestedPublic)
|
|
.SelectMany(t => t.GetMembers(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly))
|
|
.Where(m => (m is PropertyInfo || m is FieldInfo) && m.Name == "Status")
|
|
.ToList();
|
|
|
|
Assert.True(offendingMembers.Count == 0,
|
|
$"Found public member(s) named exactly 'Status' in New.Domain: {string.Join(", ", offendingMembers.Select(m => $"{m.DeclaringType!.Name}.{m.Name}"))}. " +
|
|
"Use ProcessStatus (case-framework-sourced) or AssessmentOutcome (domain decision) instead.");
|
|
}
|
|
|
|
[Fact]
|
|
public void Rule7_ApplicationSourceResolver_Is_Only_Type_Referencing_Both_Sources()
|
|
{
|
|
// ApplicationSourceResolver is `internal` (New.Api) with no
|
|
// InternalsVisibleTo grant, so it can't be named via `typeof` here -
|
|
// looked up by name instead, exactly as NetArchTest itself inspects
|
|
// compiled IL rather than relying on compile-time visibility.
|
|
var resolverType = ApiAssembly.GetType("New.Api.Resolution.ApplicationSourceResolver");
|
|
Assert.NotNull(resolverType);
|
|
|
|
var ownedType = typeof(OwnedApplicationSource);
|
|
var legacyType = typeof(LegacyCaseSource);
|
|
|
|
var typesReferencingBoth = AllNewAssemblies
|
|
.SelectMany(GetLoadableTypes)
|
|
.Where(t => ReferencesType(t, ownedType) && ReferencesType(t, legacyType))
|
|
.ToList();
|
|
|
|
Assert.True(
|
|
typesReferencingBoth.Count == 1 && typesReferencingBoth[0] == resolverType,
|
|
$"Expected only {resolverType!.Name} to reference both {nameof(OwnedApplicationSource)} and {nameof(LegacyCaseSource)}, " +
|
|
$"but found: {string.Join(", ", typesReferencingBoth.Select(t => t.FullName))}");
|
|
}
|
|
|
|
[Fact]
|
|
public void Rule8_TakeOwnershipHandler_References_Only_Ports()
|
|
{
|
|
var result = Types.InAssembly(ApplicationAssembly)
|
|
.That().HaveName(nameof(TakeOwnershipHandler))
|
|
.Should().NotHaveDependencyOnAny(
|
|
"New.Infrastructure.Persistence", "New.Infrastructure.Legacy", "New.Infrastructure.CaseFramework")
|
|
.GetResult();
|
|
Assert.True(result.IsSuccessful, Describe(result));
|
|
}
|
|
|
|
[Fact]
|
|
public void Rule9_No_New_Project_References_SqlServer()
|
|
{
|
|
var offending = AllNewAssemblies
|
|
.Where(a => a.GetReferencedAssemblies().Any(r => r.Name == "Microsoft.EntityFrameworkCore.SqlServer"))
|
|
.ToList();
|
|
|
|
Assert.True(offending.Count == 0,
|
|
$"These New.* assemblies reference Microsoft.EntityFrameworkCore.SqlServer: {string.Join(", ", offending.Select(a => a.GetName().Name))}");
|
|
}
|
|
|
|
[Fact]
|
|
public void Rule10_Legacy_SqlServer_Only_Is_The_Legacy_Agents_Concern()
|
|
{
|
|
// Owned by legacy/ (a separate solution) - not referenceable from
|
|
// this test project. Verified by inspection there instead.
|
|
Assert.True(true);
|
|
}
|
|
|
|
[Fact]
|
|
public void Rule11_Api_Never_Both_Constructs_Legacy_Dto_And_Touches_DbContext()
|
|
{
|
|
// Structurally all-but-guaranteed already: legacy DTOs are internal to
|
|
// New.Infrastructure.Legacy with no InternalsVisibleTo grant (rule 3),
|
|
// so New.Api cannot even name them, let alone construct one. This is
|
|
// therefore a best-effort namespace-level check, not full proof - see
|
|
// ADR-002 for why rule 11's stronger claim ("the write-through
|
|
// translator contains no branching on request values") is a review
|
|
// rule, not a machine-enforced one.
|
|
var apiTypesTouchingDbContext = Types.InAssembly(ApiAssembly)
|
|
.That().HaveDependencyOn("New.Infrastructure.Persistence")
|
|
.GetTypes();
|
|
|
|
var apiTypesTouchingLegacyDtos = Types.InAssembly(ApiAssembly)
|
|
.That().HaveDependencyOn("New.Infrastructure.Legacy.Dtos")
|
|
.GetTypes();
|
|
|
|
var overlap = apiTypesTouchingDbContext.Intersect(apiTypesTouchingLegacyDtos).ToList();
|
|
|
|
Assert.True(overlap.Count == 0,
|
|
$"These New.Api types both touch persistence and legacy DTOs: {string.Join(", ", overlap.Select(t => t.FullName))}");
|
|
}
|
|
|
|
private static bool ReferencesType(Type t, Type target)
|
|
{
|
|
if (t == target)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
const BindingFlags all = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly;
|
|
|
|
var ctorParamTypes = t.GetConstructors(all).SelectMany(c => c.GetParameters()).Select(p => p.ParameterType);
|
|
var fieldTypes = t.GetFields(all).Select(f => f.FieldType);
|
|
var propTypes = t.GetProperties(all).Select(p => p.PropertyType);
|
|
|
|
return ctorParamTypes.Concat(fieldTypes).Concat(propTypes).Any(x => x == target);
|
|
}
|
|
|
|
private static IEnumerable<Type> GetLoadableTypes(Assembly assembly)
|
|
{
|
|
try
|
|
{
|
|
return assembly.GetTypes();
|
|
}
|
|
catch (ReflectionTypeLoadException ex)
|
|
{
|
|
return ex.Types.Where(t => t is not null)!;
|
|
}
|
|
}
|
|
|
|
private static string Describe(TestResult result) =>
|
|
result.IsSuccessful ? string.Empty : $"Failing types: {string.Join(", ", result.FailingTypes?.Select(t => t.FullName) ?? [])}";
|
|
}
|