Building Once, Using Everywhere
If you've ever copied the same utility classes into three different projects, you know the pain when a bug appears. Fix it in one place and the other two stay broken until you remember to update them. Assemblies solve this by packaging reusable code into DLLs you reference from multiple projects.
When you extract common logic into a shared assembly, every project gets bug fixes and improvements automatically when you update the package. Your authentication logic, logging helpers, or domain models live in one place. Changes propagate through a NuGet update instead of manual copying.
You'll learn how to structure assemblies for reuse, package them as NuGet packages for distribution, and deploy applications with their dependencies. By the end, you'll understand the tradeoffs between shared libraries and duplicated code.
Designing Shared Libraries
A shared library should have a clear, stable public API. Put interfaces and core types in one assembly, implementation details in another. This lets consumers depend on abstractions without coupling to specific implementations. Your logging library might expose ILogger while concrete loggers live in separate assemblies.
Keep dependencies minimal. Every package your shared library references becomes a transitive dependency for consumers. If your utility library references a massive ORM just to format one string, consumers pay the cost. Consider multi-targeting to support different frameworks when needed.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<PackageId>MyCompany.Core</PackageId>
<Version>2.1.0</Version>
<Authors>MyCompany</Authors>
<Description>Core utilities for MyCompany applications</Description>
</PropertyGroup>
<ItemGroup>
<!-- Keep dependencies minimal -->
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
</ItemGroup>
</Project>
This project file configures automatic NuGet package generation on build. The package includes only logging abstractions, not concrete implementations. Consumers can plug in their preferred logger without your library forcing a choice.
Creating and Publishing NuGet Packages
NuGet packages bundle your assembly, dependencies, and metadata into a single .nupkg file. dotnet pack creates the package from your csproj settings. You can publish to nuget.org for public consumption or internal feeds for private company libraries.
Semantic versioning tells consumers what changed. Increment the major version for breaking changes, minor for new features, patch for bug fixes. This lets projects lock to compatible versions while still getting updates that don't break their code.
# Create the package
dotnet pack --configuration Release
# Publish to NuGet.org (requires API key)
dotnet nuget push bin/Release/MyCompany.Core.2.1.0.nupkg \
--api-key YOUR_API_KEY \
--source https://api.nuget.org/v3/index.json
# Publish to internal feed
dotnet nuget push bin/Release/MyCompany.Core.2.1.0.nupkg \
--source https://pkgs.dev.azure.com/myorg/_packaging/internal/nuget/v3/index.json
Once published, other projects add your package with dotnet add package MyCompany.Core. NuGet handles downloading the DLL and its dependencies. Version updates flow through dotnet restore when you bump the version in the csproj.
Deployment Models for Applications
Framework-dependent deployment (FDD) publishes only your app DLLs. It assumes the target machine has the .NET runtime installed. This creates small deployment packages but requires matching runtime versions between dev and production.
Self-contained deployment (SCD) bundles everything including the runtime. Your app folder is huge but it runs on any machine without installing .NET first. This works great for desktop apps where you can't control what runtimes users have installed.
# Framework-dependent (small, requires runtime installed)
dotnet publish -c Release
# Self-contained for Linux x64 (includes runtime)
dotnet publish -c Release -r linux-x64 --self-contained
# Single-file executable (bundles everything into one file)
dotnet publish -c Release -r win-x64 \
--self-contained \
-p:PublishSingleFile=true
Single-file mode packs the entire app into one executable. It extracts dependencies to a temp folder on first run, so it's not truly portable but looks cleaner for distribution. Use trimming to reduce size by removing unused code from the BCL.
Managing Versions Across Projects
Use Directory.Build.props at your solution root to lock dependency versions across all projects. This prevents the nightmare where ProjectA uses Newtonsoft.Json 13.0.1 and ProjectB uses 13.0.3, causing runtime conflicts.
Central Package Management in .NET 7+ offers better control. Define package versions once in Directory.Packages.props. Projects reference packages by name only without versions, and MSBuild enforces consistency automatically.
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
<PackageVersion Include="Serilog" Version="3.1.1" />
<PackageVersion Include="FluentValidation" Version="11.9.0" />
</ItemGroup>
</Project>
<!-- In any project file -->
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" />
<PackageReference Include="Serilog" />
</ItemGroup>
Projects reference packages without version numbers. The central file controls versions. When you need to update, change one line in Directory.Packages.props and every project gets the new version on the next build.
Build and Package a Shared Library
Create a simple utility library, package it as NuGet, and consume it from another project. You'll see the full workflow from development to distribution.
Steps:
- dotnet new classlib -n UtilityLib
- cd UtilityLib && edit UtilityLib.csproj with package settings
- Replace Class1.cs with utility code
- dotnet pack
- cd .. && dotnet new console -n ConsumerApp
- cd ConsumerApp && dotnet add package UtilityLib
- Edit Program.cs to use the library
- dotnet run
namespace UtilityLib;
public static class StringHelpers
{
public static string Truncate(string value, int maxLength)
{
if (string.IsNullOrEmpty(value)) return value;
return value.Length <= maxLength ? value : value.Substring(0, maxLength) + "...";
}
public static string ToTitleCase(string value)
{
if (string.IsNullOrEmpty(value)) return value;
return System.Globalization.CultureInfo.CurrentCulture.TextInfo.ToTitleCase(value.ToLower());
}
}
using UtilityLib;
var longText = "This is a very long string that needs truncation";
var truncated = StringHelpers.Truncate(longText, 20);
Console.WriteLine(truncated);
var title = StringHelpers.ToTitleCase("hello world from shared library");
Console.WriteLine(title);
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<PackageId>UtilityLib</PackageId>
<Version>1.0.0</Version>
</PropertyGroup>
</Project>
This is a very long...
Hello World From Shared Library
When Not to Use Shared Assemblies
Shared assemblies add coupling. Every consumer inherits your breaking changes. If you're not ready to commit to API stability, duplication might be better. Three small apps can move faster with copied code than coordinating updates across teams.
Don't share assemblies when versioning becomes a burden. If different apps need different versions of the library and the versions conflict, you've made deployment harder. Feature flags or service boundaries might solve the problem better than a shared DLL.
Avoid premature abstraction. Building a shared library for two uses case
s often creates awkward APIs trying to serve both masters. Wait until you have three concrete uses before extracting. The patterns become clearer with more examples.