Application Installers and Assembly Resolution Using the Legacy .NET Framework

Application Installers and Assembly Resolution Using the Legacy .NET Framework

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.”

 

User-Defined Functions in SQLite: Enhancing SQL with Custom C# Procedures

User-Defined Functions in SQLite: Enhancing SQL with Custom C# Procedures

SQLite, known for its simplicity and lightweight architecture, offers unique opportunities for developers to integrate custom functions directly into their applications. Unlike most databases that require learning an SQL dialect for procedural programming, SQLite operates in-process with your application. This design choice allows developers to define functions using their application’s programming language, enhancing the database’s flexibility and functionality.

Scalar Functions

Scalar functions in SQLite are designed to return a single value for each row in a query. Developers can define new scalar functions or override built-in ones using the CreateFunction method. This method supports various data types for parameters and return types, ensuring versatility in function creation. Developers can specify the state argument to pass a consistent value across all function invocations, avoiding the need for closures. Additionally, marking a function as isDeterministic optimizes query compilation by SQLite if the function’s output is predictable based on its input.

Example: Adding a Scalar Function


connection.CreateFunction(
    "volume",
    (double radius, double height) => Math.PI * Math.Pow(radius, 2) * height);

var command = connection.CreateCommand();
command.CommandText = @"
    SELECT name,
           volume(radius, height) AS volume
    FROM cylinder
    ORDER BY volume DESC
";
        

Operators

SQLite implements several operators as scalar functions. Defining these functions in your application overrides the default behavior of these operators. For example, functions like glob, like, and regexp can be custom-defined to change the behavior of their corresponding operators in SQL queries.

Example: Defining the regexp Function


connection.CreateFunction(
    "regexp",
    (string pattern, string input) => Regex.IsMatch(input, pattern));

var command = connection.CreateCommand();
command.CommandText = @"
    SELECT count()
    FROM user
    WHERE bio REGEXP '\w\. {2,}\w'
";
var count = command.ExecuteScalar();
        

Aggregate Functions

Aggregate functions return a consolidated value from multiple rows. Using CreateAggregate, developers can define and override these functions. The seed argument sets the initial context state, and the func argument is executed for each row. The resultSelector parameter, if specified, calculates the final result from the context after processing all rows.

Example: Creating an Aggregate Function for Standard Deviation


connection.CreateAggregate(
    "stdev",
    (Count: 0, Sum: 0.0, SumOfSquares: 0.0),
    ((int Count, double Sum, double SumOfSquares) context, double value) => {
        context.Count++;
        context.Sum += value;
        context.SumOfSquares += value * value;
        return context;
    },
    context => {
        var variance = context.SumOfSquares - context.Sum * context.Sum / context.Count;
        return Math.Sqrt(variance / context.Count);
    });

var command = connection.CreateCommand
();
command.CommandText = @"
SELECT stdev(gpa)
FROM student
";
var stdDev = command.ExecuteScalar();

Errors

When a user-defined function throws an exception in SQLite, the message is returned to the database engine, which then raises an error. Developers can customize the SQLite error code by throwing a SqliteException with a specific SqliteErrorCode.

Debugging

SQLite directly invokes the implementation of user-defined functions, allowing developers to insert breakpoints and leverage the full .NET debugging experience. This integration facilitates debugging and enhances the development of robust, error-free custom functions.

This article illustrates the power and flexibility of SQLite’s approach to user-defined functions, demonstrating how developers can extend the functionality of SQL with the programming language of their application, thereby streamlining the development process and enhancing database interaction.

Github Repo