Lazy loading, the TPA list from deps.json, resolution order and events, common load exceptions and the tools to trace loading.
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.
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.
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).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.X, the default ALC:
X is already loaded (by simple name);X in the TPA list;Resolving event on the ALC and AppDomain.AssemblyResolve handlers get a chance.AssemblyDependencyResolver reading the plugin's .deps.json.| 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 |
X.dll in the folder? Is it in app.deps.json with the expected version and RID-specific path (runtimes/linux-x64/lib/...)?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.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.export COREHOST_TRACE=1
export COREHOST_TRACEFILE=host.log
dotnet app.dll
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.AppDomain.CurrentDomain.GetAssemblies(), or dotnet-dump clrmodules/eeversion) to spot duplicates or unexpected versions.AssemblyDependencyResolver; shared contract assemblies must be loaded from the default ALC to keep type identity.PackageReference in the entry project (transitive dependency not flowing, PrivateAssets misuse) → add the reference.NU1605) or two packages requiring incompatible versions → align versions with Central Package Management (Directory.Packages.props).-r.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.
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.
"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.
FileLoadException.Load override (custom ALCs).AssemblyLoadContext.Resolving event.AppDomain.AssemblyResolve event.Resolution failures that reach the end produce FileNotFoundException.
| 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 |
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.
FileLoadException.COREHOST_TRACE=1 and find the TPA list in the log.dotnet-trace during start-up.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
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.
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.
What happens: NU1605 warnings suppressed. Why it is wrong: leads to runtime version mismatches. Do instead: align versions (Central Package Management).
What happens: native assets missing on Linux containers.
Why it is wrong: DllNotFoundException/load failures.
Do instead: publish with the correct -r.
What happens: old DLLs from previous versions remain. Why it is wrong: wrong versions picked up. Do instead: clean deployments (containers, zip deploy).
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.
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.
What happens: guessing at resolution logic.
Why it is wrong: wastes time.
Do instead: COREHOST_TRACE and loader events show exactly what happened.
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.
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.
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.
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.
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.
Last updated 31 Oct 2026.