Understanding Assemblies in Modern .NET
Myth: Assemblies are just DLLs you copy around. Reality: assemblies are the foundation of how .NET organizes, versions, and loads your code at runtime.
When you build a .NET project, the compiler creates an assembly containing your compiled types plus metadata describing everything inside. This metadata lets the runtime discover types, check versions, and resolve dependencies without you manually tracking every file. Assemblies turn messy file management into a structured deployment model.
You'll see how to split applications into logical assemblies, manage references between them, and avoid common versioning headaches. By the end, you'll understand when splitting makes sense and when it just adds complexity.
What .NET Assemblies Actually Contain
An assembly is a package with four main parts. It has compiled IL code (your classes and methods), type metadata describing every public and internal type, a manifest listing dependencies and versions, and resources like images or localization files. The manifest is what makes assemblies self-describing.
When you reference an assembly, .NET reads the manifest to verify compatibility. If your app needs version 2.0 of a library but finds version 1.5, the runtime catches this before running any code. This beats the old Windows DLL hell where you'd get cryptic crashes from version mismatches.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<AssemblyName>MyCompany.Core</AssemblyName>
<Version>1.2.0</Version>
<Description>Core business logic for MyCompany apps</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
</Project>
This project file tells MSBuild to create an assembly named MyCompany.Core.dll with version 1.2.0. The manifest will list the dependency on Newtonsoft.Json 13.0.3. When another project references MyCompany.Core, .NET knows exactly which NuGet packages to restore.
Structuring Multi-Assembly Solutions
Splitting code into assemblies creates boundaries. You might have MyApp.Core for business logic, MyApp.Data for database access, and MyApp.Web for the API layer. Each assembly can version independently and teams can work in parallel without stepping on each other.
The key is making dependencies flow in one direction. Your web layer references your core layer, but core never references web. This keeps your business logic testable without dragging in HTTP dependencies. Break this rule and you'll end up with circular references that won't compile.
MyApp/
├── MyApp.Core/ (business logic, domain models)
│ └── MyApp.Core.csproj
├── MyApp.Data/ (EF Core, repositories)
│ ├── MyApp.Data.csproj
│ └── References: MyApp.Core
├── MyApp.Services/ (application services)
│ ├── MyApp.Services.csproj
│ └── References: MyApp.Core, MyApp.Data
└── MyApp.Web/ (ASP.NET Core API)
├── MyApp.Web.csproj
└── References: MyApp.Services
Notice how references point downward. Web depends on Services, which depends on Data and Core. Core has zero project references, making it the most stable layer. When you change Core, you might need to update every other assembly. When you change Web, nothing else cares.
Mistakes to Avoid with Assemblies
Creating too many assemblies makes builds slow and deployments messy. Every assembly adds overhead for loading and JIT compilation. If you have 50 tiny assemblies when 5 would work, you're making your app start slower for no gain. Start with fewer assemblies and split only when you have a clear reason.
Circular references happen when Assembly A needs types from Assembly B and B needs types from A. The compiler rejects this immediately. Fix it by extracting shared types into a third assembly that both A and B reference, or rethink your layering so dependencies flow one way.
Version mismatches cause runtime failures. Your app references LibraryX version 2.0, but a NuGet package you installed brings LibraryX version 1.5. At runtime, .NET picks one version and the loser throws FileNotFoundException or MissingMethodException. Use a Directory.Build.props file to lock versions across all projects in your solution.
Forgetting to mark types as public means other assemblies can't see them. Internal types are visible only within their assembly. If you split a class into a new assembly and forget to change internal to public, you'll get compile errors in projects that were working fine before. This trips up developers new to multi-assembly solutions.
How .NET Loads Assemblies at Runtime
When your app starts, .NET loads the entry assembly (your .exe). As your code runs and hits types from other assemblies, the runtime loads those assemblies on demand. This lazy loading speeds up startup by not loading everything at once.
The runtime searches for assemblies in a specific order. It checks the app's base directory first, then probes subdirectories. For strong-named assemblies, it also checks the Global Assembly Cache (GAC). You can customize this with app configuration files, but the defaults work for most scenarios.
using System.Reflection;
var myAssembly = Assembly.GetExecutingAssembly();
Console.WriteLine($"Assembly: {myAssembly.FullName}");
Console.WriteLine($"Location: {myAssembly.Location}");
Console.WriteLine($"Version: {myAssembly.GetName().Version}");
foreach (var refAssembly in myAssembly.GetReferencedAssemblies())
{
Console.WriteLine($"References: {refAssembly.Name} v{refAssembly.Version}");
}
Assembly: MyApp, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
Location: /home/user/MyApp/bin/Debug/net8.0/MyApp.dll
Version: 1.0.0.0
References: System.Runtime v8.0.0.0
References: MyApp.Core v1.2.0.0
This reflection code shows which assemblies are loaded and where they came from. It's handy when debugging deployment issues or tracking down why the wrong version loaded. The runtime does most of this work automatically, but knowing how it works helps when things break.
Try It Yourself
Build a simple multi-assembly application to see how references and dependencies work in practice. You'll create a core library and a console app that uses it.
Steps:
- dotnet new classlib -n AssemblyDemo.Core
- dotnet new console -n AssemblyDemo.App
- cd AssemblyDemo.App && dotnet add reference ../AssemblyDemo.Core/AssemblyDemo.Core.csproj
- Update AssemblyDemo.Core/Class1.cs with the code below
- Update AssemblyDemo.App/Program.cs with the second code snippet
- cd AssemblyDemo.App && dotnet run
namespace AssemblyDemo.Core;
public class Calculator
{
public int Add(int a, int b) => a + b;
public int Multiply(int a, int b) => a * b;
}
using AssemblyDemo.Core;
using System.Reflection;
var calc = new Calculator();
Console.WriteLine($"5 + 3 = {calc.Add(5, 3)}");
Console.WriteLine($"4 * 7 = {calc.Multiply(4, 7)}");
var coreAssembly = typeof(Calculator).Assembly;
Console.WriteLine($"\nUsing assembly: {coreAssembly.GetName().Name}");
Console.WriteLine($"From: {coreAssembly.Location}");
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
5 + 3 = 8
4 * 7 = 28
Using assembly: AssemblyDemo.Core
From: /home/user/AssemblyDemo.App/bin/Debug/net8.0/AssemblyDemo.Core.dll
Choosing the Right Assembly Strategy
Choose single-assembly deployment when you value simplicity and fast builds. All your code compiles into one DLL, making deployment a matter of copying one file. This works great for small to medium apps where team boundaries aren't rigid. You avoid versioning conflicts because everything updates together.
Choose multi-assembly design when you need independent versioning, clear layer boundaries, or code reuse across multiple applications. A shared core assembly can serve both a web API and a background worker without duplicating logic. The cost is complexity in managing references and ensuring compatible versions across assemblies.
If you're unsure, start with one assembly per logical layer. Put domain models and business logic in MyApp.Core, data access in MyApp.Data, and web code in MyApp.Web. This gives you three assemblies with clear responsibilities. You can always consolidate later if the boundaries feel artificial.
For libraries you plan to publish as NuGet packages, multi-assembly design lets consumers pull only what they need. Your logging library might have MyLib.Logging.Core with interfaces and MyLib.Logging.Serilog with a specific implementation. Apps that want a different logger take just Core and skip the Serilog package. This reduces dependency bloat.