Understanding Assembly Architecture in Modern .NET

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.

InspectAssembly.cs
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.

Create and Use Strong Name Key
// 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.

app.config - Binding Redirect
<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.

Metadata Tables and Type Discovery

Metadata tables are binary structures describing every type, method, field, property, and custom attribute in an assembly. The runtime uses these tables for JIT compilation, reflection, and type checking. When you call GetType() or use LINQ to query types, you're reading metadata.

Each table has rows for entities like TypeDef (type definitions), MethodDef (methods), and FieldDef (fields). Tables are cross-referenced by token values, creating a graph of relationships. This design makes metadata compact while still supporting complex inheritance hierarchies and generic types.

QueryMetadata.cs
using System.Reflection;

var asm = Assembly.Load("System.Linq");
var publicTypes = asm.GetExportedTypes()
    .Where(t => t.IsPublic && !t.IsNested)
    .OrderBy(t => t.Name)
    .Take(5);

foreach (var type in publicTypes)
{
    Console.WriteLine($"Type: {type.FullName}");
    Console.WriteLine($"  Methods: {type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly).Length}");
    Console.WriteLine($"  Properties: {type.GetProperties(BindingFlags.Public | BindingFlags.Instance).Length}");
    Console.WriteLine();
}

This reflection code loads System.Linq and queries its metadata for public types. It counts methods and properties without executing any LINQ code. Tools like code analyzers and documentation generators use similar metadata queries to understand codebases automatically.

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:

  1. dotnet new classlib -n ArchDemo.Lib
  2. cd ArchDemo.Lib
  3. Replace Class1.cs with the code below
  4. dotnet build
  5. dotnet new console -n ArchDemo.Inspector
  6. cd ../ArchDemo.Inspector && dotnet add reference ../ArchDemo.Lib/ArchDemo.Lib.csproj
  7. Replace Program.cs with the inspector code
  8. dotnet run
ArchDemo.Lib/Class1.cs
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());
}
ArchDemo.Inspector/Program.cs
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}");
}
ArchDemo.Lib.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>
Run result
=== 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.

Common Questions

What information does an assembly manifest contain?

The manifest holds assembly identity (name, version, culture), a list of files in the assembly, references to other assemblies with version requirements, and exported types. It's basically the assembly's table of contents that the CLR reads to verify dependencies and locate types.

How does the runtime use assembly metadata?

Metadata describes every type, method, property, and attribute in your assembly. The JIT compiler reads it to generate native code. Reflection uses it to discover types at runtime. Serializers and ORMs read it to understand your objects without hardcoded knowledge.

Why sign assemblies with strong names?

Strong naming guarantees assembly identity through a public/private key pair. It prevents version conflicts by making the public key part of the assembly's unique identity. You need it for GAC deployment or when building libraries that other strong-named assemblies will reference.

Can I inspect assembly metadata without running code?

Yes, use tools like ILSpy, dnSpy, or ildasm.exe to view IL code and metadata tables. Programmatically, System.Reflection.MetadataLoadContext lets you load assemblies for inspection only without executing any code. This is safe for analyzing untrusted assemblies.

Back to Articles