What Actually Is a .NET Assembly? A Look Inside the File You Ship

Your compiler doesn't produce a program. It produces a description of one, thorough enough to reconstruct the source.
We throw the word around constantly — "reference the assembly," "the DLL is missing," "sign the assembly" — but most of us have never actually opened one and looked. When you press build on a C# project and a .dll or .exe drops into bin/, what is that file? It is not machine code your CPU can run. It's something stranger and, once you see it, far more interesting: a container holding intermediate language plus an unusually complete description of your program. This article is a tour of what's inside, because understanding the container explains a surprising number of things — from why reflection works to why, as I wrote in the companion piece on patching assemblies, you can never really hide logic you ship to a customer.
The one-sentence definition
An assembly is the unit of deployment, versioning, and identity in .NET. It's the smallest thing the runtime loads as a whole, the thing that carries a version number, and the thing a strong name or signature applies to. Usually it's a single .dll or .exe. Everything in this article lives inside that one file.
Layer 1: it's a Windows PE file (that lies a little)
Open a .NET assembly in a hex editor and the first two bytes are MZ — the same header DOS executables had in 1983. .NET assemblies are wrapped in the PE (Portable Executable) format, the standard Windows executable container. That's a deliberate compatibility trick: the OS recognizes the file as a normal executable.
But the PE wrapper is mostly a shell. A native .exe fills its PE sections with x86/x64 machine code; a .NET assembly's PE sections are nearly empty of native code. Instead there's a tiny native stub and a CLI header that points at the real payload: the managed content. The OS loads the file, sees the CLI header, and hands control to the .NET runtime instead of executing machine code directly. The PE format is the envelope; the letter inside is something else entirely.
Layer 2: the IL — your code, not compiled to the metal
Inside that managed payload is IL — Intermediate Language (also called CIL or MSIL). This is what C#, F#, and VB.NET all compile down to. It's a compact, stack-based instruction set for an abstract machine, not for any real CPU. A trivial return a + b becomes something like:
ldarg.0 // push argument a
ldarg.1 // push argument b
add // pop both, push the sum
ret // return the top of the stack
IL is CPU-agnostic on purpose — the same assembly runs on x64, Arm64, whatever, because it isn't targeting any of them yet. The translation to real machine code happens at runtime, method by method, by the JIT (Just-In-Time) compiler the first time each method is called. (Ahead-of-time options like ReadyToRun and Native AOT change this story, but classic .NET is JIT-first.)
The consequence that matters: because your logic ships as IL and IL maps cleanly back to language constructs, tools can decompile an assembly into very readable C#. Names, control flow, most structure — it survives. IL was designed to be described, verified, and re-JIT'd, and that same openness is why nothing you compile is ever truly opaque.
Layer 3: metadata — the part people underestimate
Here's the piece that makes .NET special. Alongside the IL, every assembly carries metadata: a set of relational tables describing everything the assembly defines and references. Every type, every method and its signature, every field, property, parameter, custom attribute, and every external type it depends on — all of it is recorded in structured tables, not comments, not debug info, but load-bearing data the runtime requires.
This metadata is why .NET feels the way it does:
- Reflection is just reading these tables at runtime.
typeof(Foo).GetMethods()is a query over metadata. - IntelliSense against a compiled DLL with no source works because the DLL fully describes its own public surface.
- Serializers, DI containers, ORMs, test runners all lean on metadata to discover and wire up types they were never told about at compile time.
A native .dll throws its type information away; the machine code doesn't need to know it once had a class called Invoice. A .NET assembly keeps all of it, because the runtime and the ecosystem are built to interrogate it. Self-description isn't a feature bolted on — it's the substrate.
Layer 4: the manifest — the assembly's ID card
One special slice of metadata is the manifest. It records the assembly's own identity and its bill of materials:
- the simple name (e.g.
MyApp.Core), - the version (
1.4.2.0), - the culture (for localized satellite assemblies),
- the public key / strong name token, if signed,
- the list of referenced assemblies with the versions it expects,
- and the files/resources it comprises.
The manifest is how the runtime answers "is this the assembly I'm looking for, and are its dependencies present?" It's also the thing binding redirects and version policy act on. When you get a FileLoadException about a version mismatch, it's the manifest's expectations meeting reality.
Identity and strong names
Because two different vendors can both ship a Utils.dll, an assembly can be given a strong name: a cryptographic identity built from a public/private key pair. A strong-named assembly's identity includes its public key token, so Utils.dll from you and Utils.dll from someone else are unambiguously different to the runtime, even with the same simple name.
It's worth being precise about what this does and doesn't buy you. A strong name establishes identity and lets the runtime detect casual tampering. It is not a robust anti-tamper mechanism — for an app loaded from a normal folder the signature generally isn't re-verified at load, and anyone can strip or replace it. Signing tells the world who an assembly claims to be; it doesn't lock the logic inside. That distinction is the whole subject of the patching article, and it starts right here in the manifest.
Seeing it yourself
None of this is theoretical — you can open the layers with tools you probably already have:
ildasm(ships with the .NET SDK / Windows SDK) disassembles an assembly to IL text and shows the manifest and metadata directly.- ILSpy and dnSpyEx are graphical decompilers: point them at any DLL and read the reconstructed C#, browse the metadata tree, and inspect the IL side by side.
dotnet-ildasmand Mono.Cecil let you read the same structures from code.
Point one of them at a DLL in your own bin/ folder and you'll see it plainly: the PE wrapper, the manifest with your version and references, the metadata tables listing every type you wrote, and under each method the IL that C# became.
Why the shape matters
Once you picture an assembly correctly — a PE envelope around IL and a complete, queryable description of your program — a lot of .NET stops being magic. Reflection, dynamic loading, plugin systems, serialization, and the whole "it just discovers your types" ecosystem all fall out of the same fact: the assembly describes itself thoroughly enough to be reconstructed. That's a gift when you're building tools around your code. It's a hard limit when you're trying to keep a secret inside it. Same structure, both consequences — and the next article is what happens when someone uses the second one against a license check.