Posted On: 2025-05-05
C#'s package manager NuGet simplifies managing dependencies so much that it's hard to imagine how to work without it*. Yet there are plenty of wonderful libraries out there that don't have NuGet packages (or whose packages are not properly maintained.) Until recently, I assumed the only available solution to that situation was a manual process: download (and perhaps build) the dependency, directly include the dll in the project, and coordinate with everyone working on the project to do the same**. Fortunately, there's an alternative: using the build system alone, it's possible to accomplish many of the things that a package manager does (automated downloads, version tracking, etc.)
For the most part, I'll be structuring this post as a tutorial, walking through the indivdual steps to get the build system to automatically download a specific version of a dependency, as well as only download it in specific situations (ie. when it is missing.) As this is an example, all the sample code will reference a non-existent dependency (example.dll, version 1.0.0), so if you're looking to run the code as you follow along, you may need to do a couple substitutions.
C# project files (.csproj) are XML files that (essentially) describe how MSBuild should act when it performs a build. In many projects they only feature a handful of properties (ie. project name, debug vs release, etc.), but they are both flexible and powerful enough to define whole new build processes - including, as will be leveraged here, the ability to download and manipulate files.
Editing a project file can be a bit tricky (technically they're just text files, but some IDEs require that you "unload" them before you can edit it directly.) To minimize changes to the project file itself, it's possible to create a separate file that contains the bulk of the logic, and then import that into the project file:
<Import Project="dependency-resolution.targets" />
By convention, the ".targets" suffix is used to designate that the file includes actual build instructions (as opposed to only being a list of properties), but technically the file can have any name.
Much like a C# project file, a .targets file uses <Project>
as its root node, and has one or more <Target>
nodes that define the build actions.
A given target can run at a variety of different points in the build process (designated by theAfterTargets
property), and the built-in target "BeforeBuild" is commonly used for anything that must be completed before compiling any code:
<Target Name="ExampleDependency" AfterTargets="BeforeBuild">
When the target runs, it will perform all of its contained tasks in the order they are defined.
While it's possible to import and use third-party build tasks, the built-in tasks cover a wide range of operations, including (to my surprise), the <DownloadFile>
task, which can download arbitrary files from the internet.
Exactly how the DownloadFile task should be set up will depend greatly on how the dependency itself is configured. In its simplest form, it can be used to download a dll directly:
<DownloadFile SourceUrl="https://example.com/example-dependency/download/example-1.0.0.dll" DestinationFolder="$(OutputPath)" />
In more complex scenarios one might download other files, such as zip files or even source code. Such situations would call for various other tasks (unzipping, moving, compiling, etc.) that should be included after the DownloadFile task.
The DownloadFile task is smart enough that it will only execute if it doesn't already have that file. This is a handy default, but if additional processing is required
(Unzip/Move/etc.) that probably won't be enough. Fortunately, a Target can be set to execute only in specific situations using the Condition
property:
<Target Name="Example_Dependency" AfterTargets="BeforeBuild" Condition="!Exists('$(OutputPath)/final_path/example-1.0.0.dll')" />
This is a pretty simple "file exists" check, but conditionals are fairly powerful, and MSBuild has access to most (if not all) of the .Net code base, so there's certainly the potential to do some very elaborate validation here (ie. checking version numbers, comparing hashes, etc.)
Where the Target
node defines how to acquire the dependency, a Reference
is required in order to make the dependency available in the code. References can be added to the .targets file the same way that C# project files add them - by wrapping them in an ItemGroup
:
<ItemGroup>
<Reference Include="$(OutputPath)/example-1.0.0.dll" />
</ItemGroup>
While that's enough to get a simple example to work, the hardcoded paths and version numbers would present a maintenence problem in any real-world situation.
Fortunately, it's possible to add Properties to a .targets file, so that those hard-coded values are in one easy to maintain location*.
Properties, like in C# project files, are defined by a PropertyGroup
node, which then contains one or more nodes whose names are the property names and contents are the property values. For example:
<PropertyGroup>
<ExampleDownloadLocation>$(OutputPath)</ExampleDownloadLocation>
<ExampleVersion>1.0.0</ExampleVersion>
<ExampleFinalLocation>$(ExampleDownloadLocation)/example-$(ExampleVersion).dll</ExampleFinalLocation>
</PropertyGroup>
This defines three properties (ExampleDownloadLocation, ExampleVersion, and ExampleFinalLocation), complete with their values. Since properties (like targets) need to have unique names across the entire project (including any built-in properties), it's best to use the dependency name as a prefix or suffix.
Normally I'd wrap up with a complete example using all the code in this post, but in this case I think there's much more to be gained from a real world example. So, instead, I will recommend that you take a look at GodotInk's file. It's structured precisely how I've described here, and it makes extensive use of Properties to keep the file tidy.
While NuGet is still an excellent approach for managing third-party dependencies, the ability to automatically download dependencies using only the build system is enormously helpful. When working alone or in small teams, the time savings from using this approach may not be worth the effort (small teams can work through individual issues directly), but for a team of 30+ people across multiple timezones having this kind of automation in place can avoid so many problems. Perhaps the best use case, though, is precisely the way GodotInk uses it: as a plugin, its userbase could potentially scale infinitely, so automatically downloading its dependencies will eliminate a whole class of (user error-related) support requests.