diff --git a/.github/workflows/build-samples.yml b/.github/workflows/build-samples.yml index b500112..43e3fd8 100644 --- a/.github/workflows/build-samples.yml +++ b/.github/workflows/build-samples.yml @@ -7,6 +7,7 @@ on: - "dotnet-ai/provider-agnostic-chat-gateway/**" - "dotnet-ai/hybrid-search-ef-core-pgvector/**" - "dotnet-aspire/orchestrate-distributed-system/**" + - "software-architecture/architecture-testing-dotnet/**" - ".github/workflows/build-samples.yml" pull_request: @@ -14,6 +15,7 @@ on: - "dotnet-ai/provider-agnostic-chat-gateway/**" - "dotnet-ai/hybrid-search-ef-core-pgvector/**" - "dotnet-aspire/orchestrate-distributed-system/**" + - "software-architecture/architecture-testing-dotnet/**" - ".github/workflows/build-samples.yml" workflow_dispatch: @@ -103,3 +105,35 @@ jobs: dotnet-aspire/orchestrate-distributed-system/AspireOrchestrationMinimal.slnx \ --configuration Release \ --no-restore + + test-architecture-rules: + name: Test architecture rules + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v5 + + - name: Install .NET 10 SDK + uses: actions/setup-dotnet@v5 + with: + dotnet-version: "10.0.x" + + - name: Restore + run: | + dotnet restore \ + software-architecture/architecture-testing-dotnet/ArchitectureGuardMinimal.slnx + + - name: Build + run: | + dotnet build \ + software-architecture/architecture-testing-dotnet/ArchitectureGuardMinimal.slnx \ + --configuration Release \ + --no-restore + + - name: Test architecture rules + run: | + dotnet test \ + software-architecture/architecture-testing-dotnet/tests/ArchitectureGuard.ArchitectureTests/ArchitectureGuard.ArchitectureTests.csproj \ + --configuration Release \ + --no-build diff --git a/README.md b/README.md index fcbffb0..ab708e9 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Each sample folder contains a focused implementation of one tutorial topic. The | [`dotnet-ai/provider-agnostic-chat-gateway`](dotnet-ai/provider-agnostic-chat-gateway/) | One HTTP chat endpoint using Ollama and optional OpenAI providers through `Microsoft.Extensions.AI.IChatClient` | [Build a switchable multi-provider AI gateway with IChatClient](https://www.dotnet-guide.com/tutorials/dotnet-ai/provider-agnostic-chat-gateway/) | | [`dotnet-ai/hybrid-search-ef-core-pgvector`](dotnet-ai/hybrid-search-ef-core-pgvector/) | Minimal Reciprocal Rank Fusion demo combining pre-ranked keyword and vector results | [Hybrid Search in .NET with EF Core 10 and pgvector](https://www.dotnet-guide.com/tutorials/dotnet-ai/hybrid-search-ef-core-pgvector/) | | [`dotnet-aspire/orchestrate-distributed-system`](dotnet-aspire/orchestrate-distributed-system/) | Minimal Aspire AppHost coordinating a web project and API with service discovery and startup ordering | [Aspire in .NET: Orchestrate, Run, and Deploy a Distributed System from One App Host](https://www.dotnet-guide.com/tutorials/dotnet-aspire/orchestrate-distributed-system/) | +| [`software-architecture/architecture-testing-dotnet`](software-architecture/architecture-testing-dotnet/) | Minimal NetArchTest.eNhancedEdition rule that prevents Domain from depending on outer layers | [Architecture Testing in .NET: Enforce Layer and Module Boundaries with NetArchTest and ArchUnitNET](https://www.dotnet-guide.com/tutorials/software-architecture/architecture-testing-dotnet/) | ## Companion articles @@ -68,6 +69,27 @@ tutorials/ | `-- AspireOrchestrationMinimal.Web/ | |-- Program.cs | `-- AspireOrchestrationMinimal.Web.csproj +|-- software-architecture/ +| `-- architecture-testing-dotnet/ +| |-- ArchitectureGuardMinimal.slnx +| |-- README.md +| |-- src/ +| | |-- ArchitectureGuard.Domain/ +| | | |-- ArchitectureGuard.Domain.csproj +| | | |-- DomainAssemblyMarker.cs +| | | `-- Order.cs +| | |-- ArchitectureGuard.Application/ +| | | |-- ArchitectureGuard.Application.csproj +| | | |-- ApplicationAssemblyMarker.cs +| | | `-- GetOrderSummary.cs +| | `-- ArchitectureGuard.Infrastructure/ +| | |-- ArchitectureGuard.Infrastructure.csproj +| | |-- InfrastructureAssemblyMarker.cs +| | `-- InMemoryOrderRepository.cs +| `-- tests/ +| `-- ArchitectureGuard.ArchitectureTests/ +| |-- ArchitectureGuard.ArchitectureTests.csproj +| `-- LayerRules.cs |-- .github/ | `-- workflows/ | `-- build-samples.yml diff --git a/software-architecture/architecture-testing-dotnet/ArchitectureGuardMinimal.slnx b/software-architecture/architecture-testing-dotnet/ArchitectureGuardMinimal.slnx new file mode 100644 index 0000000..a989c2f --- /dev/null +++ b/software-architecture/architecture-testing-dotnet/ArchitectureGuardMinimal.slnx @@ -0,0 +1,6 @@ + + + + + + diff --git a/software-architecture/architecture-testing-dotnet/README.md b/software-architecture/architecture-testing-dotnet/README.md new file mode 100644 index 0000000..ea864bf --- /dev/null +++ b/software-architecture/architecture-testing-dotnet/README.md @@ -0,0 +1,199 @@ +# Architecture Testing in .NET — Minimal Layer Rule + +A minimal .NET 10 architecture-testing sample demonstrating how a dedicated +test project inspects compiled assemblies and fails the build when a forbidden +dependency appears. + +## Full tutorial + +[Architecture Testing in .NET: Enforce Layer and Module Boundaries with NetArchTest and ArchUnitNET](https://www.dotnet-guide.com/tutorials/software-architecture/architecture-testing-dotnet/) + +## What this sample demonstrates + +* a layered .NET 10 solution with Domain, Application, and Infrastructure projects; +* an architecture-test project using **NetArchTest.eNhancedEdition**; +* typed assembly markers that give the test project a reliable way to locate each assembly; +* one forbidden-dependency rule that prevents Domain from depending on outer layers; +* selector non-empty protection so the rule cannot silently pass when no types are found; +* readable failure output naming the protected rule and the offending types; +* normal `dotnet test` execution through xUnit v3. + +## Architecture + +```text +Infrastructure ──► Application ──► Domain + +Allowed direction: toward Domain (outer → inner) +Rejected direction: Domain toward outer layers +``` + +```text +ArchitectureTests + │ + ├── inspects Domain + ├── inspects Application + └── inspects Infrastructure +``` + +## File structure + +```text +architecture-testing-dotnet/ +├── ArchitectureGuardMinimal.slnx +├── README.md +├── src/ +│ ├── ArchitectureGuard.Domain/ +│ │ ├── ArchitectureGuard.Domain.csproj +│ │ ├── DomainAssemblyMarker.cs +│ │ └── Order.cs +│ ├── ArchitectureGuard.Application/ +│ │ ├── ArchitectureGuard.Application.csproj +│ │ ├── ApplicationAssemblyMarker.cs +│ │ └── GetOrderSummary.cs +│ └── ArchitectureGuard.Infrastructure/ +│ ├── ArchitectureGuard.Infrastructure.csproj +│ ├── InfrastructureAssemblyMarker.cs +│ └── InMemoryOrderRepository.cs +└── tests/ + └── ArchitectureGuard.ArchitectureTests/ + ├── ArchitectureGuard.ArchitectureTests.csproj + └── LayerRules.cs +``` + +## Prerequisites + +* .NET 10 SDK + +No Docker, Podman, databases, API keys, or cloud credentials are required. + +## Run the sample + +Open a terminal in this sample folder and run: + +```powershell +dotnet restore +dotnet build --configuration Release --no-restore +dotnet test --configuration Release --no-build +``` + +Expected output: + +```text +Passed! - Failed: 0, Passed: 1, Skipped: 0, Total: 1 +``` + +## Prove the rule works + +To verify that the architecture test genuinely enforces layer boundaries, +temporarily add a forbidden dependency: + +1. Add an Infrastructure project reference to Domain: + ```powershell + dotnet add src/ArchitectureGuard.Domain/ArchitectureGuard.Domain.csproj reference src/ArchitectureGuard.Infrastructure/ArchitectureGuard.Infrastructure.csproj + ``` + +2. Edit `src/ArchitectureGuard.Domain/Order.cs` to reference an outer type: + ```csharp + // Temporary violation — add this using: + using ArchitectureGuard.Infrastructure; + + namespace ArchitectureGuard.Domain; + + public sealed record Order(Guid Id, decimal Total) + { + // Add a temporary field that references the outer layer: + private static readonly InfrastructureAssemblyMarker _marker = new(); + } + ``` + +3. Run the tests again: + ```powershell + dotnet build --configuration Release --no-restore + dotnet test --configuration Release --no-build + ``` + + The architecture test now fails with a message like: + ```text + Architecture rule failed: Domain must remain independent of outer layers. + - ArchitectureGuard.Domain.Order + [dependency explanation] + ``` + +4. Remove the temporary violation: + ```powershell + dotnet remove src/ArchitectureGuard.Domain/ArchitectureGuard.Domain.csproj reference src/ArchitectureGuard.Infrastructure/ArchitectureGuard.Infrastructure.csproj + ``` + Then revert `Order.cs` to its original content. + +5. Rebuild and confirm the test passes again. + +Do **not** commit the violation. + +## Project references + +```text +ArchitectureGuard.Application +└── references ArchitectureGuard.Domain + +ArchitectureGuard.Infrastructure +├── references ArchitectureGuard.Application +└── references ArchitectureGuard.Domain + +ArchitectureGuard.ArchitectureTests +├── references ArchitectureGuard.Domain +├── references ArchitectureGuard.Application +└── references ArchitectureGuard.Infrastructure +``` + +Domain references nothing. + +## Important boundary + +This repository sample intentionally does **not** reproduce the complete tutorial. +The full DOTNET GUIDE tutorial additionally contains: + +* ArchUnitNET; +* Roslyn analyzer projects; +* `Microsoft.CodeAnalysis.CSharp`; +* custom analyzer packaging; +* analyzer unit tests; +* modular-monolith slicing (Orders and Billing modules); +* contracts assemblies; +* reflection-based endpoint authorization checks; +* naming and sealing convention suites; +* value-object immutability rules; +* legacy violation baselines; +* multiple architecture-test frameworks; +* a web API project; +* controllers or Minimal API endpoints; +* EF Core; +* databases; +* authentication; +* containers; +* cloud services; +* production deployment; +* extensive performance optimization; +* complete production-readiness policy. + +## Verification + +| Item | Detail | +| --- | --- | +| Target framework | .NET 10 (`net10.0`) | +| SDK | .NET SDK 10.0.302 | +| Architecture library | `NetArchTest.eNhancedEdition` 1.4.5 | +| Test framework | `xunit.v3` 3.2.2 | +| Test runner | `xunit.runner.visualstudio` 3.1.5 | +| Test SDK | `Microsoft.NET.Test.Sdk` 17.14.1 | +| External services required | None | +| Containers required | None | +| API keys required | None | +| Release build | Verified | +| Architecture test | Verified | +| Last reviewed | 2026-07-27 | + +## License + +Repository-owned sample code is available under the [MIT License](../../LICENSE). + +Third-party NetArchTest and xUnit packages remain subject to their own licenses. \ No newline at end of file diff --git a/software-architecture/architecture-testing-dotnet/src/ArchitectureGuard.Application/ApplicationAssemblyMarker.cs b/software-architecture/architecture-testing-dotnet/src/ArchitectureGuard.Application/ApplicationAssemblyMarker.cs new file mode 100644 index 0000000..76ab086 --- /dev/null +++ b/software-architecture/architecture-testing-dotnet/src/ArchitectureGuard.Application/ApplicationAssemblyMarker.cs @@ -0,0 +1,3 @@ +namespace ArchitectureGuard.Application; + +public sealed class ApplicationAssemblyMarker; \ No newline at end of file diff --git a/software-architecture/architecture-testing-dotnet/src/ArchitectureGuard.Application/ArchitectureGuard.Application.csproj b/software-architecture/architecture-testing-dotnet/src/ArchitectureGuard.Application/ArchitectureGuard.Application.csproj new file mode 100644 index 0000000..366197d --- /dev/null +++ b/software-architecture/architecture-testing-dotnet/src/ArchitectureGuard.Application/ArchitectureGuard.Application.csproj @@ -0,0 +1,13 @@ + + + + + + + + net10.0 + enable + enable + + + diff --git a/software-architecture/architecture-testing-dotnet/src/ArchitectureGuard.Application/GetOrderSummary.cs b/software-architecture/architecture-testing-dotnet/src/ArchitectureGuard.Application/GetOrderSummary.cs new file mode 100644 index 0000000..ec42d87 --- /dev/null +++ b/software-architecture/architecture-testing-dotnet/src/ArchitectureGuard.Application/GetOrderSummary.cs @@ -0,0 +1,3 @@ +namespace ArchitectureGuard.Application; + +public sealed record GetOrderSummary(Guid OrderId); \ No newline at end of file diff --git a/software-architecture/architecture-testing-dotnet/src/ArchitectureGuard.Domain/ArchitectureGuard.Domain.csproj b/software-architecture/architecture-testing-dotnet/src/ArchitectureGuard.Domain/ArchitectureGuard.Domain.csproj new file mode 100644 index 0000000..b760144 --- /dev/null +++ b/software-architecture/architecture-testing-dotnet/src/ArchitectureGuard.Domain/ArchitectureGuard.Domain.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/software-architecture/architecture-testing-dotnet/src/ArchitectureGuard.Domain/DomainAssemblyMarker.cs b/software-architecture/architecture-testing-dotnet/src/ArchitectureGuard.Domain/DomainAssemblyMarker.cs new file mode 100644 index 0000000..06421e2 --- /dev/null +++ b/software-architecture/architecture-testing-dotnet/src/ArchitectureGuard.Domain/DomainAssemblyMarker.cs @@ -0,0 +1,3 @@ +namespace ArchitectureGuard.Domain; + +public sealed class DomainAssemblyMarker; \ No newline at end of file diff --git a/software-architecture/architecture-testing-dotnet/src/ArchitectureGuard.Domain/Order.cs b/software-architecture/architecture-testing-dotnet/src/ArchitectureGuard.Domain/Order.cs new file mode 100644 index 0000000..0790e26 --- /dev/null +++ b/software-architecture/architecture-testing-dotnet/src/ArchitectureGuard.Domain/Order.cs @@ -0,0 +1,3 @@ +namespace ArchitectureGuard.Domain; + +public sealed record Order(Guid Id, decimal Total); \ No newline at end of file diff --git a/software-architecture/architecture-testing-dotnet/src/ArchitectureGuard.Infrastructure/ArchitectureGuard.Infrastructure.csproj b/software-architecture/architecture-testing-dotnet/src/ArchitectureGuard.Infrastructure/ArchitectureGuard.Infrastructure.csproj new file mode 100644 index 0000000..cbffeb2 --- /dev/null +++ b/software-architecture/architecture-testing-dotnet/src/ArchitectureGuard.Infrastructure/ArchitectureGuard.Infrastructure.csproj @@ -0,0 +1,14 @@ + + + + + + + + + net10.0 + enable + enable + + + diff --git a/software-architecture/architecture-testing-dotnet/src/ArchitectureGuard.Infrastructure/InMemoryOrderRepository.cs b/software-architecture/architecture-testing-dotnet/src/ArchitectureGuard.Infrastructure/InMemoryOrderRepository.cs new file mode 100644 index 0000000..b973b3d --- /dev/null +++ b/software-architecture/architecture-testing-dotnet/src/ArchitectureGuard.Infrastructure/InMemoryOrderRepository.cs @@ -0,0 +1,13 @@ +using ArchitectureGuard.Application; +using ArchitectureGuard.Domain; + +namespace ArchitectureGuard.Infrastructure; + +public sealed class InMemoryOrderRepository +{ + public GetOrderSummary GetSummary(Guid orderId) + { + var order = new Order(orderId, 123.45m); + return new GetOrderSummary(order.Id); + } +} \ No newline at end of file diff --git a/software-architecture/architecture-testing-dotnet/src/ArchitectureGuard.Infrastructure/InfrastructureAssemblyMarker.cs b/software-architecture/architecture-testing-dotnet/src/ArchitectureGuard.Infrastructure/InfrastructureAssemblyMarker.cs new file mode 100644 index 0000000..4ac9518 --- /dev/null +++ b/software-architecture/architecture-testing-dotnet/src/ArchitectureGuard.Infrastructure/InfrastructureAssemblyMarker.cs @@ -0,0 +1,3 @@ +namespace ArchitectureGuard.Infrastructure; + +public sealed class InfrastructureAssemblyMarker; \ No newline at end of file diff --git a/software-architecture/architecture-testing-dotnet/tests/ArchitectureGuard.ArchitectureTests/ArchitectureGuard.ArchitectureTests.csproj b/software-architecture/architecture-testing-dotnet/tests/ArchitectureGuard.ArchitectureTests/ArchitectureGuard.ArchitectureTests.csproj new file mode 100644 index 0000000..3e53636 --- /dev/null +++ b/software-architecture/architecture-testing-dotnet/tests/ArchitectureGuard.ArchitectureTests/ArchitectureGuard.ArchitectureTests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/software-architecture/architecture-testing-dotnet/tests/ArchitectureGuard.ArchitectureTests/LayerRules.cs b/software-architecture/architecture-testing-dotnet/tests/ArchitectureGuard.ArchitectureTests/LayerRules.cs new file mode 100644 index 0000000..8534d06 --- /dev/null +++ b/software-architecture/architecture-testing-dotnet/tests/ArchitectureGuard.ArchitectureTests/LayerRules.cs @@ -0,0 +1,57 @@ +using System.Reflection; +using ArchitectureGuard.Domain; +using NetArchTest.Rules; +using Xunit; +using TestResult = NetArchTest.Rules.TestResult; + +namespace ArchitectureGuard.ArchitectureTests; + +public sealed class LayerRules +{ + private static readonly Assembly DomainAssembly = + typeof(DomainAssemblyMarker).Assembly; + + [Fact] + public void Domain_must_not_depend_on_outer_layers() + { + Type[] selectedTypes = DomainAssembly + .GetTypes() + .Where(type => type.IsClass) + .ToArray(); + + Assert.NotEmpty(selectedTypes); + + TestResult result = Types + .InAssembly(DomainAssembly) + .ShouldNot() + .HaveDependencyOnAny( + "ArchitectureGuard.Application", + "ArchitectureGuard.Infrastructure") + .GetResult(); + + Assert.True( + result.IsSuccessful, + FormatFailure(result)); + } + + private static string FormatFailure(TestResult result) + { + if (result.IsSuccessful) + { + return string.Empty; + } + + IEnumerable failures = + result.FailingTypes.Select(type => + $" - {type.FullName}{Environment.NewLine}" + + $" {type.Explanation}"); + + return + "Architecture rule failed: " + + "Domain must remain independent of outer layers." + + Environment.NewLine + + string.Join( + Environment.NewLine, + failures); + } +} \ No newline at end of file