Enforcing Vertical Slice Architecture in .NET
Making slices real by failing the build when someone cheats

As A kind of follow up to a post I wrote the other day on FullStackCity I specifically wanted to look at Vertical Slice in an example this time,
Vertical slice architecture works because it aligns code with outcomes, not layers. A slice is a thin, end-to-end path through the system for one capability, one use case, one verb. You ship "Create Order" as a coherent unit. You can read it, change it, test it, and delete it without spelunking through a maze of cross cutting abstractions.
The catch is that vertical slice architecture is easy to describe and surprisingly easy to violate. A developer wants to reuse a handler from another slice. A validator grabs a repository from a different feature because it is already there. A new shared project appears called Common that quietly becomes a dumping ground. You still have folders named Features, but the system has drifted back into accidental layering and hidden coupling.
NetArchTest is again how you stop that drift. You take the rules you mean when you say "vertical slices" and you encode them as tests that execute in CI. If someone reaches across a slice boundary, the build breaks. If someone introduces a dependency you forbid, the build breaks. Vertical slice stops being a preference and becomes a contract.
This post shows how to do that in a real .NET solution. The example here is a SaaS scheduling example called ClinicFlow, a system that manages appointments, patients, and clinician calendars. The exact domain does not matter. The rules and patterns do.
What you are enforcing in a vertical slice system
Vertical slice is not feature folders alone. It is a set of constraints about dependency direction and coupling. If you cannot state those constraints clearly, you cannot enforce them.
In this article, a Slice is a namespace root like:
ClinicFlow.Features.Appointments.CreateAppointmentClinicFlow.Features.Appointments.CancelAppointmentClinicFlow.Features.Patients.RegisterPatient
Each slice is allowed to contain its own endpoint, request/response models, handler, validation, and persistence access strategy. Shared code is allowed, but it must be deliberately small and clearly named, not an accidental kitchen-sink.
The core constraints we will enforce are these:
A slice must not depend on other slices directly. A slice may depend on shared building blocks, but it should not call another slice’s handler, validator, or internal types.
The API layer must not reference infrastructure details. It should reference slices, and slices should own their own wiring and dependencies.
The domain model should not depend on infrastructure, and preferably should not depend on any specific slice. Domain is the stable core. Slices orchestrate.
Infrastructure should not leak into slice code except through explicit ports or well-known boundaries you choose to permit.
You can relax or tighten any of these. The point is to make them explicit, then enforce them.
The forbidden links are the ones NetArchTest will enforce.
A concrete solution layout that is easy to test
A workable .NET layout for this style looks like this:
ClinicFlow.Api
ClinicFlow.Features
ClinicFlow.Domain
ClinicFlow.Infrastructure
ClinicFlow.Shared
ClinicFlow.ArchitectureTests
You can also keep Features inside the API project if you want one deployable. NetArchTest works either way. What matters is that slices are discoverable by namespace and that the projects reflect dependency intent.
A typical slice namespace might look like this:
ClinicFlow.Features.Appointments.CreateAppointment
EndpointRequestResponseHandlerValidatorDbaccess, either via a port or direct EF Core access if you intentionally allow it
You dont need MediatR for vertical slice. Many say you do but this is not the case and since its not free anymore its a cost you dont need to worry about. NetArchTest doesnt care. It inspects compiled dependencies, not runtime behaviour.
Set up the architecture test project
Create a test project that references the assemblies you want to check.
ClinicFlow.ArchitectureTests references:
ClinicFlow.Api(optional but useful)ClinicFlow.FeaturesClinicFlow.DomainClinicFlow.InfrastructureClinicFlow.Shared
Install the package:
dotnet add ClinicFlow.ArchitectureTests package NetArchTest.Rules
Add a normal xUnit or NUnit test setup. The architecture tests run like any other test in CI.
Create assembly markers so tests are stable
You need reliable ways to grab the correct assemblies. Do not load by string name if you can avoid it. Add one marker type per assembly.
In ClinicFlow.Features:
namespace ClinicFlow.Features;
public sealed class FeaturesAssemblyMarker { }
In ClinicFlow.Domain:
namespace ClinicFlow.Domain;
public sealed class DomainAssemblyMarker { }
In ClinicFlow.Infrastructure:
namespace ClinicFlow.Infrastructure;
public sealed class InfrastructureAssemblyMarker { }
In ClinicFlow.Shared:
namespace ClinicFlow.Shared;
public sealed class SharedAssemblyMarker { }
This keeps the tests robust across renames.
Rule 1 - Domain must not depend on Infrastructure
This is not unique to vertical slice, but it is still the first rule you should enforce because it stops the most expensive kind of coupling.
using NetArchTest.Rules;
using Xunit;
using ClinicFlow.Domain;
public sealed class CleanCoreTests
{
[Fact]
public void Domain_Should_Not_Depend_On_Infrastructure()
{
var result = Types.InAssembly(typeof(DomainAssemblyMarker).Assembly)
.ShouldNot()
.HaveDependencyOn("ClinicFlow.Infrastructure")
.GetResult();
Assert.True(result.IsSuccessful, FormatFailure(result));
}
private static string FormatFailure(TestResult result)
=> "Architecture rule failed:\n" + string.Join("\n", result.FailingTypeNames ?? Array.Empty<string>());
}
This catches both direct references and indirect references that become compile-time dependencies.
Rule 2 - Slices must not depend on other slices
This is the rule that makes vertical slices real.
The tricky part is that slice is not a language concept. You define it. Here we define a slice as:
ClinicFlow.Features.<Area>.<UseCase>
So anything under ClinicFlow.Features.Appointments.CreateAppointment is one slice. Anything under ClinicFlow.Features.Appointments.CancelAppointment is another slice.
We want to prevent types in one slice from referencing types in another slice.
NetArchTest can check dependencies for a set of types, but we need to group types by slice and then check each slice against all other slices.
This is one place where a little helper code makes the tests readable and scalable.
using System.Reflection;
using NetArchTest.Rules;
using Xunit;
using ClinicFlow.Features;
public sealed class SliceIsolationTests
{
private const string FeaturesRoot = "ClinicFlow.Features.";
[Fact]
public void Slices_Should_Not_Depend_On_Other_Slices()
{
var featuresAssembly = typeof(FeaturesAssemblyMarker).Assembly;
var sliceRoots = GetSliceRoots(featuresAssembly);
foreach (var sliceRoot in sliceRoots)
{
foreach (var otherSliceRoot in sliceRoots)
{
if (sliceRoot == otherSliceRoot)
continue;
var result = Types.InAssembly(featuresAssembly)
.That()
.ResideInNamespaceStartingWith(sliceRoot)
.ShouldNot()
.HaveDependencyOn(otherSliceRoot)
.GetResult();
Assert.True(
result.IsSuccessful,
$"Slice '{sliceRoot}' must not depend on slice '{otherSliceRoot}'.\n" +
string.Join("\n", result.FailingTypeNames ?? Array.Empty<string>())
);
}
}
}
private static IReadOnlyList<string> GetSliceRoots(Assembly assembly)
{
var namespaces = assembly.GetTypes()
.Where(t => t.Namespace is not null && t.Namespace.StartsWith(FeaturesRoot, StringComparison.Ordinal))
.Select(t => t.Namespace!)
.Distinct()
.ToArray();
var sliceRoots = new HashSet<string>(StringComparer.Ordinal);
foreach (var ns in namespaces)
{
var root = TryGetSliceRoot(ns);
if (root is not null)
sliceRoots.Add(root);
}
return sliceRoots.OrderBy(x => x, StringComparer.Ordinal).ToArray();
}
private static string? TryGetSliceRoot(string @namespace)
{
if (!@namespace.StartsWith(FeaturesRoot, StringComparison.Ordinal))
return null;
var parts = @namespace.Split('.');
if (parts.Length < 4)
return null;
// parts[0]=ClinicFlow, [1]=Features, [2]=Area, [3]=UseCase
return $"{parts[0]}.{parts[1]}.{parts[2]}.{parts[3]}";
}
}
This test dynamically discovers slice roots by scanning namespaces in the Features assembly. It then asserts no slice has a compile time dependency on another slice root namespace.
If someone imports a request model from a different use case, or calls another handler directly, the test fails.
This is the main vertical slice enforcement win.
If your team sees this diagram and still wants cross slice calls, at least you are arguing about a real rule, not vibes.
Rule 3 - Only Shared is allowed as the cross slice seam
Slice isolation often triggers the next question: how do slices share anything?
The answer is that sharing should be explicit and constrained. You choose a seam project or seam namespace and you allow dependencies on it.
In this example, the allowed seam is ClinicFlow.Shared. That project might contain:
Result types
Error primitives
Simple contracts
Ports (interfaces) that infrastructure implements
Cross-cutting abstractions that are stable and intentionally small
What you do not want is slices depending on other slices through Shared accidentally, by moving random things into Shared until the tests pass. The enforcement has to push you toward good structure, not just pass.
So you add a rule that slices may depend on Domain and Shared, but should not depend on Infrastructure directly unless you intentionally allow it.
Here is the strict version:
using NetArchTest.Rules;
using Xunit;
using ClinicFlow.Features;
public sealed class SliceDependencyDirectionTests
{
[Fact]
public void Features_Should_Not_Depend_On_Infrastructure()
{
var result = Types.InAssembly(typeof(FeaturesAssemblyMarker).Assembly)
.ShouldNot()
.HaveDependencyOn("ClinicFlow.Infrastructure")
.GetResult();
Assert.True(result.IsSuccessful, FormatFailure(result));
}
private static string FormatFailure(TestResult result)
=> "Architecture rule failed:\n" + string.Join("\n", result.FailingTypeNames ?? Array.Empty<string>());
}
This forces infrastructure access to happen through ports in Shared, or through composition in the API host.
If you prefer "feature owns persistence" and you allow EF Core in slices, do not pretend you are enforcing something you are not. Instead, define a narrower rule that still protects you, for example: slices may depend on EF Core, but not on Infrastructure project namespaces, or not on specific infrastructure implementations. The enforcement should match your intended style.
Rule 4 - API endpoints must not depend on Infrastructure
Even if your slices are clean, the API host can still become a mess if endpoints or controllers pull in infrastructure services directly. You want the host to be wiring and transport, not business logic.
This test assumes your API project namespace starts with ClinicFlow.Api.
using NetArchTest.Rules;
using Xunit;
using ClinicFlow.Api;
public sealed class ApiBoundaryTests
{
[Fact]
public void Api_Should_Not_Depend_On_Infrastructure()
{
var result = Types.InAssembly(typeof(ApiAssemblyMarker).Assembly)
.ShouldNot()
.HaveDependencyOn("ClinicFlow.Infrastructure")
.GetResult();
Assert.True(result.IsSuccessful, FormatFailure(result));
}
private static string FormatFailure(TestResult result)
=> "Architecture rule failed:\n" + string.Join("\n", result.FailingTypeNames ?? Array.Empty<string>());
}
If you use minimal APIs and everything is in Program.cs, this still works because Program.cs compiles into the same assembly and will be scanned.
If you intentionally place composition root registrations in API that reference Infrastructure, you can scope the rule to only endpoints. For example, put endpoints into ClinicFlow.Api.Endpoints and test just that namespace.
Rule 5 - Slices must follow your naming conventions
Naming sounds cosmetic until you are debugging production at 2 a.m. Consistent naming is navigational infrastructure. It is worth enforcing.
If your rule is "each slice has exactly one handler named Handler or ending with Handler," enforce it.
For a pattern like CreateAppointmentHandler, you can do:
using NetArchTest.Rules;
using Xunit;
using ClinicFlow.Features;
public sealed class NamingConventionTests
{
[Fact]
public void Handlers_Should_End_With_Handler()
{
var result = Types.InAssembly(typeof(FeaturesAssemblyMarker).Assembly)
.That()
.HaveNameEndingWith("Handler")
.Should()
.ResideInNamespaceContaining("Features")
.GetResult();
Assert.True(result.IsSuccessful, FormatFailure(result));
}
private static string FormatFailure(TestResult result)
=> "Architecture rule failed:\n" + string.Join("\n", result.FailingTypeNames ?? Array.Empty<string>());
}
You can tighten this by filtering only namespaces that match your handler conventions, such as requiring handlers to live inside a slice root and not in random helper namespaces.
Rule 6 - No "Shared dumping ground" namespace patterns
This is the rule most teams skip, and it is why their slice isolation becomes a game of whack-a-mole. They move types into Shared until tests pass, then Shared becomes the new monolith inside the monolith.
You can enforce "Shared must be thin" by banning certain patterns. For example, forbid Shared from depending on Features, and keep Shared from referencing Infrastructure.
using NetArchTest.Rules;
using Xunit;
using ClinicFlow.Shared;
public sealed class SharedKernelTests
{
[Fact]
public void Shared_Should_Not_Depend_On_Features()
{
var result = Types.InAssembly(typeof(SharedAssemblyMarker).Assembly)
.ShouldNot()
.HaveDependencyOn("ClinicFlow.Features")
.GetResult();
Assert.True(result.IsSuccessful, FormatFailure(result));
}
[Fact]
public void Shared_Should_Not_Depend_On_Infrastructure()
{
var result = Types.InAssembly(typeof(SharedAssemblyMarker).Assembly)
.ShouldNot()
.HaveDependencyOn("ClinicFlow.Infrastructure")
.GetResult();
Assert.True(result.IsSuccessful, FormatFailure(result));
}
private static string FormatFailure(TestResult result)
=> "Architecture rule failed:\n" + string.Join("\n", result.FailingTypeNames ?? Array.Empty<string>());
}
This keeps the seam from becoming a back door.
A realistic vertical slice example to illustrate "what not to do"
Imagine you have two slices:
ClinicFlow.Features.Appointments.CreateAppointment
ClinicFlow.Features.Appointments.CancelAppointment
A developer wants to reuse cancellation logic in the create flow to prevent double booking and they do this:
CreateAppointmentHandler references CancelAppointmentHandler to clear previous pending slots.
It compiles. It might even work. It is also a structural error because it makes slice A depend on slice B. The system now has hidden coupling between use cases. Refactoring becomes riskier because changes in CancelAppointment can break CreateAppointment.
The slice isolation rule above catches this immediately because CreateAppointment now has a dependency on the other slice root namespace. The build fails and the developer is forced into a better design.
What is the better design? Usually one of these, move shared logic into Domain if it is domain policy, move shared logic into Shared if it is a stable cross cutting primitive, or duplicate small orchestration logic if it is slice specific. Duplication is not a sin in vertical slice when it is small and it avoids coupling. The cost of duplication is often lower than the cost of accidental dependency networks.
NetArchTest does not tell you which option to pick. It just makes the coupling visible and expensive, which is what you want.
Enforcing "slice owns its endpoint" without becoming dogmatic
Many vertical slice teams want a simple rule, endpoints should live inside slices, not in a central Controllers folder. This makes each slice truly end-to-end.
If you follow this, you can enforce that API contains no controllers at all, or that API endpoint types must reside under ClinicFlow.Features.
One practical approach is to keep only hosting and wiring in ClinicFlow.Api, and put endpoints in ClinicFlow.Features.*.*.Endpoint.
You can then enforce that the API assembly does not contain types with names like *Controller or does not contain any types in ClinicFlow.Api.Controllers.
using NetArchTest.Rules;
using Xunit;
using ClinicFlow.Api;
public sealed class EndpointPlacementTests
{
[Fact]
public void Api_Should_Not_Contain_Controllers()
{
var result = Types.InAssembly(typeof(ApiAssemblyMarker).Assembly)
.ShouldNot()
.HaveNameEndingWith("Controller")
.GetResult();
Assert.True(result.IsSuccessful, FormatFailure(result));
}
private static string FormatFailure(TestResult result)
=> "Architecture rule failed:\n" + string.Join("\n", result.FailingTypeNames ?? Array.Empty<string>());
}
If you do use controllers intentionally, skip this rule. Enforce what you actually mean, not what sounds pure.
How to introduce these rules into an existing solution without chaos
If your codebase already has cross-slice references, turning on strict slice isolation will fail instantly. That is fine, but you need a strategy to adopt it without stalling delivery.
A pragmatic approach is to start with a small set of hard rules that you do not compromise on, then expand over time.
Begin with the clean core rule, Domain must not depend on Infrastructure. This is usually achievable quickly and it stops the worst violations.
Then enforce that Shared cannot depend on Features and Infrastructure. This prevents escape hatches.
Then enable slice isolation gradually. You can do this by limiting the test to a subset of feature areas first, such as enforcing only within ClinicFlow.Features.Appointments.* until that area is clean, then expanding to other areas.
If you need that selective enforcement, you can filter sliceRoots before running the pairwise checks. Keep it temporary and tracked. Architecture tests should converge toward full enforcement, not become a permanent set of exceptions.
What NetArchTest cannot do and how you compensate
NetArchTest enforces compile time dependency structure. It does not understand runtime behaviour. It will not catch "we are coupled through a database table" or "we are coupled through message schemas" unless that coupling surfaces as code dependencies.
So you use NetArchTest for what it is excellent at, preventing direct type coupling across boundaries, stopping forbidden references, and enforcing structural conventions. You complement it with other guardrails, project reference restrictions, package boundaries, code review focus, and sometimes Roslyn analysers for rules that NetArchTest cannot express cleanly.
The net effect is that architecture becomes harder to accidentally violate than to follow. That is the goal.
Vertical slice architecture is not a folder structure. It is a dependency discipline. If slices can call each other freely, you have not built vertical slices, you have built a new naming scheme for a layered system and a few folders with opinions!
NetArchTest gives you a simple, mechanical enforcement tool. You define what a slice is in your solution, you codify what is allowed and forbidden, and you run those rules on every build. Over time, this prevents the exact kind of coupling that makes refactoring painful and delivery slow.





