Project & Solution Structure
How .NET projects fit together -- SDK-style .csproj, project vs package references, solution organization, Directory.Build.props, and central package management
Project & Solution Structure
Before you write a line of business logic, you make a set of decisions that quietly govern how a codebase ages: how it is carved into projects, how those projects reference each other, and where shared build settings live. C# engineers who only ever touch one file at a time tend to treat the .csproj as opaque XML that Visual Studio manages for them. The engineers who can keep a large solution healthy understand that the project file is the build -- it is MSBuild's input, and almost everything about how your code compiles, what it depends on, and what it ships is expressed there.
The SDK-Style Project
A modern .csproj is an MSBuild file, but the SDK-style format made it dramatically smaller than the legacy one. The old format listed every source file by hand; the SDK format globs them in automatically, so a project file is now mostly a short declaration of intent. The first line -- <Project Sdk="Microsoft.NET.Sdk"> -- pulls in a whole set of default targets and conventions, and from there you declare the framework you target and the dependencies you take.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>Microsoft.NET.Sdk is the SDK for class libraries and console apps; Microsoft.NET.Sdk.Web adds the web-specific targets for ASP.NET Core, and Microsoft.NET.Sdk.Worker does the same for hosted services. The properties you set here -- Nullable, LangVersion, TargetFramework, TreatWarningsAsErrors -- are the dials that control the compiler for that project, and the single most important habit is to set them deliberately rather than accept whatever a template scaffolded years ago.
Project References vs Package References
A project takes on two fundamentally different kinds of dependency, and conflating them is a common source of confusion. A <PackageReference> pulls a compiled artifact from NuGet -- a versioned, published library you do not build. A <ProjectReference> points at another .csproj in your own solution that the build compiles from source alongside the referencing project.
<ItemGroup>
<PackageReference Include="Serilog" Version="4.1.0" />
<ProjectReference Include="..\MyApp.Domain\MyApp.Domain.csproj" />
</ItemGroup>The distinction matters because project references define the dependency graph of your own code, and that graph is where architecture lives or dies. When MyApp.Api references MyApp.Domain, the compiler enforces that the API can see the domain -- and, just as importantly, that the domain cannot see the API, because there is no reference in that direction. This is how layering stops being a diagram on a wall and becomes a rule the build checks. References are also transitive: if the API references Application and Application references Domain, the API can use Domain types without referencing it directly. Most of the time that is convenient; occasionally it leaks a dependency you wanted to keep contained, which is exactly the kind of thing a deliberate project structure exists to prevent.
The Solution
A solution (.sln, or the newer .slnx XML format) is just a list of the projects that build and open together, plus some grouping into solution folders. It carries no compilation semantics of its own -- the projects and their references do the real work -- but it is how humans and tooling navigate a multi-project codebase. A typical layered backend separates concerns into projects whose references only ever point inward:
MyApp.Domain-- entities and domain logic, referencing nothing of your ownMyApp.Application-- use cases and orchestration, referencing DomainMyApp.Infrastructure-- EF Core, external clients, referencing Application and DomainMyApp.Api-- the ASP.NET Core host, referencing Application and InfrastructureMyApp.Domain.Testsand friends -- test projects referencing what they exercise
The shape mirrors the clean architecture dependency rule: dependencies point toward the domain, never away from it. Whether that many projects is the right call is a real trade-off. More projects give you compile-checked boundaries and parallel builds, but each one adds build overhead and ceremony. The healthy default is to split by genuine architectural boundary, not by reflex -- a project per layer earns its keep; a project per feature folder usually does not.
Directory.Build.props: Settings in One Place
Once you have more than a couple of projects, copying the same <Nullable>enable</Nullable> and <LangVersion> into every .csproj becomes both tedious and a source of drift. MSBuild solves this with Directory.Build.props: drop one file at the repository root and MSBuild automatically imports it into every project beneath it, before the project's own contents. It is the canonical place for settings that should be uniform across the whole codebase.
<!-- Directory.Build.props at the repo root -->
<Project>
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
</PropertyGroup>
</Project>Its sibling Directory.Build.targets does the same for build targets rather than properties, and is imported after each project, which makes it the place for shared build steps. The mental model is simple and worth internalizing: set a policy once at the root, override it in an individual project only when you genuinely must, and you have eliminated an entire category of "this project is configured differently and nobody knows why" bugs.
Central Package Management
The version-drift problem repeats for NuGet packages: in a large solution, ten projects each pin Serilog and they slowly diverge until you have three versions of the same library in one build. Central Package Management (CPM) fixes this. You add a Directory.Packages.props at the root that declares every package version once, and the individual <PackageReference> entries drop their Version attribute entirely.
<!-- Directory.Packages.props -->
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Serilog" Version="4.1.0" />
</ItemGroup>
</Project>Now every project that references Serilog gets exactly the version declared centrally, upgrades happen in one place, and the build fails if a project tries to specify a version locally. For any solution past a handful of projects, this is the difference between a coherent dependency set and a slow-motion versioning mess.
The NZ Angle
The enterprise and government systems that dominate New Zealand's C# market are rarely a single tidy project. They are large solutions -- often dozens of projects, sometimes inherited from the .NET Framework era and only partway through modernization. The engineer who understands the project graph is the one who can answer the questions that actually matter on these codebases: why does this assembly end up in the deployment when nothing seems to use it, why did touching a shared library trigger a rebuild of half the solution, and how do we stop the domain layer from quietly taking a dependency on the web framework. That is structural knowledge, and on a sprawling legacy solution it is worth more than fluency in any single language feature.