Project Structure ā 8 Projects, the .slnx Format, and What Goes Where
The exact project layout of a Clean Architecture .NET solution: what each of the 8 projects contains, why the .slnx format replaces .sln, and the conventions that keep the solution navigable.
The Full Directory Layout
SystemForge/
āāā SystemForge.slnx ā XML solution format (replaces .sln)
ā
āāā src/
ā āāā SystemForge.Domain/
ā ā āāā AssemblyReference.cs
ā ā āāā Entities/
ā ā ā āāā Patient.cs
ā ā āāā ValueObjects/
ā ā ā āāā PatientId.cs
ā ā āāā Events/
ā ā ā āāā PatientCreatedDomainEvent.cs
ā ā āāā SystemForge.Domain.csproj
ā ā
ā āāā SystemForge.Application/
ā ā āāā AssemblyReference.cs
ā ā āāā Patients/
ā ā ā āāā Commands/
ā ā ā ā āāā CreatePatient/
ā ā ā ā ā āāā CreatePatientCommand.cs
ā ā ā ā ā āāā CreatePatientCommandHandler.cs
ā ā ā ā ā āāā CreatePatientCommandValidator.cs
ā ā ā āāā Queries/
ā ā ā āāā GetPatient/
ā ā ā āāā GetPatientQuery.cs
ā ā ā āāā GetPatientQueryHandler.cs
ā ā ā āāā PatientResponse.cs
ā ā āāā Abstractions/
ā ā ā āāā IPatientRepository.cs
ā ā ā āāā IEmailService.cs
ā ā āāā Common/
ā ā ā āāā Result.cs
ā ā āāā SystemForge.Application.csproj
ā ā
ā āāā SystemForge.Infrastructure/
ā ā āāā AssemblyReference.cs
ā ā āāā Persistence/
ā ā ā āāā AppDbContext.cs
ā ā ā āāā Configurations/
ā ā ā ā āāā PatientConfiguration.cs
ā ā ā āāā Migrations/
ā ā āāā Repositories/
ā ā ā āāā PatientRepository.cs
ā ā āāā Services/
ā ā ā āāā EmailService.cs
ā ā āāā Identity/
ā ā ā āāā AppUser.cs
ā ā ā āāā TokenService.cs
ā ā āāā SystemForge.Infrastructure.csproj
ā ā
ā āāā SystemForge.Api/
ā ā āāā Controllers/
ā ā ā āāā PatientsController.cs
ā ā āāā Middleware/
ā ā ā āāā ExceptionHandlingMiddleware.cs
ā ā āāā Extensions/
ā ā ā āāā ServiceCollectionExtensions.cs
ā ā ā āāā WebApplicationExtensions.cs
ā ā āāā Program.cs
ā ā āāā SystemForge.Api.csproj
ā ā
ā āāā SystemForge.AppHost/
ā ā āāā Program.cs
ā ā āāā SystemForge.AppHost.csproj
ā ā
ā āāā SystemForge.ServiceDefaults/
ā āāā Extensions.cs
ā āāā SystemForge.ServiceDefaults.csproj
ā
āāā tests/
āāā SystemForge.Architecture.Tests/
ā āāā LayerDependencyTests.cs
ā āāā SystemForge.Architecture.Tests.csproj
āāā SystemForge.Application.UnitTests/
āāā Patients/
ā āāā CreatePatientCommandHandlerTests.cs
āāā SystemForge.Application.UnitTests.csprojThe .slnx Format
The .slnx format is a clean XML replacement for the legacy .sln text format introduced in .NET 9.
<!-- SystemForge.slnx -->
<Solution>
<Folder Name="/src/">
<Project Path="src/SystemForge.Domain/SystemForge.Domain.csproj" />
<Project Path="src/SystemForge.Application/SystemForge.Application.csproj" />
<Project Path="src/SystemForge.Infrastructure/SystemForge.Infrastructure.csproj" />
<Project Path="src/SystemForge.Api/SystemForge.Api.csproj" />
<Project Path="src/SystemForge.AppHost/SystemForge.AppHost.csproj" />
<Project Path="src/SystemForge.ServiceDefaults/SystemForge.ServiceDefaults.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/SystemForge.Architecture.Tests/SystemForge.Architecture.Tests.csproj" />
<Project Path="tests/SystemForge.Application.UnitTests/SystemForge.Application.UnitTests.csproj" />
</Folder>
</Solution>Advantages over .sln:
ā Human-readable XML (no GUID noise)
ā Diff-friendly ā merge conflicts are obvious
ā Sorted and consistent ā no tool-specific section ordering
ā Supported by Visual Studio 2022 17.10+, Rider, and `dotnet` CLIEach Project's Responsibilities
// āāā Domain āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
// What goes here: entities, value objects, domain events, domain exceptions
// What does NOT go here: anything with a NuGet dependency
// Domain/Entities/Patient.cs
public sealed class Patient
{
public PatientId Id { get; private set; }
public string Name { get; private set; }
public DateOnly DateOfBirth { get; private set; }
private readonly List<IDomainEvent> _domainEvents = [];
private Patient() { } // EF Core constructor
public static Patient Create(string name, DateOnly dob)
{
var patient = new Patient
{
Id = PatientId.New(),
Name = name,
DateOfBirth = dob,
};
patient._domainEvents.Add(new PatientCreatedDomainEvent(patient.Id));
return patient;
}
public IReadOnlyList<IDomainEvent> PopDomainEvents()
{
var events = _domainEvents.ToList();
_domainEvents.Clear();
return events;
}
}// āāā Application āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
// What goes here: command/query handlers, interfaces (IRepository, IEmailService),
// validators, response DTOs, Result<T>
// What does NOT go here: EF Core, SqlConnection, HttpClient, anything I/O
// Application/Abstractions/IPatientRepository.cs
public interface IPatientRepository
{
Task<Patient?> GetByIdAsync(PatientId id, CancellationToken ct);
Task AddAsync(Patient patient, CancellationToken ct);
Task<bool> ExistsByNameAsync(string name, CancellationToken ct);
}
// Application/Patients/Commands/CreatePatient/CreatePatientCommandHandler.cs
public sealed class CreatePatientCommandHandler
{
private readonly IPatientRepository _patients;
public CreatePatientCommandHandler(IPatientRepository patients)
=> _patients = patients;
public async Task<Result<PatientId>> Handle(
CreatePatientCommand command, CancellationToken ct)
{
if (await _patients.ExistsByNameAsync(command.Name, ct))
return Result.Failure<PatientId>(PatientErrors.NameTaken);
var patient = Patient.Create(command.Name, command.DateOfBirth);
await _patients.AddAsync(patient, ct);
return Result.Success(patient.Id);
}
}// āāā Infrastructure āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
// What goes here: EF Core DbContext, concrete repository implementations,
// external API clients, email/SMS, identity, Redis clients
// What does NOT go here: business logic
// Infrastructure/Persistence/PatientRepository.cs
public sealed class PatientRepository : IPatientRepository
{
private readonly AppDbContext _context;
public PatientRepository(AppDbContext context) => _context = context;
public async Task<Patient?> GetByIdAsync(PatientId id, CancellationToken ct)
=> await _context.Patients.FirstOrDefaultAsync(p => p.Id == id, ct);
public async Task AddAsync(Patient patient, CancellationToken ct)
=> await _context.Patients.AddAsync(patient, ct);
public async Task<bool> ExistsByNameAsync(string name, CancellationToken ct)
=> await _context.Patients.AnyAsync(p => p.Name == name, ct);
}// āāā Api āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
// What goes here: controllers/minimal API endpoints, middleware, DI wiring,
// request/response mapping, Program.cs
// What does NOT go here: business logic, direct DB access
// Api/Controllers/PatientsController.cs
[ApiController]
[Route("api/patients")]
public sealed class PatientsController : ControllerBase
{
private readonly CreatePatientCommandHandler _create;
private readonly GetPatientQueryHandler _get;
public PatientsController(
CreatePatientCommandHandler create,
GetPatientQueryHandler get)
{
_create = create;
_get = get;
}
[HttpPost]
public async Task<IActionResult> Create(
CreatePatientRequest request, CancellationToken ct)
{
var command = new CreatePatientCommand(request.Name, request.DateOfBirth);
var result = await _create.Handle(command, ct);
return result.Match<IActionResult>(
id => CreatedAtAction(nameof(Get), new { id }, id),
err => Problem(err.Description));
}
[HttpGet("{id}")]
public async Task<IActionResult> Get(Guid id, CancellationToken ct)
{
var query = new GetPatientQuery(new PatientId(id));
var result = await _get.Handle(query, ct);
return result.Match<IActionResult>(Ok, err => NotFound(err.Description));
}
}AssemblyReference Convention
Each project exposes a single empty class as an anchor for assembly discovery:
// Domain/AssemblyReference.cs
namespace SystemForge.Domain;
public static class AssemblyReference { }
// Application/AssemblyReference.cs
namespace SystemForge.Application;
public static class AssemblyReference { }This lets architecture tests, DI scanning, and EF Core migrations reference the assembly without knowing a specific type:
// Architecture.Tests/LayerDependencyTests.cs
var domainAssembly = typeof(Domain.AssemblyReference).Assembly;
// Infrastructure ā EF Core migration target
dotnet ef migrations add Init --project src/Infrastructure --startup-project src/ApiProject File Conventions
<!-- Domain.csproj ā minimal, no NuGet packages -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
</Project>
<!-- Application.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentValidation" Version="11.*" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Domain\SystemForge.Domain.csproj" />
</ItemGroup>
</Project>
TreatWarningsAsErrorsat the project level is the simplest way to maintain code quality ā it is more reliable than.editorconfigalone and is enforced at build time, not just in the IDE.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.