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