Most .NET developers can write correct C# for a decade without ever needing to know how the CLR turns a .dll into running code. Senior, lead and architect interviews probe exactly that gap, because the engineers who understand the execution model make better calls under pressure: they know why a TypeLoadException appears somewhere unexpected, why one deployment model starts in milliseconds and another in seconds, and why "value types live on the stack" is a half-truth that causes real bugs. This page works through the CLR's architecture the way a staff-level interview actually does — from the moment you type dotnet app.dll down to how a single virtual call resolves to an address in memory — with the reasoning an interviewer is listening for at each step, not just the vocabulary.
Q1 Walk through exactly what happens when you run dotnet app.dll on a machine with the shared runtime installed.#
Short answer: The dotnet executable is a generic muxer that locates hostfxr, which reads app.runtimeconfig.json to pick a runtime version and hands off to hostpolicy, which reads app.deps.json to resolve every managed and native dependency into a trusted platform assembly list, loads coreclr, and only then does the CLR initialize the GC, the type system and JIT the managed entry point.
There are three hosting components, each with a narrow job. hostfxr is a resolver: it never runs managed code, it just decides which shared framework (Microsoft.NETCore.App, plus Microsoft.AspNetCore.App if referenced) and which installed runtime version satisfies the app's roll-forward policy, covered in depth in Assembly Loading and Versioning Interview Questions. hostpolicy then does the heavier lifting: it parses .deps.json, builds the trusted platform assemblies (TPA) list that becomes the default AssemblyLoadContext's probing path, and locates the native coreclr library for the resolved version. Only at that point does coreclr.dll/libcoreclr.so actually load into the process, initialize the garbage collector and the base type system, and start executing. The very first managed frame is not your Main method directly — the runtime JITs and calls a small bootstrap that sets up the default AssemblyLoadContext, then JITs your entry point on first use, tier by tier, as described in JIT, Tiered Compilation and AOT Interview Questions. Publishing a self-contained apphost changes only the entry point (a native apphost binary embeds the app path so hostfxr doesn't need dotnet on the PATH); the resolution and load sequence after that point is identical.
What interviewers look for: whether you can name the three hosting layers and what each one reads, not just "it starts the runtime." Candidates who can explain why a missing .deps.json entry fails before a single line of Main runs demonstrate they understand this is resolution, then loading, then execution — three distinct phases with distinct failure modes.
Common mistakes: conflating dotnet run (which also invokes the SDK's build/restore machinery) with dotnet app.dll (pure execution of an already-built output), and assuming the apphost is a different runtime rather than a renamed entry point into the same hostfxr/hostpolicy/coreclr chain.
Q2 What is the relationship between IL, metadata and the tokens embedded in compiled code?#
Short answer: A compiler emits two parallel streams into the same PE file — common intermediate language (CIL) instructions and metadata tables/heaps describing every type, member and reference — and every non-trivial CIL instruction references the metadata through a four-byte token instead of embedding names or addresses directly.
Metadata is what makes a .NET assembly self-describing: there is no separate IDL or header file, because the compiler writes the type's shape (base type, interfaces, members, custom attributes) into metadata tables, and strings live in a separate string heap that table rows point into rather than duplicate. A metadata token is structurally simple: its top byte identifies which table it points into (for example 0x06 for MethodDef, 0x01 for TypeRef), and the remaining three bytes are a row index. When the JIT compiles a call instruction like call int32 MyApp::Add(int32,int32), the IL only carries the token 0x06000003; the JIT resolves that token against the MethodDef table to get the method's relative virtual address, signature and flags, and only then can it finish generating native code for the call site.
// This C# call compiles to a `call` instruction whose operand is a MethodDef
// or MemberRef token — never the method's name or address directly.
var total = Calculator.Add(10, 20);This is also why tools like ildasm, dotnet-ildasm or the ECMA-335-based System.Reflection.Metadata APIs can reconstruct a complete, language-neutral picture of an assembly without executing it: they walk the same tables the runtime walks.
What interviewers look for: understanding that metadata isn't documentation bolted onto IL — it's load-bearing, and the JIT cannot finish compiling a method until every token it references resolves.
Follow-up questions:
- Why does a malformed metadata token cause a load-time failure rather than a runtime exception?
- What's the difference between a
MethodDefand aMemberReftoken?
Q3 What are the Common Type System (CTS) and Common Language Specification (CLS), and why does the distinction matter in practice?#
Short answer: The CTS is the full set of types and type-construction rules the CLR understands (classes, interfaces, value types, delegates, generics, pointers); the CLS is a smaller, stricter subset of those rules that a type must follow if it wants to be safely consumable from any CLS-compliant language, not just the one it was written in.
The CTS is why a C# class can inherit from a VB.NET base class, implement an F# interface, and be consumed by a PowerShell script without any of them agreeing on syntax — they all target the same underlying type model, and the CLR's metadata format captures it precisely enough for any compiler to read. The CLS exists because the CTS is intentionally permissive: it allows things that not every .NET language supports well, such as unsigned integers (missing from some older CLS-targeting languages), operator overloading, or case-sensitive member names that collide when a case-insensitive language calls them. Marking an assembly or type with [assembly: CLSCompliant(true)] tells the compiler to flag CTS-legal constructs that aren't CLS-safe, which matters most for library authors who cannot control which language their consumers use.
[assembly: CLSCompliant(true)]
// Flagged by the compiler under CLS compliance: unsigned types aren't
// guaranteed to exist in every CLS-consuming language.
public class Counter
{
public uint Increment(uint by) => by + 1; // CS3001-style CLS warning
}What interviewers look for: recognizing that CTS is about the runtime's capabilities and CLS is about a voluntary, narrower contract for cross-language library design — most application code never needs CLS compliance at all.
Common mistakes: treating CLS compliance as a runtime-enforced rule rather than a compile-time, opt-in analyzer check that only matters for publicly exposed library surface.
Q4 Describe what happens between referencing a type by a TypeRef token and having a fully usable MethodTable for it.#
Short answer: The type loader doesn't load a type atomically; it walks the type through a sequence of load levels — from an approximate, dependency-free shell up through CLASS_LOADED — specifically so that mutually dependent types (a base class generic over its own derived type, for example) can be constructed without deadlocking on each other.
Loading starts by resolving the token to a scope (a Module) and, for a TypeRef, chasing it to the assembly and TypeDef that actually declares the type. The loader then builds the MethodTable and its associated EEClass incrementally: an early phase fills in only what doesn't require any other type to be loaded, later phases resolve the approximate parent and interfaces, and only the final phase (PushFinalLevels) can safely assume the whole graph is consistent. This matters for a case every senior engineer eventually hits: a class A<T> : C<B<T>> and class B<T> : C<A<T>> pair is perfectly legal and loadable, but only because the loader tolerates a half-loaded "approximate" view of each type while resolving the other. Generic sharing is layered on top of this: every reference-type instantiation of a generic (List<string>, List<object>) shares one canonical EEClass and its MethodDescs, because all references are pointer-sized, while each value-type instantiation (List<int>, List<Guid>) gets its own unshared MethodTable, because the field layout genuinely differs — this is why typeof(List<int>).TypeHandle and typeof(List<long>).TypeHandle are backed by distinct native data even though the IL is identical.
What interviewers look for: awareness that type loading is staged for a structural reason (breaking circular dependencies), and that this staging is also the mechanism behind generic code sharing — a connection most mid-level engineers have never had to make.
Follow-up questions:
- Why can two closed generic types over different value types never share JIT-compiled code, while two over different reference types can?
- What is
System.__Canonand where does it show up in a debugger?
Q5 AppDomains are gone from modern .NET. What replaced them, and where does the replacement fall short?#
Short answer: System.Runtime.Loader.AssemblyLoadContext (ALC) replaced AppDomains as the unit of isolation and unloading, but it trades AppDomain's forced isolation (separate security context, marshaled cross-domain calls, guaranteed unload by aborting threads) for cooperative isolation: types are only kept apart by not being looked up by name across contexts, and unloading only completes once nothing — including a single stray stack slot — still references the ALC.
Every process has an implicit default AssemblyLoadContext that the host populates at startup via default probing; you create additional ALCs explicitly, typically to load plugin assemblies whose dependencies might conflict with the host's own. Two ALCs can each load a different version — or even a bit-identical copy — of the same assembly simultaneously, and the CLR treats a type as identical to another only if it comes from the exact same loaded Assembly instance, so a Foo.Bar loaded into ALC #1 is never assignable to a Foo.Bar loaded into ALC #2, even though the fully qualified name and the source code are byte-for-byte identical. Unloading a collectible ALC only initiates teardown; it finishes asynchronously once the GC proves nothing still roots any type, instance or the AssemblyLoadContext object itself from outside the context — which is why forgetting to null out a field on your own AssemblyLoadContext subclass, or leaving a Thread running code from inside it, silently pins the whole plugin in memory forever. AppDomains never had this failure mode because unloading them was forced, not negotiated.
What interviewers look for: the specific trade-off — ALC gives you isolation and unloading without the marshaling overhead and single-process-wide-security-context baggage of AppDomains, but unloading is now a debugging problem you own, not a guarantee the runtime gives you.
Common mistakes: claiming ALC unloading is guaranteed or synchronous; assuming type identity is based on assembly name and version rather than the concrete loaded Assembly instance.
Q6 Compare CoreCLR, Mono and NativeAOT as .NET execution models. When do you actually choose each?#
Short answer: CoreCLR is the full JIT-based runtime used by ASP.NET Core, desktop and most server workloads; Mono is the runtime built for constrained and sandboxed targets — Android, iOS/Mac Catalyst, and Blazor WebAssembly — where a full JIT is unavailable or undesirable; NativeAOT skips a runtime-hosted JIT entirely and ahead-of-time compiles the whole application closure into one self-contained native binary for the fastest possible startup and smallest footprint.
CoreCLR is optimized for throughput and flexibility: it gives you tiered compilation with Dynamic PGO, the full reflection and System.Reflection.Emit surface, unrestricted dynamic assembly loading, and the choice of workstation, server or background GC discussed in Garbage Collector Interview Questions. Mono runs the same C# language and largely the same BCL, but on platforms that forbid JIT compilation at the OS level (iOS) it ships as a fully AOT-compiled or interpreter-driven runtime instead, and its WebAssembly build compiles managed code down to WASM (optionally with an interpreter for code paths that don't AOT cleanly) so a browser tab can run C# with no server round-trip. NativeAOT is not "CoreCLR compiled early" — it statically links a minimal native runtime with your application's entire reachable code, resolved and compiled by the ilc compiler at publish time, which means no runtime JIT, no arbitrary Assembly.LoadFrom, and no unconstrained Reflection.Emit, in exchange for cold starts measured in single-digit milliseconds and a memory footprint that beats even ReadyToRun. The practical decision: pick CoreCLR by default, reach for NativeAOT when startup latency and density are the dominant cost (serverless functions, CLI tools, sidecars, high-replica-count microservices), and understand Mono is effectively chosen for you the moment you target a mobile app store or the browser.
What interviewers look for: that you see these as three different points on a flexibility-versus-startup-cost curve rather than three competing "engines" for the same job, and that you know NativeAOT's restrictions (no dynamic loading, heavy trimming) are inherent to the model, not bugs to work around.
Q7 How does the CLR dispatch a virtual method call versus an interface method call? Why are they implemented differently?#
Short answer: A virtual instance method call resolves through a classic v-table: the MethodTable holds a slot array, and the JIT emits code that indexes into it directly, because the set of virtual slots for a concrete type is fixed and known at JIT time. An interface call instead goes through virtual stub dispatch (VSD) — a chain of generated stubs keyed by a <token, type> pair — because the same interface slot can map to a completely different implementation slot depending on which unrelated class implements it.
The v-table works for override because inheritance is a single linear chain: slot 4 in a derived class's table is unambiguously "the same virtual method" as slot 4 in the base, just possibly overridden. Interfaces break that assumption — two unrelated classes can both implement IShape, placing Area() at completely different slot numbers in their own tables — so a fixed offset can't work. VSD instead starts every interface call site with a lookup stub that consults a generic resolver and caches a dispatch stub: a tiny, inlined "does the object's MethodTable match the one I last saw? if so, jump to the cached target" check, which is fast as long as the call site is monomorphic. If the same call site sees enough different types, the runtime "demotes" it to a resolve stub that hashes into a global <token, type> → target cache instead of repeatedly failing a single-type check. This is exactly why megamorphic interface call sites (a Dictionary<Type, IHandler> dispatch loop with dozens of handler types) are measurably slower than a monomorphic one, and why the JIT's guarded devirtualization — covered in JIT, Tiered Compilation and AOT Interview Questions — tries to convert a hot, effectively-monomorphic interface call back into a direct, inlinable call guarded by a cheap type check.
What interviewers look for: the "why," not just the "what" — that interfaces need a different mechanism because their slot numbers aren't stable across unrelated implementers, and that stub dispatch is itself an adaptive, self-optimizing system (lookup → dispatch → resolve) rather than one fixed strategy.
Common mistakes: assuming all polymorphic calls go through the same mechanism, or that interface dispatch is simply "slower vtable lookup" rather than a structurally different technique.
Q8 "Value types live on the stack, reference types live on the heap." What's wrong with that statement?#
Short answer: It confuses a common implementation detail with a rule: a value type lives wherever the variable holding it lives, and that variable can be a local (usually stack-resident), a field of a heap-allocated object (heap-resident), an array element (heap-resident), a captured closure variable or an async state machine field (heap-resident), or a register — the CLR specification says nothing about stack versus heap at all.
The stack-allocation you observe for simple locals is a JIT implementation choice, not a language guarantee, and it disappears the moment a struct stops being a self-contained local: a struct Point field inside a class Shape is allocated inline as part of Shape's heap block; a struct captured by a lambda that becomes a closure is promoted into the compiler-generated closure class on the heap; a struct inside an async method that survives an await is lifted into the heap-allocated state machine. Conversely, "reference types always heap-allocate" is also not absolute: escape analysis, covered under object stack allocation research and used more aggressively as the JIT matures each release, can prove a small, non-escaping object never needs heap residency and keep it entirely in registers or on the stack — invisible to your code either way. The one thing that is true and worth stating precisely: assigning a value type never copies a reference, it copies the bits of the value itself, which is the real, load-bearing distinction — not where the bytes physically sit.
struct Point { public int X, Y; }
class Container
{
public Point P; // lives inline inside whatever holds the Container instance
}
var c = new Container(); // Container itself is heap-allocated
var p2 = c.P; // copies the Point's bits; p2 is independent of c.P
p2.X = 99; // does not affect c.P.XWhat interviewers look for: whether you reach for "copy semantics versus reference semantics" as the real distinction instead of stack versus heap, since that's the version of the answer that survives every counter-example an interviewer throws at it.
Follow-up questions:
- Where does a
structlocal captured by an iterator method actually live? - Can the JIT stack-allocate a class instance? Under what conditions?
Q9 What is a MethodDesc, and how does the runtime get from "this method has never run" to "this method has native code" the first time it's called?#
Short answer: Every method has a compact MethodDesc — as small as eight bytes for a simple case — that owns its entry point slot; before any code exists, that slot points at a tiny generated stub called a precode, which on first invocation jumps into a PreStub that triggers JIT compilation and then atomically rewrites the slot to point at the real native code, so every call after the first goes straight there.
This indirection exists because eagerly JITting every method reachable from Main — including ones a given run never actually calls — would waste real startup time and memory; instead, each method gets a cheap temporary entry point (the precode) that is far smaller than a compiled method body, and the expensive step (JIT compilation) is deferred until the method is provably needed. Once compiled, the stable entry point never moves again for that method's lifetime, which is a deliberate invariant: other threads may already have cached the old slot value, and rewriting it in place (rather than reassigning a separate lookup) keeps calling code lock-free. This same precode mechanism doubles as an efficient wrapper for P/Invoke marshaling stubs and delegate Invoke/BeginInvoke implementations, letting many methods share one hand-written worker routine behind individually tiny precodes. It also explains why the first call to any method is measurably slower than the rest, independent of tiered compilation entirely — tiering is about upgrading already-jitted Tier 0 code to Tier 1 later, while the precode/PreStub sequence is about getting any code at all for the very first call.
What interviewers look for: distinguishing "a method has no code yet" (precode/PreStub, a one-time event per method) from "a method has low-quality code that will be replaced" (tiered compilation, an ongoing policy) — conflating the two is one of the most common gaps at the senior level.
Q10 A TypeLoadException is thrown from code wrapped in a try/catch for exactly that exception — and the catch block doesn't run. How?#
Short answer: If the type failure occurs while the JIT is compiling the very method that contains the try/catch, the exception is raised before that method has executed a single instruction — including entering its own try block — so it propagates to whichever caller's invocation triggered that method to be JITted in the first place.
Type and member references used inside a method body are typically resolved during JIT compilation of that method, not lazily at the moment each instruction executes; the JIT needs to know a type's layout, or a callee's signature, to generate correct code around it. If CreateClass() contains return new MyClass(); inside a try block that catches TypeLoadException, and MyClass fails to load — say a referenced assembly was rebuilt and the type moved — the load failure surfaces while the JIT is still building the native code for CreateClass itself, which means the try block hasn't been entered yet; there is no active exception handler to catch anything, because CreateClass hasn't started running. The exception instead unwinds to whatever call site caused CreateClass to be JITted for the first time, which could be several frames away, or could even be masked by inlining so it's not obvious which method's compilation actually triggered it. This is a genuinely surprising, production-relevant gotcha: the fix isn't a try/catch around the risky construction call, it's ensuring the referenced type is loadable before you ever reach code that could JIT a method depending on it, or isolating that dependency behind a boundary (a plugin AssemblyLoadContext, discussed in Assembly Loading and Versioning Interview Questions) where a load failure can be handled deliberately.
What interviewers look for: recognition that JIT-time resolution, not just runtime execution, can throw — this question filters for engineers who've actually debugged a "the catch block never ran" report rather than only read about the CLR.
Common mistakes: assuming all exceptions originate from executing IL inside the method that appears in the stack trace, rather than from compiling it.
Quick-Fire Round#
| Question | Answer |
|---|---|
What resolves the runtime version before coreclr even loads? | hostfxr, by reading runtimeconfig.json. |
| What file drives the trusted platform assembly list? | app.deps.json, read by hostpolicy. |
| What replaced AppDomains for isolation in modern .NET? | AssemblyLoadContext. |
| Is ALC unloading forced or cooperative? | Cooperative — it completes only once nothing references it. |
| What mechanism handles virtual instance method calls? | A fixed-offset v-table slot on the MethodTable. |
| What mechanism handles interface method calls? | Virtual stub dispatch: lookup, dispatch and resolve stubs. |
| Does a value type always live on the stack? | No — it lives wherever its containing variable lives. |
| What triggers a method's very first JIT compilation? | The precode's PreStub on first invocation. |
| Which runtime model AOT-compiles the whole app with no JIT? | NativeAOT. |
| Which runtime model targets iOS, Android and WebAssembly? | Mono. |
How to Prepare#
- Trace the exact sequence from
dotnet app.dllto yourMainmethod's first instruction, naming every component involved. - Practice explaining type loading's staged load levels using a mutually-recursive generic type as the example.
- Be ready to justify, with the underlying mechanism, why interface dispatch and virtual dispatch aren't the same code path.
- Prepare a concrete example of a value type that ends up heap-allocated despite "structs are stack types" intuition.
- Rehearse the AppDomain-to-ALC trade-off in one paragraph: what isolation you kept, and what guarantee you gave up.
- Know one real debugging story involving
TypeLoadException, plugin isolation or a load-order surprise — interviewers weight lived experience heavily here.