Solution Filters (.slnf) in the CI Pipeline
Sometimes a solution contains a project that you want to keep for local development,
but that must not be part of the CI build. Classic example: a legacy or temporary
project that is only referenced during a migration, lives in a folder that is excluded via
.gitignore, and therefore never gets checked out on the build agent.
The moment your pipeline tries to restore the solution, it blows up:
1
2
error MSB3202: The project file
"...\_Temp_Legacy_Code\LegacyApp\LegacyApp.csproj" was not found.
This article explains why this happens, why “just don’t build it” does not help, and how
a solution filter file (.slnf) solves it cleanly - both locally and in CI.
The trap: restore does not care about build configuration
A very common first reaction is:
“I’ll just remove the project from the
Release|Any CPUbuild configuration so it doesn’t get built.”
That does not work. dotnet restore (and MSBuild /t:Restore) enumerates every
project listed in the solution to build the restore graph - completely independent of the
build configuration. So even a project that is “excluded from build” is still read during
restore. If its .csproj is physically missing on the agent (because the folder is
gitignored), restore fails with MSB3202 before any build-configuration filtering ever
happens.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Solution on the build agent
===========================
AppSuite.Clients.slnx
|
| restore enumerates ALL projects,
| regardless of Debug/Release config
v
+----------------------+ +----------------------+ +----------------------+
| App.Client.csproj | | App.Core.csproj | | LegacyApp.csproj |
| (checked out) OK | | (checked out) OK | | GITIGNORED ✗ 404 |
+----------------------+ +----------------------+ +----------------------+
|
v
error MSB3202: not found
So there are only really three ways out:
- Make the file exist on the agent (un-ignore it) - but then it gets restored/built, which is exactly what you wanted to avoid for a legacy/temp project.
- Remove it from the solution - but if you keep merging migration work that re-adds it, this fight never ends.
- Feed restore/build a filtered view of the solution that simply does not contain that
project. That is what a
.slnfis for.
What a solution filter (.slnf) is
A .slnf is a tiny JSON file that sits next to your solution and lists the subset of
projects that should be loaded / restored / built. It references the real solution file
and includes only what you want:
1
2
3
4
5
6
7
8
9
AppSuite.Clients.slnx AppSuite.Clients.slnf
(full solution) (filtered view for CI)
+---------------------------+ +---------------------------+
| App.Client | | App.Client |
| App.Core | -> | App.Core |
| App.Tests | | App.Tests |
| LegacyApp <-- temp | | (LegacyApp omitted) |
+---------------------------+ +---------------------------+
used locally used by the CI pipe
Key properties that make it a great fit:
- The solution file stays untouched. Locally you still open the full solution with the legacy project referenced - nothing breaks for developers.
- The filter is independent of the solution content. Even if a later merge re-adds the legacy project to the solution, it will never appear in the filter.
- Project-to-project references are still followed transitively. You only need to list the projects you actually care about; their dependencies are pulled in automatically. Since nothing references the omitted legacy project, it drops out cleanly.
The .slnf file format
The format is plain JSON. Paths are relative to the folder the .slnf lives in and use
backslashes:
1
2
3
4
5
6
7
8
9
10
11
12
{
"solution": {
"path": "AppSuite.Clients.slnx",
"projects": [
"App.Client\\App.Client.csproj",
"App.Client.Core\\App.Client.Core.csproj",
"..\\Core\\App.Core\\App.Core.csproj",
"..\\Core\\App.Grpc.Core\\App.Grpc.Core.csproj",
"..\\Core\\App.Client.Tests\\App.Client.Tests.csproj"
]
}
}
Note: the omitted legacy project (..\\Core\\_Temp_Legacy_Code\\LegacyApp\\LegacyApp.csproj)
is simply not in the list. That is the whole trick.
Compatibility: Solution filters work with the modern XML solution format (
.slnx) as well as with the classic.sln. Building.slnxand.slnffrom the command line is supported since the .NET SDK 9.0.200, and.slnxis the default solution format from .NET 10 onwards. If you migrate.sln->.slnx, point the"path"in your filter at the new.slnx.
Creating a .slnf
You do not have to hand-write it (although it is easy enough). Common ways:
- Visual Studio: right-click the projects you want to unload, unload them, then use
“Save As Solution Filter” on the solution node. VS writes the
.slnflisting the loaded projects. - CLI: start from the full solution and remove what you do not want, or just author the JSON by hand for full control.
Then verify it statically (no build needed) - valid JSON, all listed projects exist, and the unwanted one is absent:
1
2
3
4
5
$slnf = Get-Content .\AppSuite.Clients.slnf -Raw | ConvertFrom-Json
$slnf.solution.projects | ForEach-Object {
$exists = Test-Path (Join-Path (Get-Location) $_)
"{0,-6} {1}" -f $(if ($exists) {'OK'} else {'MISS'}), $_
}
Using it in the CI pipeline
The neat part: a .slnf can be handed to the exact same tasks you already use for a solution.
Just point your restore and build tasks at the filter instead of the solution.
Before - restoring the full solution (fails on the missing project)
1
2
3
variables:
- name: client-solution
value: '**/AppSuite.Clients.slnx' # includes the gitignored legacy project -> MSB3202
After - restoring the filtered view
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
variables:
- name: client-solution
value: '**/AppSuite.Clients.slnf' # legacy project is filtered out
steps:
# ==== Client Restore ====
- task: DotNetCoreCLI@2
displayName: 'Restore Client'
inputs:
command: restore
projects: '$(client-solution)'
feedsToUse: config
nugetConfigPath: NuGet.config
# ==== Client Build ====
- task: VSBuild@1
displayName: 'Build Client'
inputs:
solution: '$(client-solution)'
platform: '$(buildPlatform)'
configuration: '$(buildConfiguration)'
vsVersion: 'latest'
Both DotNetCoreCLI@2 (restore/build) and VSBuild@1 accept the .slnf transparently, so the
change is usually a one-line variable swap.
1
2
3
4
5
6
7
Pipeline flow
=============
checkout -> restore (*.slnf) -> build (*.slnf) -> test -> publish
| |
| +-- legacy project never seen
+-- restore graph built WITHOUT the legacy project -> no MSB3202
Trade-offs and gotchas
- Maintenance point: the filter is an explicit allow-list. When you add a new, real
project that CI must build, remember to add it to the
.slnftoo - otherwise it silently stays out of the CI build. - Transitive references only: a project reached only through a P2P reference of a listed project is included automatically. But if you want a project built as a root, list it explicitly.
- Not a build-config toggle: a
.slnfchanges which projects load, not how they are configured. It is the right tool precisely because it operates at the same stage as restore. - Keep the solution as the source of truth for humans: developers still open the full
.slnx. The.slnfis a CI/automation concern.
Conclusion
If your build fails during restore with MSB3202 because the solution references a project
that is not checked out (a gitignored legacy/temp project, a migration leftover, an optional
component), do not try to fix it via build configuration - restore ignores that.
Instead, drop a small solution filter next to the solution, list everything the CI needs,
leave the unwanted project out, and point your pipeline’s solution variable at the .slnf. The
full solution stays intact for local work, future merges cannot sneak the project back into the
build, and your pipeline goes green again.