📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

How does assembly loading work in .NET, and how do you debug a FileNotFoundException or FileLoadException for an assembly?

Lazy loading, the TPA list from deps.json, resolution order and events, common load exceptions and the tools to trace loading.

Senior Debugging Advanced assembly-loading clr debugging deps-json fileloadexception

🎯 Key points

  • Assemblies load lazily when the JIT first needs a type from them
  • The host builds the TPA list from deps.json and the shared framework
  • Default context accepts the same or a higher version; no binding redirects
  • Resolving and AssemblyResolve events run only after normal resolution fails
  • FileNotFoundException, FileLoadException and BadImageFormatException have different usual causes
  • Use the exception's requested version, deps.json, COREHOST_TRACE and dotnet-trace loader events
  • Fix loading failures in the build and publish, not with runtime hooks

⏱ 30-second answer

Assemblies load lazily: the first time the JIT compiles a method that references a type from an assembly, the runtime resolves and loads it. For the default load context the host builds the Trusted Platform Assemblies (TPA) list from deps.json and the shared framework; that list is where assemblies are found. If resolution fails, the AssemblyLoadContext.Resolving and AppDomain.AssemblyResolve events get a chance. FileNotFoundException means not found, FileLoadException usually means found with the wrong identity or a lower version, BadImageFormatException means wrong format or architecture. Debug with the exception's requested version, the deps.json, COREHOST_TRACE and dotnet-trace loader events.

🗣 Full answer (senior)

How I would answer this in an interview

In modern .NET, assemblies are loaded into AssemblyLoadContexts (ALCs). The default ALC resolves assemblies from the TPA list that the host builds from the app's .deps.json and the shared framework. A FileNotFoundException/FileLoadException for an assembly means that resolution failed: the file isn't where the runtime looks, or a different version than required was found, or the file is invalid. I'd explain the loading process, then a systematic debugging approach.

1. How assembly loading works

  1. Host start-up: hostfxr reads app.runtimeconfig.json (which framework and version to use, roll-forward policy) and picks the shared framework (e.g. Microsoft.AspNetCore.App 10.0.x).
  2. hostpolicy reads app.deps.json, listing every dependency (packages, project references, runtime-specific assets per RID), and builds the TPA (Trusted Platform Assemblies) list: full paths to app and framework assemblies, plus native search paths.
  3. When code first touches a type from assembly X, the default ALC:
    • checks if X is already loaded (by simple name);
    • looks up X in the TPA list;
    • checks the version: the found assembly's version must be ≥ the referenced version;
    • loads it (from file, or from a single-file bundle).
  4. If not found, the Resolving event on the ALC and AppDomain.AssemblyResolve handlers get a chance.
  5. Satellite (resource) assemblies are probed by culture subfolder.
  6. Custom ALCs (plugins) use their own logic, often with AssemblyDependencyResolver reading the plugin's .deps.json.

2. The exceptions

Exception Typical meaning
FileNotFoundException: Could not load file or assembly 'X, Version=...' Not in TPA/probing paths: missing package reference, not copied to output, missing from deps.json, wrong RID assets, plugin folder missing dependency
FileLoadException Found but couldn't load: version lower than required, conflicting identity already loaded in the same ALC, strong-name/culture mismatch in edge cases, file locked or corrupted
BadImageFormatException Not a valid .NET assembly, or wrong architecture (x86 vs x64 native image), or reference assembly loaded at run time
TypeLoadException / MissingMethodException Assembly loaded but a type/member isn't there — binary incompatibility between versions

3. Debugging step by step

  1. Read the full exception: assembly name, requested version, and inner exception. Note which assembly referenced it (stack trace).
  2. Check the published output: is X.dll in the folder? Is it in app.deps.json with the expected version and RID-specific path (runtimes/linux-x64/lib/...)?
  3. Check versions: what version is present (AssemblyName.GetAssemblyName(path).Version) vs requested? A lower version present → FileLoadException. Check project.assets.json / dotnet list package --include-transitive to see which package versions NuGet chose; look for downgrade warnings (NU1605) and transitive conflicts.
  4. Framework vs app: is X part of the shared framework? Different framework versions on the machine (dotnet --list-runtimes) and roll-forward settings can change what's loaded. Package references that duplicate framework assemblies can cause conflicts.
  5. Runtime identifiers: native/RID-specific assets missing when publishing without a RID or for a different platform (common with SQLite, gRPC, SkiaSharp).
  6. Enable host tracing to see exactly what the host resolved:
    export COREHOST_TRACE=1
    export COREHOST_TRACEFILE=host.log
    dotnet app.dll
    
  7. Runtime loader tracing: dotnet-trace collect --providers Microsoft-Windows-DotNETRuntime:0x4:5 (Loader keyword) or the AssemblyLoader events to see each load attempt, ALC, path and result. Hook AssemblyLoadContext.Default.Resolving temporarily to log misses.
  8. List loaded assemblies at run time (AppDomain.CurrentDomain.GetAssemblies(), or dotnet-dump clrmodules/eeversion) to spot duplicates or unexpected versions.
  9. Plugins: check that the plugin's own dependencies are next to it and that it uses AssemblyDependencyResolver; shared contract assemblies must be loaded from the default ALC to keep type identity.

4. Common root causes and fixes

  • Missing PackageReference in the entry project (transitive dependency not flowing, PrivateAssets misuse) → add the reference.
  • Copying DLLs manually instead of referencing packages → reference properly so deps.json lists them.
  • Package downgrade (NU1605) or two packages requiring incompatible versions → align versions with Central Package Management (Directory.Packages.props).
  • Published for the wrong RID or framework-dependent without RID-specific native assets → publish with -r.
  • Old DLLs left in deployment folders (xcopy deployments) → clean deployments.
  • Plugin loaded into default ALC with conflicting dependency → isolate in its own ALC.
  • Binary breaking change in a library (method removed) → align versions, recompile against the deployed version.

5. A production example

After a deployment, an Azure Function threw FileLoadException: Could not load 'Microsoft.Extensions.Logging.Abstractions, Version=8.0.0.0'. The function host provided v6 of that assembly in its context, while our package graph required v8. Host tracing and dotnet list package --include-transitive showed a new SDK package pulled v8 transitively. Moving the function to the isolated worker model (our own process and dependency graph) eliminated the conflict entirely; we also adopted Central Package Management to make transitive version changes visible in reviews.

🔍 What the interviewer is checking

The interviewer checks you can debug a classic production failure methodically.

Green flags: lazy loading, TPA from deps.json, same-or-higher version rule, exception meanings, COREHOST_TRACE and loader events, fix in the build.

Red flags: suggests binding redirects or the GAC; reaches for AssemblyResolve hacks first; cannot explain where the runtime looks.

📚 Learn in depth

1. deps.json structure

"targets": {
  ".NETCoreApp,Version=v10.0/linux-x64": {
    "Microsoft.Data.Sqlite.Core/9.0.0": {
      "runtime": { "lib/net8.0/Microsoft.Data.Sqlite.dll": { "assemblyVersion": "9.0.0.0" } }
    },
    "SQLitePCLRaw.lib.e_sqlite3/2.1.10": {
      "runtimeTargets": { "runtimes/linux-x64/native/libe_sqlite3.so": { "rid": "linux-x64", "assetType": "native" } }
    }
  }
}

The host uses runtime and runtimeTargets entries for the current RID to build the TPA and native search paths.

2. Version rules in the default ALC

  • Assemblies are unified by simple name: only one version per name in the default context.
  • A found version ≥ requested is accepted; lower → FileLoadException.
  • Public key token mismatch can also cause failures in some cases.
  • Framework assemblies win over app-local copies with lower versions; app-local copies with higher versions can override framework ones ("app-local" servicing), which can create subtle mismatches.

3. Resolving events order

  1. ALC's Load override (custom ALCs).
  2. Default ALC lookup (TPA).
  3. AssemblyLoadContext.Resolving event.
  4. AppDomain.AssemblyResolve event.

Resolution failures that reach the end produce FileNotFoundException.

4. Tools

Tool Use
COREHOST_TRACE Host-level resolution (frameworks, deps.json, TPA)
dotnet-trace Loader events Runtime load attempts and results
dotnet list package --include-transitive / project.assets.json NuGet resolution
dotnet --list-runtimes / --info Installed frameworks
ILSpy / AssemblyName.GetAssemblyName Inspect versions and references
dotnet-dump (clrmodules) Loaded modules in a running/crashed process

5. Binary compatibility

TypeLoadException/MissingMethodException after loading indicates assemblies compiled against a different API surface. Package validation and SemVer discipline prevent this; consumers should avoid mixing major versions.

Try it yourself

  1. Delete a dependency DLL from a published app and read the exception.
  2. Replace a dependency with a lower version and observe FileLoadException.
  3. Run with COREHOST_TRACE=1 and find the TPA list in the log.
  4. Collect loader events with dotnet-trace during start-up.

💻 Code

using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.Loader;

AppDomain.CurrentDomain.AssemblyLoad += (_, e) =>
    Console.WriteLine($"Loaded: {e.LoadedAssembly.GetName().Name}");

AssemblyLoadContext.Default.Resolving += (context, name) =>
{
    Console.WriteLine($"Could not resolve {name.FullName}; returning null");
    return null; // let the runtime throw FileNotFoundException
};

Console.WriteLine("Before first JSON use");
UseJson();   // System.Text.Json loads only when this method is JIT-compiled
Console.WriteLine("After first JSON use");

try
{
    Assembly.Load(new AssemblyName("Does.Not.Exist, Version=1.0.0.0"));
}
catch (FileNotFoundException ex)
{
    Console.WriteLine($"{ex.GetType().Name}: {ex.FileName}");
}

[MethodImpl(MethodImplOptions.NoInlining)]
static void UseJson() =>
    Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(new[] { 1, 2, 3 }));
COREHOST_TRACE=1 COREHOST_TRACEFILE=host.txt dotnet bin/Release/net10.0/App.dll
dotnet-trace collect --providers Microsoft-Windows-DotNETRuntime:4 -- dotnet bin/Release/net10.0/App.dll

⚠ Common mistakes

1. Looking for binding redirects

What happens: trying .NET Framework fixes in .NET 8+. Why it is wrong: binding redirects don't exist; deps.json and ALCs govern loading. Do instead: fix package versions and deployment contents.

2. Copying DLLs manually

What happens: "fixing" missing assemblies by copying files into bin. Why it is wrong: deps.json doesn't reference them; fragile and inconsistent. Do instead: add proper package/project references.

3. Ignoring NuGet downgrade warnings

What happens: NU1605 warnings suppressed. Why it is wrong: leads to runtime version mismatches. Do instead: align versions (Central Package Management).

4. Publishing without RID for native dependencies

What happens: native assets missing on Linux containers. Why it is wrong: DllNotFoundException/load failures. Do instead: publish with the correct -r.

5. Leaving stale files in deployment folders

What happens: old DLLs from previous versions remain. Why it is wrong: wrong versions picked up. Do instead: clean deployments (containers, zip deploy).

6. Loading plugins into the default context

What happens: plugin dependencies conflict with the host. Why it is wrong: one version per name in the default ALC. Do instead: isolated ALCs with AssemblyDependencyResolver.

7. Not reading the requested version

What happens: checking only that the file exists. Why it is wrong: version mismatch is the usual cause of FileLoadException. Do instead: compare requested vs actual versions.

8. Not using tracing

What happens: guessing at resolution logic. Why it is wrong: wastes time. Do instead: COREHOST_TRACE and loader events show exactly what happened.

🏛 Senior insight: trade-offs

Assembly-loading failures are packaging bugs, so the fix belongs in the build, not in runtime hooks.

Symptom Likely cause Fix
Works locally, fails in container Wrong RID or copied bin/ Publish for the RID
FileLoadException after upgrade Lower version shipped than referenced Align package versions
MissingMethodException Older implementation assembly loaded Find which project pulls the old version
Two majors of one library needed Default context can hold only one Custom AssemblyLoadContext

Senior engineers also add a smoke test that runs the published artefact, because many of these only show up outside the IDE.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate says two major versions need isolation Why: Checks custom AssemblyLoadContext knowledge
    How to answer

    AssemblyLoadContext (ALC) is the modern replacement for AppDomains as the unit of assembly isolation. Each ALC has its own set of loaded assemblies, so plugins can bring their own dependency versions. A custom ALC overrides Load and uses AssemblyDependencyResolver to resolve from the plugin's deps.json. Shared contract assemblies must come from the default context (return null from Load), otherwise the same interface loaded twice is two different types and casts fail. With isCollectible: true you can call Unload(), but unloading is cooperative: it completes only when nothing references the plugin's types, objects, delegates or threads. Verify it with a WeakReference to the ALC.

  2. When: Candidate mentions the requested version in the exception Why: Checks assembly identity and version rules
    How to answer

    An assembly is the unit of deployment, versioning and loading in .NET: a PE file (.dll or .exe) containing IL, metadata, a manifest and optional resources. Its identity is name, version, culture and public key token. In modern .NET there is no GAC, strong-name signatures are not validated at load, and the runtime accepts an assembly whose version is the same or higher than the one referenced, so binding redirects are not needed. Assembly versions still matter for compatibility and for what NuGet and the SDK place in deps.json.

  3. When: Candidate says to publish for the right RID Why: Probes publish and deployment options
    How to answer

    By default dotnet publish gives a framework-dependent folder that needs the .NET runtime on the server. Self-contained (--self-contained -r linux-x64) bundles the runtime, so nothing needs installing, but the output is bigger and you patch the runtime by redeploying. On top of that: single-file packs everything into one executable, trimming removes unused code, ReadyToRun precompiles to speed up start-up, and Native AOT compiles to a native binary with very fast start-up and low memory but no dynamic code generation. The SDK can also build a container image directly. I choose based on where it runs, start-up and memory needs, and whether the libraries are trim- and AOT-safe, and I measure the result.

🛠 Real-world problems: how you handle them

Container deployment failed with FileNotFoundException for a RID-specific package
Situation
An image-processing API worked on developer machines but crashed in its Linux container when generating thumbnails.
Signals
`FileNotFoundException: Could not load file or assembly 'System.Drawing.Common'` only on the thumbnail endpoint, minutes after startup.
Investigation
Inspected the image: the Dockerfile copied `bin/Release/net10.0` from a Windows build. `deps.json` referenced runtime-specific asset paths not present. `COREHOST_TRACE` confirmed the assembly was missing from the TPA.
Resolution
Changed the Dockerfile to `dotnet publish -r linux-x64` and copy `publish/`; later replaced System.Drawing with a cross-platform imaging library.
Prevention
Added a container smoke test that calls every endpoint once after build.

How to tell it (STAR)

Our [image API] crashed in the container only on [the thumbnail endpoint]. Because loading is lazy, the error appeared late. I read deps.json and the host trace, found we copied bin from the wrong RID, switched to dotnet publish for linux-x64 and added a smoke test for the published image.

↩ Usually asked after

More in CLR Fundamentals

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me