- MSBuild Anti-Pattern Catalog
- A numbered catalog of common MSBuild anti-patterns. Each entry follows the format:
- Smell
-
- What to look for
- Why it's bad
-
- Impact on builds, maintainability, or correctness
- Fix
-
- Concrete transformation
- Use this catalog when scanning project files for improvements.
- AP-01:
- for Operations That Have Built-in Tasks
- Smell
- :
- ,
- ,
- Why it's bad
- Built-in tasks are cross-platform, support incremental build, emit structured logging, and handle errors consistently.
is opaque to MSBuild.
< Target Name = " PrepareOutput "
< Exec Command = " mkdir $(OutputPath)logs " /> < Exec Command = " copy config.json $(OutputPath) " /> < Exec Command = " del $(IntermediateOutputPath)*.tmp " /> </ Target
- <
- Target
- Name
- =
- "
- PrepareOutput
- "
- >
- <
- MakeDir
- Directories
- =
- "
- $(OutputPath)logs
- "
- />
- <
- Copy
- SourceFiles
- =
- "
- config.json
- "
- DestinationFolder
- =
- "
- $(OutputPath)
- "
- />
- <
- Delete
- Files
- =
- "
- @(TempFiles)
- "
- />
- </
- Target
- >
- Built-in task alternatives:
- Shell Command
- MSBuild Task
- mkdir
- copy
- /
- cp
- del
- /
- rm
- move
- /
- mv
- echo text > file
- touch
- xcopy /s
- with item globs
- AP-02: Unquoted Condition Expressions
- Smell
- :
- Condition="$(Foo) == Bar"
- — either side of a comparison is unquoted.
- Why it's bad
- If the property is empty or contains spaces/special characters, the condition evaluates incorrectly or throws a parse error. MSBuild requires single-quoted strings for reliable comparisons.
< PropertyGroup Condition = " $(Configuration) == Release "
< Optimize
true </ Optimize
</ PropertyGroup
- <
- PropertyGroup
- Condition
- =
- "
- '
- $(Configuration)' == 'Release'
- "
- >
- <
- Optimize
- >
- true
- </
- Optimize
- >
- </
- PropertyGroup
- >
- Rule
-
- Always quote
- both
- sides of
- ==
- and
- !=
- comparisons with single quotes.
- AP-03: Hardcoded Absolute Paths
- Smell
-
- Paths like
- C:\tools\
- ,
- D:\packages\
- ,
- /usr/local/bin/
- in project files.
- Why it's bad
- Breaks on other machines, CI environments, and other operating systems. Not relocatable.
< PropertyGroup
< ToolPath
C:\tools\mytool\mytool.exe </ ToolPath
</ PropertyGroup
< Import Project = " C:\repos\shared\common.props " />
- <
- PropertyGroup
- >
- <
- ToolPath
- >
- $(MSBuildThisFileDirectory)tools\mytool\mytool.exe
- </
- ToolPath
- >
- </
- PropertyGroup
- >
- <
- Import
- Project
- =
- "
- $(RepoRoot)eng\common.props
- "
- />
- Preferred path properties:
- Property
- Meaning
- $(MSBuildThisFileDirectory)
- Directory of the current .props/.targets file
- $(MSBuildProjectDirectory)
- Directory of the .csproj
- $([MSBuild]::GetDirectoryNameOfFileAbove(...))
- Walk up to find a marker file
- $([MSBuild]::NormalizePath(...))
- Combine and normalize path segments
- AP-04: Restating SDK Defaults
- Smell
-
- Properties set to values that the .NET SDK already provides by default.
- Why it's bad
- Adds noise, hides intentional overrides, and makes it harder to identify what's actually customized. When defaults change in newer SDKs, the redundant properties may silently pin old behavior.
< PropertyGroup
< OutputType
Library </ OutputType
< EnableDefaultItems
true </ EnableDefaultItems
< EnableDefaultCompileItems
true </ EnableDefaultCompileItems
< RootNamespace
MyLib </ RootNamespace
< AssemblyName
MyLib </ AssemblyName
< AppendTargetFrameworkToOutputPath
true </ AppendTargetFrameworkToOutputPath
</ PropertyGroup
- <
- PropertyGroup
- >
- <
- TargetFramework
- >
- net8.0
- </
- TargetFramework
- >
- </
- PropertyGroup
- >
- AP-05: Manual File Listing in SDK-Style Projects
- Smell
- :
- ,
- in SDK-style projects.
- Why it's bad
- SDK-style projects automatically glob */.cs (and other file types). Explicit listing is redundant, creates merge conflicts, and new files may be accidentally missed if not added to the list.
< ItemGroup
< Compile Include = " Program.cs " /> < Compile Include = " Services\MyService.cs " /> < Compile Include = " Models\User.cs " /> </ ItemGroup
- <
- ItemGroup
- >
- <
- Compile
- Remove
- =
- "
- LegacyCode**
- "
- />
- </
- ItemGroup
- >
- Exception
-
- Non-SDK-style (legacy) projects require explicit file includes. If migrating, see
- msbuild-modernization
- skill.
- AP-06: Using
- with HintPath for NuGet Packages
- Smell
- :
- Why it's bad
- This is the legacy packages.config pattern. It doesn't support transitive dependencies, version conflict resolution, or automatic restore. The packages/ folder must be committed or restored separately.
< ItemGroup
< Reference Include = " Newtonsoft.Json "
< HintPath
..\packages\Newtonsoft.Json.13.0.3\lib\netstandard2.0\Newtonsoft.Json.dll </ HintPath
</ Reference
</ ItemGroup
- <
- ItemGroup
- >
- <
- PackageReference
- Include
- =
- "
- Newtonsoft.Json
- "
- Version
- =
- "
- 13.0.3
- "
- />
- </
- ItemGroup
- >
- Note
- :
- without HintPath is still valid for .NET Framework GAC assemblies like
- WindowsBase
- ,
- PresentationCore
- , etc.
- AP-07: Missing
- PrivateAssets="all"
- on Analyzer/Tool Packages
- Smell
- :
- without
- PrivateAssets="all"
- .
- Why it's bad
-
- Without
- PrivateAssets="all"
- , analyzer and build-tool packages flow as transitive dependencies to consumers of your library. Consumers get unwanted analyzers or build-time tools they didn't ask for.
- See
- references/private-assets.md
- for BAD/GOOD examples and the full list of packages that need this.
- AP-08: Copy-Pasted Properties Across Multiple .csproj Files
- Smell
-
- The same
- block appears in 3+ project files.
- Why it's bad
- Maintenance burden — a change must be made in every file. Inconsistencies creep in over time.
< PropertyGroup
< LangVersion
latest </ LangVersion
< Nullable
enable </ Nullable
< TreatWarningsAsErrors
true </ TreatWarningsAsErrors
< ImplicitUsings
enable </ ImplicitUsings
</ PropertyGroup
- <
- Project
- >
- <
- PropertyGroup
- >
- <
- LangVersion
- >
- latest
- </
- LangVersion
- >
- <
- Nullable
- >
- enable
- </
- Nullable
- >
- <
- TreatWarningsAsErrors
- >
- true
- </
- TreatWarningsAsErrors
- >
- <
- ImplicitUsings
- >
- enable
- </
- ImplicitUsings
- >
- </
- PropertyGroup
- >
- </
- Project
- >
- See
- directory-build-organization
- skill for full guidance on structuring
- Directory.Build.props
- /
- Directory.Build.targets
- .
- AP-09: Scattered Package Versions Without Central Package Management
- Smell
- :
- with different versions of the same package across projects.
- Why it's bad
- Version drift — different projects use different versions of the same package, leading to runtime mismatches, unexpected behavior, or diamond dependency conflicts.
< PackageReference Include = " Newtonsoft.Json " Version = " 13.0.1 " />
- <
- PackageReference
- Include
- =
- "
- Newtonsoft.Json
- "
- Version
- =
- "
- 13.0.3
- "
- />
- Fix:
- Use Central Package Management. See
- https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management
- for details.
- AP-10: Monolithic Targets (Too Much in One Target)
- Smell
-
- A single
- with 50+ lines doing multiple unrelated things.
- Why it's bad
- Can't skip individual steps via incremental build, hard to debug, hard to extend, and the target name becomes meaningless.
< Target Name = " PrepareRelease " BeforeTargets = " Build "
< WriteLinesToFile File = " version.txt " Lines = " $(Version) " Overwrite = " true " /> < Copy SourceFiles = " LICENSE " DestinationFolder = " $(OutputPath) " /> < Exec Command = " signtool sign /f cert.pfx $(OutputPath)*.dll " /> < MakeDir Directories = " $(OutputPath)docs " /> Copy SourceFiles = " @(DocFiles) " DestinationFolder = " $(OutputPath)docs " /
</ Target
- <
- Target
- Name
- =
- "
- WriteVersionFile
- "
- BeforeTargets
- =
- "
- CoreCompile
- "
- Inputs
- =
- "
- $(MSBuildProjectFile)
- "
- Outputs
- =
- "
- $(IntermediateOutputPath)version.txt
- "
- >
- <
- WriteLinesToFile
- File
- =
- "
- $(IntermediateOutputPath)version.txt
- "
- Lines
- =
- "
- $(Version)
- "
- Overwrite
- =
- "
- true
- "
- />
- </
- Target
- >
- <
- Target
- Name
- =
- "
- CopyLicense
- "
- AfterTargets
- =
- "
- Build
- "
- >
- <
- Copy
- SourceFiles
- =
- "
- LICENSE
- "
- DestinationFolder
- =
- "
- $(OutputPath)
- "
- SkipUnchangedFiles
- =
- "
- true
- "
- />
- </
- Target
- >
- <
- Target
- Name
- =
- "
- SignAssemblies
- "
- AfterTargets
- =
- "
- Build
- "
- DependsOnTargets
- =
- "
- CopyLicense
- "
- Condition
- =
- "
- '
- $(SignAssemblies)' == 'true'
- "
- >
- <
- Exec
- Command
- =
- "
- signtool sign /f cert.pfx %(AssemblyFiles.Identity)
- "
- />
- </
- Target
- >
- AP-11: Custom Targets Missing
- Inputs
- and
- Outputs
- Smell
- :
- with no
- Inputs
- /
- Outputs
- attributes.
- Why it's bad
- The target runs on every build, even when nothing changed. This defeats incremental build and slows down no-op builds.
See
references/incremental-build-inputs-outputs.md
for BAD/GOOD examples and the full pattern including FileWrites registration.
See
incremental-build
skill for deep guidance on Inputs/Outputs, FileWrites, and up-to-date checks.
AP-12: Setting Defaults in .targets Instead of .props
Smell
:
with default values inside a .targets file. Why it's bad : .targets files are imported late (after project files). By the time they set defaults, other .targets files may have already used the empty/undefined value. .props files are imported early and are the correct place for defaults.
< PropertyGroup
< MyToolVersion
2.0 </ MyToolVersion
</ PropertyGroup
< Target Name = " RunMyTool "
< Exec Command = " mytool --version $(MyToolVersion) " /> </ Target
< PropertyGroup
< MyToolVersion Condition = " ' $(MyToolVersion)' == '' "
2.0 </ MyToolVersion
</ PropertyGroup
- <
- Target
- Name
- =
- "
- RunMyTool
- "
- >
- <
- Exec
- Command
- =
- "
- mytool --version $(MyToolVersion)
- "
- />
- </
- Target
- >
- Rule
- :
- .props
- = defaults and settings (evaluated early).
- .targets
- = build logic and targets (evaluated late).
- AP-13: Import Without
- Exists()
- Guard
- Smell
- :
- without a
- Condition="Exists('...')"
- check.
- Why it's bad
- If the file doesn't exist (not yet created, wrong path, deleted), the build fails with a confusing error. Optional imports should always be guarded.
< Import Project = " $(RepoRoot)eng\custom.props " />
< Import Project = " $(RepoRoot)eng\custom.props " Condition = " Exists('$(RepoRoot)eng\custom.props') " />
- <
- Project
- Sdk
- =
- "
- Microsoft.NET.Sdk
- "
- >
- Exception
-
- Imports that are
- required
- for the build to work correctly should fail fast — don't guard those. Guard imports that are optional or environment-specific (e.g., local developer overrides, CI-specific settings).
- AP-14: Using Backslashes in Paths (Cross-Platform Issue)
- Smell
- :
- with backslash separators in
- .props
- /
- .targets
- files meant to be cross-platform.
- Why it's bad
- Backslashes work on Windows but fail on Linux/macOS. MSBuild normalizes forward slashes on all platforms.
< Import Project = " $(RepoRoot)\eng\common.props " /> < Content Include = " assets\images** " />
- <
- Import
- Project
- =
- "
- $(RepoRoot)/eng/common.props
- "
- />
- <
- Content
- Include
- =
- "
- assets/images/**
- "
- />
- Note
- :
- $(MSBuildThisFileDirectory)
- already ends with a platform-appropriate separator, so
- $(MSBuildThisFileDirectory)tools/mytool
- works on both platforms.
- AP-15: Unconditional Property Override in Multiple Scopes
- Smell
-
- A property set unconditionally in both
- Directory.Build.props
- and a
- .csproj
- — last write wins silently.
- Why it's bad
- Hard to trace which value is actually used. Makes the build fragile and confusing for anyone reading the project files.
< PropertyGroup
< OutputPath
bin\custom\ </ OutputPath
</ PropertyGroup
< PropertyGroup
< OutputPath
bin\other\ </ OutputPath
</ PropertyGroup
< PropertyGroup
< OutputPath Condition = " ' $(OutputPath)' == '' "
bin\custom\ </ OutputPath
</ PropertyGroup
For additional anti-patterns (AP-16 through AP-21) and a quick-reference checklist, see additional-antipatterns.md .