AlgoMaster Logo

Assemblies

Medium Priority27 min readUpdated June 6, 2026

An assembly is the physical unit of code in .NET. Source files compile into IL, the compiler bundles that IL together with metadata and a manifest, and what comes out is either a .dll or an .exe on disk. That file is the assembly: it's what the runtime loads, what the version system tracks, what a project references when it depends on another project, and what eventually ships when you deploy. This chapter covers what an assembly contains, how .dll and .exe differ in modern .NET, how to build and reference assemblies with the dotnet CLI, how versioning works, and how to inspect or load assemblies at runtime.

Namespaces Are Logical, Assemblies Are Physical

A namespace is a name like ECommerce.Catalog.Products. It exists only as a label baked into type metadata, and the compiler uses it to disambiguate types that share short names. Two namespaces can live inside the same assembly, and a single namespace can be split across multiple assemblies. Nothing about a namespace tells the runtime where to find code.

An assembly is the opposite. It's a single file on disk that holds the actual IL for some set of types. The runtime can't load a namespace, it can only load an assembly. When code in your project says using ECommerce.Catalog;, the compiler turns that into a reference to the assembly that contains the ECommerce.Catalog.Products.Product type, and at run time the CLR finds and loads that assembly so the type is usable.

ConceptKindWhere it livesUsed for
NamespaceLogical nameInside type metadataAvoiding name collisions, grouping types by intent
AssemblyPhysical file.dll or .exe on diskLoading code, versioning, deployment, type resolution

A class library called ECommerce.Catalog.dll might define types in three namespaces: ECommerce.Catalog.Products, ECommerce.Catalog.Categories, and ECommerce.Catalog.Search. All three travel together in one file. A different library, ECommerce.Pricing.dll, might also define types under ECommerce.Catalog.Pricing. Same prefix in the namespace, different assembly on disk, no conflict.

This split is the reason you can move types between assemblies (refactor, split, merge) without renaming them, and the reason two assemblies are allowed to ship overlapping namespaces. The namespace is part of the type's identity; the assembly is part of the runtime's deployment story. They overlap a lot in practice, which is why folks sometimes use the words interchangeably, but the runtime cares about the difference.

What's Inside an Assembly

A .NET assembly is not a plain bag of compiled functions. It has four parts, all stored inside the same file in a format called PE (Portable Executable, the same container Windows uses for native EXEs and DLLs).

The diagram shows the four parts that always sit inside an assembly. The manifest is the assembly's identity card: its simple name (ECommerce.Catalog), version (1.0.0.0), culture (usually neutral), public key token if it's strong-named, and a list of every other assembly it references along with the versions of those references. The runtime reads the manifest first whenever it loads an assembly, because that's how it figures out what else it needs to find.

The type metadata is a structured table of every type the assembly defines: classes, structs, interfaces, enums, delegates, and the members on each (fields, properties, methods, events). It records names, signatures, attributes, generic parameters, accessibility, and inheritance. When the compiler builds your project and sees cart.GetTotal(), it doesn't store the call as a function pointer. It stores a reference to the GetTotal method in the metadata table, and the runtime resolves that reference at load time.

The IL code is the actual instructions for each method body, expressed in Common Intermediate Language. IL is a stack-based instruction set that the just-in-time compiler translates to native machine code when the method first runs. Your csc (or dotnet build) doesn't produce x86 or ARM code; it produces IL that the runtime can JIT on any platform.

The resources section is optional. It holds embedded files like icons, localized string tables, or any blob you marked as an embedded resource in the project file. Many assemblies have an empty resources section.

That layout is the same whether the file extension is .dll or .exe. The two extensions differ only in whether the file has an entry point and a small launcher; the parts inside are identical in structure.

DLL vs EXE: Two Physical Forms

A .NET assembly comes in two physical forms. A class library compiles to a .dll and has no entry point, so it can't be launched on its own. An executable application compiles to a .exe (on Windows) or an extensionless launcher (on Linux and macOS) and has a Main method or top-level statements that the runtime calls to start the program. Both are PE files, both contain IL and metadata, and the only structural difference is that one has the "this is the entry point" pointer in its header.

Modern .NET (Core 3.0 and later, including .NET 5 through .NET 8) handles the executable form slightly differently than the old .NET Framework did. When you build a console app called ECommerce.Shop on Windows, the output folder contains:

  • ECommerce.Shop.exe: a tiny native launcher (a few dozen kilobytes) that knows how to start the .NET runtime.
  • ECommerce.Shop.dll: the actual managed assembly with your IL and types.
  • ECommerce.Shop.pdb: the debug symbols (optional, but produced by default).
  • A bunch of .dll files for the runtime and any libraries you reference.

On Linux and macOS, the launcher is called ECommerce.Shop (no extension) instead of ECommerce.Shop.exe, but the rest is the same. The .dll is where your code actually lives, and the launcher's only job is to host the runtime and point it at that .dll's entry point.

FormExtension (Windows)Has entry pointCan run standaloneTypical use
Class library.dllNoNo, must be referencedReusable code: business logic, data access, shared utilities
Executable.exe (+ .dll)YesYes, via dotnet run or the launcherApps with a Main: console programs, web hosts, services

The old .NET Framework on Windows used to produce a single .exe that the OS could launch directly because the runtime was already installed system-wide. Modern .NET produces a separate launcher because the runtime ships per-app (or as a shared runtime selected at startup), and the launcher's job is to pick the right runtime, load it, and hand control to the managed .dll. The detail rarely matters until you look at the output folder and wonder why both files exist.

A library project never produces an entry-point file because there's nothing to launch. You can write any number of public types in a class library, and they all sit in the .dll waiting for some other assembly (usually an executable) to reference them and call into them.

Building a Class Library

The dotnet CLI is the standard way to create, build, and run .NET projects from the command line. A class library is the right starting point when you have code that should be reusable across multiple applications (a catalog of products, a pricing engine, an order workflow). You create one with dotnet new classlib:

That command produces a folder named ECommerce.Catalog/ containing two files:

The .csproj file is the project file, an XML document that describes how to build the project. The default for a class library looks like this:

The Sdk="Microsoft.NET.Sdk" attribute is what makes this a "library SDK" project. The same SDK handles both libraries and executables; the difference is in the OutputType setting, which defaults to Library and produces a .dll. If you wanted an executable, you'd either set <OutputType>Exe</OutputType> or use the console template (next section).

Delete Class1.cs and add a real type:

Build it:

The compiled assembly lives at bin/Debug/net8.0/ECommerce.Catalog.dll. That .dll is your assembly. The build also produces a .pdb file (debug symbols) and a .deps.json (a manifest of dependencies the runtime uses to resolve references at startup).

You cannot run a class library on its own. Try dotnet run inside the project folder and the CLI tells you so:

That's expected. A .dll is a passenger, not a driver. You need a .exe to do the driving.

Building a Console App

The console template creates an executable project that the runtime can actually launch. Create one next to the library:

This produces a folder with a .csproj and a Program.cs:

The .csproj for a console app sets OutputType to Exe:

That single <OutputType>Exe</OutputType> line is what makes the build produce a launcher .exe plus the managed .dll, instead of just a .dll.

The default Program.cs uses top-level statements:

Build it:

The output folder this time contains:

Run it with dotnet run from the project folder, or by invoking the launcher directly:

So far the shop project doesn't know anything about the catalog project. They're two separate folders, two separate assemblies, two separate worlds. The next step is to wire them together.

Referencing One Project from Another

A reference tells the compiler: "when I use a type from this other assembly, here's where to find it." Adding a reference to a project is one CLI command:

That command edits ECommerce.Shop.csproj and adds a <ProjectReference> element:

Now the shop project can use the Product class. Edit Program.cs:

Build and run:

When you ran dotnet build (which dotnet run does for you), the build system noticed the project reference and built the catalog first. It copied ECommerce.Catalog.dll into the shop's output folder, so at run time the launcher can find it sitting next to its own .dll:

The dependency graph for this two-project setup is straightforward:

The arrows in the diagram represent assembly references. The launcher (.exe) loads the managed .dll. The shop .dll references the catalog .dll and the base class library (System.Runtime and others, brought in implicitly by the SDK). The catalog .dll also references the base class library. The runtime walks this graph at load time, finding and loading each .dll on demand.

If you delete ECommerce.Catalog.dll from the output folder and try to run the shop, you get a runtime error:

That message is the manifest at work. The shop's manifest records that it references ECommerce.Catalog, Version=1.0.0.0, and the runtime tries to load exactly that. When it can't, it throws.

Assembly Versioning

Every assembly carries a version. The runtime uses it to record what the consumer was built against, and the manifest can be used to verify at load time that the actual file matches what was expected. The version is set in the .csproj file using a small family of properties:

Four different properties, each with a slightly different job:

PropertyFormatWhat it affects
VersionMajor.Minor.Patch (SemVer)Default for the next three when they aren't set; package version when packing a NuGet
AssemblyVersionMajor.Minor.Build.RevisionThe version baked into the manifest, used by the runtime for binding
FileVersionMajor.Minor.Build.RevisionThe Win32 "File version" property, shown by file explorers
InformationalVersionFree-form stringHuman-readable label, often includes a Git commit hash

If you set only <Version>2.3.1</Version>, the SDK fills in the other three for you with sensible defaults. AssemblyVersion and FileVersion become 2.3.1.0, and InformationalVersion becomes 2.3.1. That's enough for almost every application project.

The one that matters most at run time is AssemblyVersion. It goes into the manifest and shows up in references from consuming assemblies. The shop's manifest doesn't just say "I reference ECommerce.Catalog"; it says "I reference ECommerce.Catalog, Version=2.3.1.0."

You can confirm the assembly version of a built .dll from code:

In the .NET Framework days, the runtime would refuse to load an assembly whose AssemblyVersion didn't match what the consumer was built against, unless a binding redirect in the config said it was OK. Modern .NET (Core and later) is much more relaxed. By default, it does roll-forward on the patch number and accepts higher versions, which removes a whole class of "could not load file or assembly" errors that plagued the Framework era. The version still gets recorded; it just isn't used as a strict gate.

Don't change AssemblyVersion on every build when the library is consumed by other projects via direct file copies. Each version change invalidates older references and can force consumers to rebuild. For most application code, bump Version and let AssemblyVersion follow, but freeze AssemblyVersion on the major.minor when shipping a stable library.

AssemblyInfo: From a File to the Project File

If you've worked in older .NET Framework code, you may remember Properties/AssemblyInfo.cs, a generated file full of attributes like these:

Modern SDK-style projects don't ship that file anymore. The SDK generates an equivalent file automatically, reading values from .csproj properties:

.csproj propertyGenerated attribute
<AssemblyName>AssemblyTitle (defaults to project name)
<Description>AssemblyDescription
<Company>AssemblyCompany
<Product>AssemblyProduct
<Copyright>AssemblyCopyright
<AssemblyVersion>AssemblyVersion
<FileVersion>AssemblyFileVersion
<InformationalVersion>AssemblyInformationalVersion

The auto-generation happens during dotnet build and lives in obj/Debug/net8.0/ECommerce.Catalog.AssemblyInfo.cs. You don't normally read or edit it.

If you actually need to hand-author a custom assembly attribute that the SDK doesn't expose as a property, you can put it in any source file:

You can also disable the SDK's auto-generation by setting <GenerateAssemblyInfo>false</GenerateAssemblyInfo> in the .csproj and going back to a manual AssemblyInfo.cs. There's almost never a reason to do this in new code.

Strong Names and Signing

A strong name is a cryptographic identity for an assembly. Instead of identifying it by name and version alone, the assembly also carries a public key, and the actual file is signed with the matching private key. Consumers reference it by name + version + public key token (the last eight bytes of the SHA-1 hash of the public key), so the runtime can refuse to load a substitute file that happens to share the name.

Strong-naming was important in the .NET Framework era for two reasons. First, it was required to install an assembly in the Global Assembly Cache (the machine-wide shared bin folder for .NET Framework). Second, it was the main defense against a malicious DLL sneaking into your app by pretending to be a trusted library. The Framework runtime would catch the mismatch because the swapped file wouldn't be signed with the right key.

Modern .NET (Core 3.0 and later) doesn't have a GAC. Every app deploys its own .dll files into its own output folder, and there's no shared bin to install into. The deployment model itself solved most of what strong names were originally for, so strong-naming has dropped from "default" to "rare." Microsoft's own runtime assemblies are still strong-named (necessary for compatibility with older Framework code), and so are some popular libraries, but new application code almost never needs it.

If you do need to sign an assembly today, the process looks like this:

Add the key file to the project:

After dotnet build, the assembly is signed. You can verify with sn -v (the strong-name tool, shipped with the SDK):

Now any consumer that references this assembly records its public key token in their manifest, and the runtime checks the token at load time. A different .dll with the same name but a different key won't load.

The cost of strong-naming is friction. You have to keep the .snk file safe (it's effectively a signing secret), every consumer of the assembly also has to be strong-named in some scenarios, and any rename of the key invalidates every reference. For application code in modern .NET, the right answer is almost always to skip it. For library code that has to interop with strong-named consumers (some enterprise plugins, some Framework integration), it's still a real requirement.

Loading an Assembly at Runtime

Most assemblies the runtime loads come from the static references in the manifest: the compiler put them there, the runtime resolves them at startup, and nothing exotic happens. There are situations where code needs to load an assembly that wasn't known at compile time, for example a plugin system that scans a folder, picks up every .dll it finds, and discovers types implementing a known interface. The System.Reflection.Assembly class is the entry point.

Three loader methods cover most cases:

MethodWhen to use
Assembly.Load("ECommerce.Catalog")Load by simple name. Resolves through the same machinery the runtime uses for static references.
Assembly.LoadFrom("plugins/ECommerce.Promotions.dll")Load by file path. Pulls in the file and any of its dependencies that sit next to it.
Assembly.LoadFile("/abs/path/to/ECommerce.Promotions.dll")Load by absolute path, ignoring any context. Rarely used in app code; mostly used in tooling.

A minimal plugin loader looks like this:

Assuming the plugin assembly contains types ECommerce.Promotions.SeasonalDiscount and ECommerce.Promotions.BulkDiscount, the output is:

Assembly.GetTypes() returns every type defined in the assembly, regardless of accessibility (public, internal, nested). If you want only the public types, use Assembly.GetExportedTypes(). From there, real plugin systems usually filter to types that implement a known interface and create instances via Activator.CreateInstance(type).

The takeaway here is that the assembly file on disk is the unit of dynamic loading: you point the runtime at a .dll, it parses the manifest and metadata, and you get an Assembly object back that you can inspect and call into.

Assembly.GetTypes() walks every type in the metadata table. On a large assembly that's typically a sub-millisecond operation, but doing it in a hot loop is wasted work. Cache the result.

You can also inspect a built assembly without writing any code, using a decompiler or disassembler. Three options that come up most often:

  • `ildasm` (the IL disassembler) ships with Visual Studio on Windows. Open a .dll and you see the manifest, the metadata, and the IL for every method.
  • `dotnet-ildasm` is a cross-platform port of the same idea, installable as a global tool via dotnet tool install -g dotnet-ildasm.
  • dotPeek (JetBrains) and ILSpy (open source) are richer GUI decompilers that show readable C# alongside the IL.

Cracking open a built .dll with one of these tools is a good way to see what's actually inside an assembly: the manifest section lists the references, the type list mirrors the metadata, and every method body shows up as IL instructions. The first time you do it, the abstract "assembly contains IL and metadata" claim becomes very concrete.

Quiz

Assemblies Quiz

10 quizzes