How Assemblies Really Work
If you've ever wondered why .NET can load the right DLL version automatically or how reflection finds your types without config files, the answer lives in assembly architecture. Every .NET assembly carries detailed metadata about itself and its dependencies, making deployment smarter than copying files around.
When you compile a .NET project, the output isn't just machine code. It's a self-contained package with IL instructions, type definitions, resource files, and a manifest listing everything the assembly needs to run. This structure lets the runtime verify compatibility before executing a single line of your code.
You'll explore the four main parts of an assembly, see how strong naming prevents conflicts, and learn to inspect assemblies programmatically. By the end, you'll understand what makes .NET deployment more reliable than traditional native code.
The Four Parts of Every Assembly
An assembly contains a manifest, metadata tables, IL code, and optional resources. The manifest is like a table of contents listing the assembly's name, version, culture, public key token, referenced assemblies, and exported types. Without this manifest, the CLR can't verify if dependencies match or locate types correctly.
Metadata describes every type in the assembly down to individual method signatures and attributes. When you call Assembly.GetTypes(), the runtime reads these metadata tables. Reflection APIs, serializers, and ORM frameworks all depend on metadata to discover structure without hardcoded knowledge of your classes.
IL code is your compiled methods stored as platform-independent bytecode. The JIT compiler translates IL to native instructions when methods run for the first time. This two-step compilation lets .NET optimize for the actual CPU at runtime, unlike precompiled native executables locked to one architecture.
using System.Reflection;
var asm = typeof(string).Assembly;
var name = asm.GetName();
Console.WriteLine($"Assembly: {name.Name}");
Console.WriteLine($"Version: {name.Version}");
Console.WriteLine($"Culture: {name.CultureName ?? "neutral"}");
Console.WriteLine($"Public Key Token: {BitConverter.ToString(name.GetPublicKeyToken())}");
Console.WriteLine($"\nTotal Types: {asm.GetTypes().Length}");
Console.WriteLine($"Exported Types: {asm.GetExportedTypes().Length}");
This code reads the manifest of System.Private.CoreLib (where string lives) and displays identity information. The public key token proves this assembly was signed by Microsoft. You can run similar inspection on any assembly to see what it exposes publicly versus what stays internal.
Strong Names and Assembly Identity
A strong name combines the assembly's simple text name with version, culture, and a public key token. This guarantees uniqueness because only the holder of the private key can sign assemblies claiming that identity. Two assemblies can have the same text name but different public keys, and .NET treats them as completely distinct.
You create a strong name by signing the assembly with a key pair. The private key signs the manifest, and the public key gets embedded as the public key token. When the runtime loads the assembly, it verifies the signature hasn't been tampered with. This prevents someone from swapping a malicious DLL with the same filename.
// Generate key pair (run once)
// sn -k MyKey.snk
// MyLibrary.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<AssemblyName>MyCompany.MyLibrary</AssemblyName>
<SignAssembly>true</SignAssembly>
<AssemblyOriginatorKeyFile>MyKey.snk</AssemblyOriginatorKeyFile>
</PropertyGroup>
</Project>
After building, your assembly has a strong name. Other projects referencing it must use the full identity including the public key token. This prevents version conflicts when multiple apps on the same machine use different versions of your library.
How Assembly Versioning Works
Assembly version has four parts: major.minor.build.revision. The runtime uses this to enforce compatibility rules. By default, apps request the exact version they were compiled against. If you built against version 1.2.0.0 but only 1.3.0.0 is available, the runtime throws FileNotFoundException unless you add binding redirects.
Binding redirects tell the runtime to substitute one version for another. You typically add these in app config files or use central package management to prevent version conflicts. For strong-named assemblies, binding redirects need exact public key tokens to work.
<configuration>
<runtime>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="Newtonsoft.Json"
publicKeyToken="30ad4fe6b2a6aeed"
culture="neutral" />
<bindingRedirect oldVersion="0.0.0.0-13.0.0.0"
newVersion="13.0.3.0" />
</dependentAssembly>
</assemblyBinding>
</runtime>
</configuration>
This redirect tells the runtime to load version 13.0.3 whenever any code asks for Newtonsoft.Json versions 0.0.0.0 through 13.0.0.0. It solves the classic problem where different NuGet packages bring incompatible versions of the same dependency.
Build and Inspect Your Own Assembly
Create a small library and use reflection to inspect its manifest and metadata. You'll see firsthand how the CLR reads assembly information.
Instructions:
- dotnet new classlib -n ArchDemo.Lib
- cd ArchDemo.Lib
- Replace Class1.cs with the code below
- dotnet build
- dotnet new console -n ArchDemo.Inspector
- cd ../ArchDemo.Inspector && dotnet add reference ../ArchDemo.Lib/ArchDemo.Lib.csproj
- Replace Program.cs with the inspector code
- dotnet run
namespace ArchDemo.Lib;
public class MathHelper
{
public int Add(int a, int b) => a + b;
public int Subtract(int a, int b) => a - b;
}
public class StringHelper
{
public string Reverse(string input) =>
new string(input.Reverse().ToArray());
}
using System.Reflection;
using ArchDemo.Lib;
var asm = typeof(MathHelper).Assembly;
var name = asm.GetName();
Console.WriteLine("=== Assembly Identity ===");
Console.WriteLine($"Name: {name.Name}");
Console.WriteLine($"Version: {name.Version}");
Console.WriteLine($"Culture: {name.CultureName ?? "neutral"}");
Console.WriteLine("\n=== Exported Types ===");
foreach (var type in asm.GetExportedTypes())
{
Console.WriteLine($"Type: {type.Name}");
var methods = type.GetMethods(BindingFlags.Public |
BindingFlags.Instance | BindingFlags.DeclaredOnly);
foreach (var method in methods)
{
Console.WriteLine($" Method: {method.Name}({string.Join(", ", method.GetParameters().Select(p => p.ParameterType.Name))})");
}
}
Console.WriteLine("\n=== Referenced Assemblies ===");
foreach (var refAsm in asm.GetReferencedAssemblies())
{
Console.WriteLine($"{refAsm.Name} v{refAsm.Version}");
}
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
=== Assembly Identity ===
Name: ArchDemo.Lib
Version: 1.0.0.0
Culture: neutral
=== Exported Types ===
Type: MathHelper
Method: Add(Int32, Int32)
Method: Subtract(Int32, Int32)
Type: StringHelper
Method: Reverse(String)
=== Referenced Assemblies ===
System.Runtime v8.0.0.0
System.Linq v8.0.0.0
Gotchas to Watch For
Forgetting to sign assemblies consistently causes pain. If you sign your library with a strong name, every assembly that references it must also be strong-named. You can't mix signed and unsigned assemblies in the same dependency chain. This trips up developers who add strong naming midway through a project.
Version mismatches happen when binding redirects point to versions that don't exist. If you redirect to Newtonsoft.Json 13.0.3 but only 13.0.2 is deployed, the app crashes on startup. Always verify the target version is actually present in your output directory or NuGet cache.
Loading assemblies from byte arrays or streams bypasses normal probing. Assembly.Load(byte[]) loads the assembly but doesn't set its location property, breaking relative path dependencies. Use AssemblyLoadContext for better control when loading dynamically.
Ignoring culture in assembly names breaks localized apps. If your Spanish resources are in MyApp.resources.dll with culture "es", but your manifest doesn't specify the culture, the runtime won't find them. Always set the culture property in satellite assemblies.