Solutions versus projects, the classic .sln format, the XML .slnx format that is the default for new solutions in the .NET 10 SDK, migrating with dotnet sln migrate, and .slnf solution filters for large repos.
A solution is a list of projects that IDEs open together and the CLI can build together. Each project's build is still defined by its own project file. The classic .sln is a custom text format full of GUIDs and configuration mappings, so it's hard to read and often conflicts in merges. .slnx is a short XML format that just lists projects and folders. The .NET 10 SDK creates .slnx by default, and dotnet sln migrate converts an existing .sln. A solution filter (.slnf) opens only some of a solution's projects. That's useful in a big repo when a team works on a slice of it.
"A solution is a list of projects that IDEs open together and the CLI can build together, plus mappings from solution configurations to project configurations. It doesn't define how anything builds: that lives in each project file and in
Directory.Build.props, so every project should still build on its own.The classic
.slnis a custom text format full of GUIDs and per-configuration lines, so each new project adds lines in several places and two branches that add projects almost always conflict..slnxis a short XML format: one line per project, folders as elements, and standard mappings implied. The .NET 10 SDK creates.slnxby default withdotnet new sln,dotnet sln migrateconverts an existing file, anddotnet sln addworks with either.Before switching, I check every tool that reads solution files: current IDEs and the .NET SDK from 9.0.200 support
.slnx, but older scripts may not. The mistake I see is keeping both files, so people add projects to one but not the other. A solution filter, a.slnffile, opens just a subset of a solution's projects, which keeps a large monorepo fast in the IDE for each team. CI can build a solution, a filter, a single project or a traversal project, and I keep solutions thin: a view over projects, never the place where build behaviour lives."
A solution is an organising file. IDEs use it to know which projects to load, and dotnet build Orders.slnx builds every project in it. It also maps solution configurations (Debug/Release) to project configurations. It does not contain build logic; that's in the project files and Directory.Build.* files.
Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Orders.Api", "src\Orders.Api\Orders.Api.csproj", "{6A1C...}"
EndProject
Global
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{6A1C...}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
...
Every project adds several GUID lines, and adding two projects on two branches almost always conflicts.
<Solution>
<Folder Name="/src/">
<Project Path="src/Orders.Api/Orders.Api.csproj" />
<Project Path="src/Orders.Domain/Orders.Domain.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/Orders.Tests/Orders.Tests.csproj" />
</Folder>
</Solution>
Standard configuration mappings are implied, so only exceptions need writing down.
dotnet new sln creates a .slnx; dotnet new sln --format sln gives the classic format.dotnet sln Orders.sln migrate writes Orders.slnx next to it. Remove the old .sln once everything uses the new one..slnx. Check any other tool that parses solution files (older build scripts, some code-analysis tools) before you switch.A .slnf is a small JSON file that points to a solution and lists the projects to load:
{
"solution": {
"path": "Contoso.slnx",
"projects": [
"src\\Payments\\Payments.Api\\Payments.Api.csproj",
"src\\Payments\\Payments.Domain\\Payments.Domain.csproj",
"tests\\Payments.Tests\\Payments.Tests.csproj"
]
}
}
Teams open their filter instead of the 300-project solution, and the IDE loads in seconds. dotnet build Payments.slnf builds just that set. Visual Studio can create filters from Solution Explorer (unload projects, then save as a filter).
A monorepo's .sln with 180 projects caused a merge conflict in most pull requests that added a project. Migrating to .slnx cut the file from about 1,400 lines to 200 and conflicts almost stopped. Each team also got a .slnf, which brought Visual Studio load time from over a minute to under 15 seconds.
Checks practical knowledge of solution files and large-repo hygiene in a team setting.
Green flags: knows solutions don't define build logic; explains why .sln causes merge conflicts when many people add projects and how .slnx fixes that; knows .slnx is the default for new solutions in the .NET 10 SDK and when to keep .sln for compatibility; uses solution filters to keep large repos workable for each team.
Red flags: thinks the solution is required to build a project; has never heard of .slnx or .slnf; resolves solution conflicts by guesswork.
IDE / dotnet build
|
v
+-------------------------+ +----------------------+
| Contoso.slnx | | Payments.slnf |
| list of projects |<-------| subset of projects |
| folders, config maps | +----------------------+
+-------------------------+
|
+---------+----------+
v v v
Api.csproj Domain.csproj Tests.csproj <-- build logic lives here
\ | /
Directory.Build.props / .targets <-- and here
A solution answers two questions: which projects belong together, and which project configuration to build for each solution configuration (for example, solution Release|Any CPU builds every project as Release|AnyCPU). Everything else (target framework, packages, compiler settings) lives in project files.
The quick test: delete the solution, and every project should still build with dotnet build path/to/project.csproj. If it doesn't, build logic has leaked into the solution, usually through $(SolutionDir) (see section 6).
MSBuild doesn't understand solution files natively. When you run dotnet build Contoso.slnx (or .sln), it converts the solution into an in-memory metaproject: a generated MSBuild project with one MSBuild task call per project, passing the mapped Configuration and Platform.
Contoso.slnx --parse--> metaproject (in memory)
|
+-- MSBuild Api.csproj Configuration=Release
+-- MSBuild Domain.csproj Configuration=Release
+-- MSBuild Tests.csproj Configuration=Release
|
+-- ProjectReference --> built on demand
Set the environment variable MSBUILDEMITSOLUTION=1 to write the metaproject to disk as Contoso.slnx.metaproj and see exactly what it does. Build order comes from ProjectReference items, not from the order in the solution.
.sln |
.slnx |
|
|---|---|---|
| Format | Custom text format from 2002 | XML |
| Project entries | Type GUID, name, path and project GUID per project | <Project Path="..." /> |
| Configuration mappings | One line per project per configuration per platform | Implied for the standard case; only exceptions are written down |
| Solution folders | Fake "projects" with their own GUID and a NestedProjects section |
<Folder Name="/src/"> elements |
| Size, 100 projects | Often more than 1,000 lines | Roughly 100 to 150 lines |
| Merge conflicts | Very common when two branches add projects | Rare, and easy to resolve |
| Supported by | Every tool | .NET SDK 9.0.200 and later, MSBuild 17.13 and later, current Visual Studio, Rider and VS Code C# Dev Kit |
Default for dotnet new sln |
Up to the .NET 9 SDK | From the .NET 10 SDK |
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Orders.Api", "src\Orders.Api\Orders.Api.csproj", "{6A1C4F0E-...}"
EndProject
...
Global
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{6A1C4F0E-...}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6A1C4F0E-...}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6A1C4F0E-...}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6A1C4F0E-...}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{6A1C4F0E-...} = {2150E333-...}
EndGlobalSection
EndGlobal
The first GUID is the project type (C# SDK-style, in this case). The second is the project's own id. Each new project adds lines in three different places, which is why two branches that each add a project almost always conflict.
<Solution>
<Folder Name="/Solution Items/">
<File Path="Directory.Build.props" />
<File Path="global.json" />
</Folder>
<Folder Name="/src/">
<Project Path="src/Orders.Api/Orders.Api.csproj" />
<Project Path="src/Orders.Domain/Orders.Domain.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/Orders.Tests/Orders.Tests.csproj" />
</Folder>
</Solution>
No GUIDs, forward slashes in paths, and one line per project. Non-standard mappings, such as a project that should only build for one platform, are written as child elements of that <Project>.
.sln text. Check each supports .slnx.global.json).dotnet sln Contoso.sln migrate. It writes Contoso.slnx next to the original.dotnet build Contoso.slnx). Compare the list of projects with dotnet sln Contoso.slnx list.Contoso.sln in the same pull request, update CI paths and any .slnf files that point at the old name.{
"solution": {
"path": "Contoso.slnx",
"projects": [
"src\\Payments\\Payments.Api\\Payments.Api.csproj",
"src\\Payments\\Payments.Domain\\Payments.Domain.csproj",
"tests\\Payments.Tests\\Payments.Tests.csproj"
]
}
}
| Behaviour | Detail |
|---|---|
| In the IDE | Only the listed projects load. Others appear unloaded and cost nothing |
| With the CLI | dotnet build Payments.slnf builds the listed projects |
| Project references | MSBuild still builds projects the listed ones reference, even if they aren't in the filter. A filter limits what you open, not what the build needs |
| Creating one | In Visual Studio, unload the projects you don't need, then "Save As Solution Filter" from the solution's context menu; or write the JSON by hand |
| Path | solution.path is relative to the .slnf file, so keep filters next to the solution |
Filters are a developer convenience. CI usually still builds the full solution, or uses a traversal project, so nothing is missed.
When MSBuild builds through a solution, it sets properties such as SolutionDir, SolutionName and SolutionPath. When you build a single project, they are undefined (SolutionDir becomes *Undefined*).
<!-- Breaks when the project is built on its own -->
<Import Project="$(SolutionDir)build\common.props" />
Instead: locate shared files relative to the project, for example with $([MSBuild]::GetDirectoryNameOfFileAbove($(MSBuildThisFileDirectory), 'global.json')), or move the settings into Directory.Build.props, which MSBuild finds automatically.
Very large repos often stop relying on a hand-maintained solution in CI:
<!-- dirs.proj at the repo root -->
<Project Sdk="Microsoft.Build.Traversal">
<ItemGroup>
<ProjectReference Include="src/**/*.csproj" />
<ProjectReference Include="tests/**/*.csproj" />
</ItemGroup>
</Project>
Pin the SDK version in global.json under msbuild-sdks. dotnet build dirs.proj then builds every project found by the globs, so a new project can't be forgotten. Solutions and filters remain for the IDE.
| Option | Best for |
|---|---|
| One solution | Small and medium repos |
| Solution plus team filters | Large repos where teams own slices |
| Traversal project in CI, solutions or filters in the IDE | Monorepos with hundreds of projects |
.sln and .slnx produce the same metaproject.-m) than from the solution format.Expect .slnx to remain the default and more third-party tools to support it. Keep production repos on the .NET 10 SDK; the solution format needs no change for .NET 11.
dotnet new sln --format sln -n Demo, then add two projects with dotnet sln Demo.sln add.Demo.sln and count the lines and GUIDs.dotnet sln Demo.sln migrate and open Demo.slnx.dotnet build Demo.slnx and dotnet sln Demo.slnx list.MSBUILDEMITSOLUTION=1, build again and open the generated .metaproj file..slnf that lists one project which references the other, run dotnet build Demo.slnf, and notice the referenced project builds too.<Import Project="$(SolutionDir)x.props" Condition="Exists('$(SolutionDir)x.props')" /> to a project, then build the project alone and through the solution, and compare.# New solution (slnx by default in the .NET 10 SDK)
dotnet new sln -n Contoso
dotnet sln Contoso.slnx add src/Orders.Api/Orders.Api.csproj src/Orders.Domain/Orders.Domain.csproj
dotnet sln Contoso.slnx add --solution-folder tests tests/Orders.Tests/Orders.Tests.csproj
dotnet sln Contoso.slnx list
# Convert an existing classic solution
dotnet sln Legacy.sln migrate
# Build just a team's slice
dotnet build Payments.slnf -c Release
Mistake: putting build behaviour in the solution.
Relying on solution-only configuration mappings or $(SolutionDir) means a project builds differently on its own than in the solution, and breaks when a filter or a single-project build is used.
Instead: keep all build logic in project files and Directory.Build.props / .targets, and check that each project builds with dotnet build path/to/project.csproj.
Mistake: living with constant .sln merge conflicts.
Each project adds lines in several places (project entry, configuration mappings, nested folders), so two branches that each add a project almost always conflict. Resolving them by hand can silently drop a project or a mapping.
Instead: migrate to .slnx with dotnet sln <name>.sln migrate, where each project is one line.
Mistake: switching to .slnx without checking every tool.
An old script, an older IDE version or a third-party tool that parses .sln files fails, often only in CI or on one team's machines.
Instead: list every consumer of the solution file first, require an SDK of 9.0.200 or later in global.json, and keep .sln (dotnet new sln --format sln) for repos whose tools don't support .slnx yet.
Mistake: keeping both .sln and .slnx long-term.
People add projects to one file but not the other, so the IDE and CI build different sets of projects.
Instead: migrate in one pull request, delete the old .sln in that same pull request and update CI paths.
Mistake: opening a 300-project solution every day.
The IDE spends minutes loading and indexing projects you never touch, and analysis and IntelliSense slow down.
Instead: give each team a solution filter (.slnf) with the projects they work on, kept next to the solution.
Mistake: expecting a solution filter to limit what CI builds or tests. CI usually builds the full solution, and MSBuild also builds every project a filtered project references. Instead: treat filters as an IDE convenience. Decide what CI builds separately, with the full solution, a traversal project or explicit project paths.
Mistake: assuming the order of projects in the solution sets the build order.
MSBuild orders the build by ProjectReference dependencies. Projects that depend on each other only through the solution's build order, or through files copied between output folders, break with parallel builds.
Instead: express every real dependency as a ProjectReference.
Mistake: forgetting to add a new project to the solution.
The project builds when referenced, but its tests never run in CI, or it never appears in the IDE.
Instead: add projects with dotnet sln add, check dotnet sln list in review, or use a traversal project with globs in CI so new projects are picked up automatically.
A senior engineer keeps solutions thin: they're a view over projects, never the place where build behaviour lives. In large repos, they move to .slnx to remove a constant source of merge pain, give each team a solution filter, and consider a traversal project for CI so the build graph doesn't depend on a hand-maintained list. The migration is cheap but has to be coordinated: announce it, check every tool that reads solution files, migrate in one pull request and delete the old .sln.
An SDK-style project begins with <Project Sdk="Microsoft.NET.Sdk">. That one attribute pulls in all the standard build logic, so the file only holds what's specific to your project: the target framework, the output type, a few settings and its references. Source files are picked up automatically by globbing, so you don't list each .cs file. Packages are PackageReference items. Old-style .NET Framework project files listed every file, imported targets by hand and used packages.config. That made them long, noisy in merges and tied to Visual Studio on Windows.
I'd treat the build as a product, with a contract that lives in files at the repo root:
global.json for the SDK.Directory.Build.props/.targets for framework, nullable, analysers and output layout.Directory.Packages.props with transitive pinning for versions.nuget.config with source mapping, plus lock files..editorconfig for style and analyser severities.Project files stay tiny. A small platform group owns those files through CODEOWNERS, and there's a documented exception process for overrides and suppressions. CI uses shared templates that run exactly what developers run locally. The warning policy ratchets stricter over time. SDK, framework and package upgrades arrive as automated, grouped pull requests. I'd measure it all (build time, warnings, drift, time to patch) so governance helps teams instead of just blocking them.
The .NET CLI is the dotnet command that ships with the SDK. It runs apps and drives the SDK's build system, which is MSBuild underneath. dotnet new creates a project, solution or file from a template. dotnet build restores NuGet packages and compiles the project into bin/Debug/net10.0. dotnet run builds if needed and then runs the app, and anything after -- is passed to the app as arguments. dotnet clean deletes the outputs of a previous build. The same commands work on every OS and in CI, and IDEs call the same SDK underneath.
Last updated 1 Nov 2026.