Assembly loading is where a lot of theoretical CLR knowledge meets genuinely painful production reality — the FileNotFoundException that only reproduces on the build server, the plugin that silently won't unload, the "why is this method returning the wrong overload" bug that turns out to be two copies of the same assembly loaded side by side. Interviewers ask about this area specifically because it rewards engineers who've actually debugged a load failure rather than just read the happy path, and because it's one of the clearest places where .NET Framework habits actively mislead you about how modern .NET behaves. This page covers probing and deps.json, AssemblyLoadContext isolation and unloading, diamond dependencies, strong naming, runtimeconfig.json and roll-forward policy with the precision a senior or architect loop expects.

Q1 Walk through exactly how the runtime resolves and loads an assembly the first time your code references a type in it.#

Short answer: Resolution happens against whichever AssemblyLoadContext (ALC) loaded the referencing assembly — the "active" ALC — which checks its own cache first, then gives its own Load override a chance to supply the assembly, then (if it's the default ALC) runs default probing against deps.json and the app's directories, and only if all of that fails does it raise the Resolving event and finally the legacy AppDomain.AssemblyResolve event as a last resort.

This order matters because it explains two things engineers hit constantly: why overriding AssemblyLoadContext.Load in a custom context lets you redirect or veto a load before the default probing logic ever runs, and why a Resolving event handler is a genuine last-resort escape hatch rather than the primary loading mechanism — if default probing already found the assembly, your handler never fires at all. The timing of when this triggers is deliberately unspecified for static references: a compiler-inserted reference to a type in another assembly gets loaded lazily, and exactly when varies with inlining and JIT scheduling, which is why "the assembly isn't loaded until the type is actually touched" is a more reliable mental model than "assemblies load when the process starts." Once an assembly is resolved into a given ALC's cache, every subsequent request for that simple name in that same ALC returns the cached instance rather than re-resolving — this cache-by-name behavior is also exactly why an ALC can only ever hold one version of a given assembly name at a time.

What interviewers look for: the specific ordering (cache, then Load override, then default probing, then the Resolving event, then the legacy AppDomain event) — vague answers like "it searches the bin folder" miss that this is a layered, overridable algorithm, not a fixed folder scan.

Q2 What is deps.json, and what actually breaks if it's missing or wrong for a framework-dependent deployment?#

Short answer: deps.json is the manifest the SDK generates at build/publish time listing every managed and native dependency your app needs, including transitive ones; hostpolicy reads it to build the trusted platform assemblies (TPA) list that becomes the default ALC's probing path, so a missing or incorrect entry doesn't cause a slow fallback — it causes a hard FileNotFoundException for that specific assembly the first time something tries to load it, often deep in a call stack that gives no hint the file was ever supposed to exist.

This is the same file AssemblyDependencyResolver reads when you build a custom plugin-loading AssemblyLoadContext: rather than reimplementing NuGet-style dependency resolution yourself, you point the resolver at a plugin's own .deps.json, and it maps assembly names to the correct absolute paths on disk, including native libraries and satellite (localization) assemblies. A common real failure mode is a plugin project that targets a netstandard2.x TFM instead of a concrete runtime TFM like net10.0 — because deps.json generation depends on which target framework actually produced the build, a netstandard-targeted plugin can end up with a deps.json that references the wrong flavor of a package (reference assembly versus runtime-specific implementation), producing confusing type-mismatch or missing-member failures that look unrelated to the actual cause. The practical lesson: deps.json isn't an optional optimization file, it's load-bearing for exactly how the default ALC discovers everything beyond the assemblies sitting directly next to the entry point.

JSON
{
  "targets": {
    ".NETCoreApp,Version=v10.0": {
      "MyPlugin/1.0.0": {
        "dependencies": { "Newtonsoft.Json": "13.0.3" },
        "runtime": { "MyPlugin.dll": {} }
      }
    }
  }
}

What interviewers look for: connecting deps.json to the actual failure symptom (a late, confusing FileNotFoundException) and to AssemblyDependencyResolver, rather than describing it only as "some build output file."

Q3 What does AssemblyLoadContext actually isolate, and what happens if two ALCs both define a type called Foo.Bar?#

Short answer: ALC isolation is purely a naming boundary, not a binary sandbox — the two loaded copies of Foo.Bar are simply never looked up by the same name-to-assembly mapping, but there's no memory protection or security boundary between them; and critically, the two Foo.Bar types are not interchangeable at runtime even though the name, namespace and source code are byte-for-byte identical, because the CLR defines type identity as coming from the same loaded Assembly instance, not from the type's name.

This produces the single most common ALC-related production bug: code in ALC #1 that receives an instance of Foo.Bar created in ALC #2 cannot cast it to its own Foo.Bar, and the resulting InvalidCastException message shows both sides as the identical-looking string "Object of type 'Foo.Bar' cannot be converted to type 'Foo.Bar'", which is deeply confusing until you know to check type.Assembly and AssemblyLoadContext.GetLoadContext(type.Assembly) for each side rather than trusting the printed type name. The fix pattern is always one of two things: share the type definition by loading the assembly that defines it only into the default ALC and never duplicating it into the plugin context (the <Private>false</Private>/ExcludeAssets="runtime" reference pattern), or accept the isolation is real and cross a boundary only through reflection, a shared marshaling contract, or a genuinely common interface assembly both sides load from the same place. This is precisely why a host and its plugins should always share their plugin-contract assembly (interfaces, DTOs) through the default context rather than letting each plugin bundle its own copy.

What interviewers look for: understanding that ALC isolation is a lookup-by-name boundary with no memory/security enforcement, and specifically that type identity requires the same Assembly instance — this is the detail that explains the confusing cast-exception symptom rather than just its existence.

Q4 Design a plugin system that can unload third-party plugins without restarting the host process. What actually has to be true for the unload to complete?#

Short answer: Load each plugin into its own collectible AssemblyLoadContext (constructed with isCollectible: true), resolve the plugin's own dependencies with an AssemblyDependencyResolver pointed at its .deps.json, call Unload() on the context when you're done with it, and then nothing outside that context — no stack slot, static field, running thread, registered callback or strong/pinned GC handle — can still reference anything from inside it, because unload is cooperative: Unload() only requests teardown, and it completes asynchronously once the GC proves the context is truly unreferenced.

The entities that silently keep a context alive are the ones that trip up real implementations: a Thread still executing a method from the plugin assembly; a RegisteredWaitHandle whose callback points into it; and, subtly, fields on your own AssemblyLoadContext subclass that still reference plugin types or instances — the runtime itself holds a strong handle to the context object while unload is in progress specifically so it can coordinate the teardown, so those fields won't get collected out from under you, which means you must explicitly null them yourself. Even a correctly-implemented unload isn't synchronous: after calling Unload() and dropping your last reference (commonly returned to the caller as a WeakReference specifically so it can verify collection), you generally need to call GC.Collect() and GC.WaitForPendingFinalizers() in a short loop and check the weak reference until it goes null, because reclaiming the context's LoaderAllocator and everything it owns is subject to ordinary GC timing, potentially across more than one collection if anything in the plugin has a finalizer. Two hard limitations apply regardless of how carefully you implement this: C++/CLI (mixed-mode) assemblies can never be loaded into a collectible context, and ReadyToRun-precompiled code inside a plugin assembly is simply ignored — it gets JIT-compiled instead, since R2R data isn't designed to be unloaded.

C#
private static WeakReference LoadAndRun(string pluginPath)
{
    var alc = new PluginLoadContext(pluginPath); // isCollectible: true
    var assembly = alc.LoadFromAssemblyPath(pluginPath);
    // ...invoke plugin entry point, then release every local reference...
    alc.Unload();
    return new WeakReference(alc);
}

var weakAlc = LoadAndRun(path);
for (var i = 0; weakAlc.IsAlive && i < 10; i++)
{
    GC.Collect();
    GC.WaitForPendingFinalizers();
}

What interviewers look for: naming the concrete, non-obvious things that keep a collectible context alive (stray stack slots, your own subclass's fields, running threads) — this is the question that separates "I've read about ALC" from "I've actually shipped and debugged an unloadable plugin host."

Q5 What's a diamond dependency, and what actually happens if package A needs Newtonsoft.Json 12.0 and package B needs 13.0, and your app references both A and B?#

Short answer: This is resolved at build/restore time, not at runtime: NuGet's dependency resolution unifies the whole graph and picks one version per package — generally the highest version requested anywhere in the graph, subject to any explicit version constraints your project sets — so by the time deps.json is generated, there is normally exactly one physical version of Newtonsoft.Json recorded for the app to load, and both A and B run against that single unified version at runtime.

The risk isn't "which version loads," which NuGet answers deterministically — it's whether the unified version is actually compatible with both A and B's expectations. If package A was built against 12.0 and the resolved version is 13.0, the unification generally works fine as long as 13.0 didn't remove or change a member A actually calls; if it did, you get a runtime MissingMethodException on a call that compiled cleanly, because NuGet's resolution is version-arithmetic, not a guarantee of API compatibility. This is precisely the failure mode strong version ranges, <PackageReference> pinning and tools like dotnet list package --include-transitive exist to catch before it reaches production, and it's a different problem from the multi-ALC scenario earlier in this page: with deliberate multiple AssemblyLoadContexts, you genuinely can run two different physical versions of the same assembly side by side (one per context), which is sometimes the deliberate fix for a diamond conflict severe enough that unification would break one of the two dependents.

What interviewers look for: knowing this resolves at build time through NuGet's version unification rather than at runtime through some binding-redirect-like mechanism, and understanding that a successful build doesn't guarantee runtime compatibility with the unified version.

Common mistakes: assuming .NET silently loads both versions side by side by default (it doesn't — that requires deliberately separate ALCs), or assuming a successful restore/build means the diamond is definitely safe.

Q6 How did binding redirects work in .NET Framework, and why doesn't modern .NET need an equivalent mechanism?#

Short answer: .NET Framework's assembly binder demanded an exact version match by default, so if your app referenced Foo, Version=1.0.0.0 and only Foo, Version=1.1.0.0 was present, the load failed outright unless an app.config <bindingRedirect> (or a publisher policy) explicitly told the binder "treat requests for this range as this version instead"; modern .NET doesn't need an equivalent because the whole dependency graph is unified to single versions at build time via NuGet, and the runtime's own resolution rule for a request that's already satisfied is more forgiving by default.

Two things combine to remove the need: first, because NuGet resolves the entire graph before deps.json is even generated (the diamond-dependency question above), there's typically no runtime ambiguity between "what version is referenced" and "what version is actually present" the way .NET Framework's more loosely-coordinated, GAC-and-config-file world routinely produced. Second, for the cases where a version mismatch still occurs within one AssemblyLoadContext — one already-loaded assembly and a second request for the same simple name at a different version — the rule is "the request succeeds if the already-loaded version is equal to or higher than what's requested," which acts as a built-in, automatic forward-unification rather than a hard exact-match failure requiring an explicit redirect. Binding redirects also solved a second problem modern .NET sidesteps structurally: .NET Framework's shared, machine-wide GAC meant version conflicts between unrelated apps sharing the same machine were common; .NET Core and later ship dependencies alongside the app (or resolve them from a versioned, side-by-side shared framework), so there's no single shared registry of assembly versions to reconcile redirects against in the first place.

What interviewers look for: the two real causes — build-time graph unification and a more permissive equal-or-higher runtime rule, plus the disappearance of the GAC — rather than a vague "modern .NET just handles it better."

Q7 What does strong naming actually give you in modern .NET? Does it provide any security or tamper protection?#

Short answer: A strong name signs an assembly with a public/private key pair and folds the public key token into the assembly's identity (name, version, culture, public key token), which historically enabled side-by-side loading of multiple versions in the machine-wide GAC and strict identity-based binding in .NET Framework; in modern .NET, with no GAC, it's essentially reduced to an identity and tooling convention — it is not a security boundary, and it provides no tamper-evidence guarantee, because anyone with the (often publicly shippable) key pair, or a re-signing tool, can produce an assembly with the same strong name.

The practical reasons it still matters day to day: InternalsVisibleTo requires the "friend" assembly to be strong-named and its public key specified exactly when the assembly granting access is itself strong-named, some enterprise or legacy interop scenarios still expect strong-named components, and a handful of tools and policies (certain COM interop paths, some older signing/compliance pipelines) still check for it. What it explicitly does not give you: strong naming is not code signing in the security sense (that's Authenticode/certificate-based signing, a separate mechanism), the private key used for strong naming is frequently checked into source control or embedded in a build pipeline rather than protected like a real signing key, and there is no runtime verification step in modern .NET that rejects an assembly for having an invalid or missing strong-name signature the way old-.NET-Framework security policy could. The honest interview answer is that strong naming today is almost entirely about assembly identity and a handful of specific tooling requirements, not about trust.

What interviewers look for: explicitly separating identity/versioning from security — candidates who describe strong naming as "how .NET prevents tampering" have a materially wrong mental model that this question is designed to surface.

Q8 What's actually in runtimeconfig.json, how does it relate to .csproj and runtimeconfig.template.json, and what happens if you hand-edit it incorrectly?#

Short answer: app.runtimeconfig.json is a build-generated file listing the target framework/runtime version the app needs and a configProperties bag of runtime switches (GC mode, tiered compilation, roll-forward policy, and dozens more); hostfxr reads it before coreclr even loads to pick which installed runtime to use, so a malformed or self-contradictory entry typically fails the process before a single line of your code runs, with a host-level error rather than a managed exception.

The file is generated, not hand-authored directly, from two sources: MSBuild properties in your .csproj (things like <TieredCompilation> or <ServerGarbageCollection> translate directly into configProperties entries) and, for settings with no dedicated MSBuild property, an optional runtimeconfig.template.json in your project whose configProperties object gets merged into the generated output — this is the standard escape hatch for a setting like a custom GC configuration property that doesn't have a first-class MSBuild property yet. Because hostfxr parses this file as part of resolving which runtime to launch at all, a syntactically broken JSON file, or a tfm/rollForward combination that can't be satisfied by anything installed on the machine, produces a startup failure with a hostfxr-level exit code, not a .NET exception you can catch — which is why editing the generated app.runtimeconfig.json directly (rather than its template or your project file) is fragile: your changes are also silently overwritten on the next build regardless of whether they were correct.

JSON
{
  "runtimeOptions": {
    "tfm": "net10.0",
    "framework": { "name": "Microsoft.NETCore.App", "version": "10.0.0" },
    "configProperties": {
      "System.GC.Server": true,
      "System.Runtime.TieredPGO": true
    }
  }
}

What interviewers look for: knowing the file is generated (from .csproj plus an optional template) rather than a source file you should edit directly, and that hostfxr consumes it before the runtime and your code even start.

Q9 What's the default roll-forward policy, and what does it actually do when your net10.0 app runs on a machine that only has net10.0.3 or net11.0 installed?#

Short answer: The default policy is Minor: if the exact requested minor version isn't installed, the host rolls forward to the next available higher minor version at its highest patch (never automatically crossing a major version boundary); if the requested minor version is installed, it behaves like LatestPatch for that minor — meaning net10.0 on a machine with only 10.0.3 installed simply runs on 10.0.3 (a patch roll-forward within the requested minor), while a machine with only 11.0.x installed would need Major or LatestMajor to run at all, because Minor alone never crosses into a new major version.

The full policy set is worth knowing precisely, because "roll forward" isn't one behavior: LatestPatch takes the highest patch for the exact requested major.minor and explicitly disables minor-version roll-forward; LatestMinor takes the highest available minor for the requested major even when the requested minor is present; Major behaves like Minor when the requested major is available, but if it's missing, rolls to the next higher major at its lowest minor and highest patch; LatestMajor always takes the highest major and minor and patch available, even if an exact match exists; and Disable refuses to roll forward at all, binding only to the exact requested version and failing hard otherwise — explicitly not recommended outside of testing, since it opts an app out of picking up security patches automatically. This is directly relevant to the .NET support lifecycle: because .NET 8 and .NET 9 both reach end of support on November 10, 2026, an app still targeting either one with the default Minor policy will keep running on the last patch that policy can find once security servicing stops, silently, unless someone actively retargets it to .NET 10.

What interviewers look for: the precise default (Minor, not LatestPatch or LatestMinor) and the specific behavior split within Minor and Major depending on whether the exact requested version is present — this is a frequently mis-remembered detail even among experienced engineers.

Q10 You maintain a shared library referenced by dozens of internal teams. How do you evolve it across major versions without becoming a dependency-hell source for your consumers?#

Short answer: Treat SemVer as a promise you verify, not just a number you bump — use an API-compatibility checker against the previous major version before every release, multi-target where practical so consumers on different TFMs aren't forced to upgrade in lockstep, deprecate loudly with [Obsolete] well ahead of removal, and reserve a genuinely breaking major version (or, in the extreme case, side-by-side loading via separate AssemblyLoadContexts) for changes that truly can't be made additive.

The highest-leverage habit is catching accidental breaks before they ship: an API-compatibility tool run in CI against the last released major version turns "did I just break binary or source compatibility" from a question a consumer discovers weeks later into a build failure you see immediately, which matters enormously for a library with dozens of internal consumers who can't all upgrade in the same sprint. [Obsolete("message", error: false)] with a clear migration path, held for at least one full minor release cycle before the member is actually removed, gives consumers a visible, compiler-enforced warning instead of a silent removal that only surfaces as a build break on their side. For the genuinely unavoidable case — two major versions of your library that truly cannot coexist as one loaded assembly because their public surface diverged too far — side-by-side loading through separate AssemblyLoadContexts (the same mechanism a plugin host uses) is the last-resort escape hatch that lets two consumers on different majors coexist inside one process without either of them winning a forced-unification fight at restore time; it's rarely the first tool to reach for, but knowing it's available is what separates "we blocked half the org from upgrading" from a contained migration.

What interviewers look for: a concrete process (automated API-compat checks, staged [Obsolete] deprecation, multi-targeting) rather than just "we follow SemVer," plus awareness that ALC-based side-by-side loading is an available, if heavyweight, escape hatch for truly incompatible majors.

Quick-Fire Round#

QuestionAnswer
What's the last resort in the managed assembly load algorithm?The legacy AppDomain.AssemblyResolve event.
What file does hostpolicy read to build the TPA list?app.deps.json.
Is ALC isolation a security boundary?No — it's a naming boundary only, with no memory protection.
What determines whether two loaded types are "the same type"?Coming from the same loaded Assembly instance, not the same name.
What kind of AssemblyLoadContext supports unloading?A collectible one (isCollectible: true).
Where is a diamond dependency conflict normally resolved?At NuGet restore/build time, via version unification.
Does modern .NET use app.config binding redirects?No — build-time unification plus an equal-or-higher runtime rule replace them.
Does strong naming provide tamper protection in modern .NET?No — it's identity, not a security mechanism.
What reads runtimeconfig.json before the runtime even loads?hostfxr.
What's the default roll-forward policy?Minor.

How to Prepare#

  • Recite the load-by-name algorithm's exact order: cache, Load override, default probing, Resolving event, legacy AppDomain event.
  • Be ready to debug an InvalidCastException between two identically-named types from different AssemblyLoadContexts.
  • Know the concrete list of things that keep a collectible ALC from unloading — stray stack slots, your own subclass's fields, running threads.
  • Practice the diamond-dependency answer: build-time unification, not runtime binding redirects, and why a successful build isn't a compatibility guarantee.
  • Memorize the six roll-forward policy values and, precisely, what the default (Minor) does with and without an exact-version match.
  • Prepare one real story about strong naming, deps.json, or a plugin unload bug — this topic rewards production scars over textbook recall.