Most .NET developers eventually face it.

A project that targets .NET Framework 4.7.2, uses video and audio components, depends on vendor SDKs, and mixes managed code, native DLLs, and legacy decisions.

In other words: a brownfield project.

This is the kind of system that still runs real businesses, even if it doesn’t fit neatly into modern slides about containers and self-contained deployments.

And it’s also where many developers discover — usually the hard way — that deployment is not just copying the Release folder and hoping for the best.

The Myth: “Just Copy the EXE”

I’ve seen this mindset for years:

“It works on my machine. Just copy the EXE and the DLLs to the client.”

Sometimes it works. Often it doesn’t.

And when it fails, it fails in the most frustrating ways:

  • Silent crashes
  • Missing assembly errors
  • COM exceptions that appear only on client machines
  • Video or audio features that break minutes after startup

The real issue isn’t the DLL.

The real issue is that most developers don’t understand how .NET Framework actually resolves assemblies.

How I Learned This the Hard Way (XPO + Pervasive, 2006)

The first time I truly understood this was around 2006, while writing a custom XPO provider for Pervasive 7.

At the time, the setup was fairly typical:

  • A .NET Framework application
  • Using DevExpress XPO
  • Talking to Pervasive SQL
  • The Pervasive .NET provider lived under Program Files
  • It was not registered in the GAC

On my development machine, everything worked.

On another machine? File not found. Or worse: a crash when XPO tried to initialize the provider.

The “fix” everyone used back then was almost ritual:

“Copy the Pervasive provider DLL into the same folder as the EXE.”

And suddenly… it worked.

That was my first real encounter with assembly probing — even though I didn’t know the name yet.

How Assembly Resolution Really Works in .NET Framework

.NET Framework does not scan your disk.

It does not care that a DLL exists under Program Files.

It follows a very strict resolution order.

1. Already Loaded Assemblies

If the assembly is already loaded in the current AppDomain, the CLR reuses it.

Simple.

2. Application Base Directory

Next, the CLR looks in the directory where the EXE lives.

This single rule explains years of “just copy the DLL next to the EXE” folklore.

In the Pervasive case, copying the provider locally worked because it entered the application base probing path.

3. Private Probing Paths

This is where things get interesting.

In app.config, you can extend the probing logic:

<runtime>
  <assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
    <probing privatePath="lib;providers;drivers" />
  </assemblyBinding>
</runtime>

This tells the runtime:

“If you don’t find the assembly in the EXE folder, also look in these subfolders.”

Important details many developers miss:

  • Paths are relative to the EXE
  • No recursive search
  • Every folder must be explicitly listed

4. Global Assembly Cache (GAC)

Only after probing the application paths does the CLR look in the GAC, and only if:

  • The assembly is strong-named
  • The reference includes that strong name

Two common misconceptions:

  • A DLL being “installed on the system” does not matter
  • Non–strong-named assemblies are never loaded from the GAC

5. AssemblyResolve: The Last-Chance Hook

If the CLR cannot find an assembly using any of the rules above, it fires:

AppDomain.CurrentDomain.AssemblyResolve

This happens at runtime, exactly when the assembly is needed.

That’s why:

  • The app may start fine
  • The crash happens later
  • Video or database features fail “randomly”

Why Video and Audio Projects Amplify the Pain

Projects that deal with video codecs, audio pipelines, hardware acceleration, and vendor SDKs are especially vulnerable because:

  • Assemblies load late
  • Managed code pulls native DLLs
  • Bitness matters (x86 vs x64)
  • Licensing logic often lives outside managed code

The failure doesn’t happen at startup. It happens when the feature is first used.

The Final Step: Building a Real Installer

Eventually, I stopped pretending that copying files was enough.

I built a proper installer.

Even though today I often use the Visual Studio Installer Projects extension, for this legacy application I went with a WiX-based installer. Not because it was fashionable — but because it forced me to be explicit.

An installer asks uncomfortable questions:

  • Which assemblies belong in the GAC?
  • Which must live next to the EXE?
  • Which native DLLs must be deployed together?
  • Which dependencies only worked because Visual Studio copied them silently?

I had to inspect every file I was adding and make a conscious decision:

  • Shared, strong-named → GAC
  • App-local or version-sensitive → EXE folder
  • Native dependencies → exact placement matters

The installer didn’t magically fix the application.

It revealed the truth about it.

The Real Lesson of Brownfield Work

Legacy projects don’t fail because they’re old.

They fail because nobody understands them anymore.

Once you understand assembly probing, GAC rules, runtime loading, and deployment boundaries, brownfield systems stop being mysterious.

They become predictable.

What’s Next: COM (Yes, That COM)

This application doesn’t stop at managed assemblies.

It also depends heavily on COM components.

The next article will focus entirely on that world: what COM components really are, why they survived for decades, and how to work with them safely as a .NET developer.

If assembly probing was the first reality check, COM is the one that separates “it runs on my machine” from “this can be deployed.”