Reaching 32-bit dependencies from a 64-bit .NET 8 slice

Most strangler migrations we run hit this wall in the first month. A slice is rebuilt on .NET 8, it runs fine on a developer machine, and then someone points out that the slice still needs one thing that only exists as a 32-bit binary. Usually it is the Visual FoxPro OLE DB provider (VFPOLEDB, x86 only, unsupported since 2015), or a 32-bit Access Database Engine install, or a VB6 ActiveX DLL that does the freight-rate calculation nobody has re-derived, or a barcode or check-printing control from a vendor that no longer exists.

A 64-bit process cannot load a 32-bit in-process DLL. That is a Windows rule, not a .NET one, and no amount of configuration changes it. So you have three real options. This tutorial builds the third one, because it is the one that lets the rest of the migration proceed on 64-bit .NET 8.

Pick the option before you write code

Option 1: run the whole new app as x86. Set <PlatformTarget>x86</PlatformTarget> and be done. It works, it takes ten minutes, and it is the right answer when the slice is small, memory use is well under the ~3 GB a 32-bit process gets, and you expect to retire the dependency within a release or two. The cost is that every future slice inherits the constraint, and some packages you will want later ship 64-bit only.

Option 2: remove the dependency now. For FoxPro data, that usually means finishing the SQL Server migration first, which we walk through in Moving Visual FoxPro data to SQL Server, row for row. For a VB6 calculation DLL, it means porting the calculation with characterization tests around it. Best outcome, longest lead time, and it is rarely available on the schedule the first slice is on.

Option 3: isolate the dependency in an out-of-process x86 worker and call it over a local RPC boundary. The new application stays 64-bit. The 32-bit code runs in its own process, with its own crash domain, behind an interface you own. When Option 2 eventually lands, you delete the worker and keep the interface.

Windows offers a built-in version of Option 3 through a COM+ out-of-process surrogate, and if the dependency is a well-behaved registered COM component, dllhost can host it with no code from you. In practice we write the worker ourselves, because the components in these systems are not well behaved: they are single-threaded, they hold file handles, they pop message boxes on error, and we want a place to put timeouts and logging.

Build the x86 worker

Define the contract first

Write the interface in terms of what the business needs, not in terms of the legacy API. If the VB6 component exposes SetOrigin, SetDest, SetWeight, Calc and GetResult as five stateful calls, your contract is one call:

syntax = "proto3";
package relicbridge.legacybridge;

service LegacyBridge {
  rpc RateFreight (RateFreightRequest) returns (RateFreightReply);
  rpc Ping (PingRequest) returns (PingReply);
}

message RateFreightRequest {
  string origin_zip = 1;
  string dest_zip = 2;
  int32 weight_lbs = 3;
  string service_code = 4;
}

message RateFreightReply {
  string amount = 1;        // decimal as string; do not round-trip through double
  string tariff_version = 2;
  repeated string warnings = 3;
}

Two details that matter later. Money crosses the boundary as a string and is parsed as decimal on the other side, because the legacy component almost certainly uses Currency or double and you do not want a second rounding step. And there is a Ping, because you will want a cheap health check.

Create the project and force x86

dotnet new grpc -o LegacyBridge.Worker

Then in LegacyBridge.Worker.csproj:

<PropertyGroup>
  <TargetFramework>net8.0</TargetFramework>
  <PlatformTarget>x86</PlatformTarget>
  <RuntimeIdentifier>win-x86</RuntimeIdentifier>
  <SelfContained>false</SelfContained>
  <EnableComHosting>false</EnableComHosting>
</PropertyGroup>

Verify the bitness at startup rather than trusting the build. A worker that silently came out 64-bit fails much later, with a 0x80040154 that sends someone down the wrong path for an afternoon:

if (Environment.Is64BitProcess)
    throw new InvalidOperationException(
        "LegacyBridge.Worker must run as x86; the VFP/COM dependencies are 32-bit.");

Call the 32-bit component

For a registered COM component, late binding keeps the build free of interop references and of the version pinning that comes with them:

public sealed class FreightComponent : IDisposable
{
    private readonly object _com;
    private readonly Type _type;

    public FreightComponent()
    {
        _type = Type.GetTypeFromProgID("Acme.Freight.Rater", throwOnError: true)!;
        _com = Activator.CreateInstance(_type)!;
    }

    public (decimal Amount, string Tariff) Rate(
        string originZip, string destZip, int weightLbs, string serviceCode)
    {
        Invoke("SetOrigin", originZip);
        Invoke("SetDest", destZip);
        Invoke("SetWeight", weightLbs);
        Invoke("Calc", serviceCode);

        var raw = Convert.ToString(Get("Result"), CultureInfo.InvariantCulture) ?? "";
        var tariff = Convert.ToString(Get("TariffVersion"), CultureInfo.InvariantCulture) ?? "";
        return (decimal.Parse(raw, NumberStyles.Any, CultureInfo.InvariantCulture), tariff);
    }

    private object? Invoke(string member, params object[] args) =>
        _type.InvokeMember(member, BindingFlags.InvokeMethod, null, _com, args);

    private object? Get(string member) =>
        _type.InvokeMember(member, BindingFlags.GetProperty, null, _com, null);

    public void Dispose()
    {
        if (_com is not null && Marshal.IsComObject(_com))
            Marshal.FinalReleaseComObject(_com);
    }
}

For a 32-bit OLE DB provider such as VFPOLEDB.1 or the 32-bit Microsoft.ACE.OLEDB.12.0, the worker uses System.Data.OleDb directly and the contract exposes named queries rather than SQL strings. Do not let callers pass SQL across the boundary; you will regret it the first time someone builds a string from user input.

using var conn = new OleDbConnection(
    "Provider=VFPOLEDB.1;Data Source=D:\\legacy\\data\\orders.dbc;");

Handle the single-threaded reality

Most of these components are marked ThreadingModel=Apartment and many are not safe to instantiate more than once per process. gRPC will happily call your service on several thread-pool threads at once. Put the component behind a single dedicated STA thread and a queue:

public sealed class StaComExecutor : IDisposable
{
    private readonly BlockingCollection<Action> _work = new();
    private readonly Thread _thread;

    public StaComExecutor()
    {
        _thread = new Thread(Pump) { IsBackground = true, Name = "legacy-com-sta" };
        _thread.SetApartmentState(ApartmentState.STA);
        _thread.Start();
    }

    private void Pump()
    {
        using var component = new FreightComponent();
        _current = component;
        foreach (var job in _work.GetConsumingEnumerable()) job();
    }

    private static FreightComponent? _current;

    public Task<T> RunAsync<T>(Func<FreightComponent, T> fn, CancellationToken ct)
    {
        var tcs = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
        _work.Add(() =>
        {
            try { tcs.TrySetResult(fn(_current!)); }
            catch (Exception ex) { tcs.TrySetException(ex); }
        });
        return tcs.Task.WaitAsync(ct);
    }

    public void Dispose() => _work.CompleteAdding();
}

That serializes access, which is honest: the legacy component was always a single-lane road. Measure the lane width before you promise throughput. On one FoxPro reporting bridge we measured 180 ms per call, which capped the worker at roughly five calls a second; the fix was to run four worker processes behind the client, not to make the component concurrent.

Make failure finite

The two failure modes that hurt are a call that never returns and a modal dialog on a headless server. Both are handled at the process boundary:

public override async Task<RateFreightReply> RateFreight(
    RateFreightRequest request, ServerCallContext context)
{
    using var cts = CancellationTokenSource.CreateLinkedTokenSource(context.CancellationToken);
    cts.CancelAfter(TimeSpan.FromSeconds(10));

    try
    {
        var (amount, tariff) = await _executor.RunAsync(
            c => c.Rate(request.OriginZip, request.DestZip, request.WeightLbs, request.ServiceCode),
            cts.Token);

        return new RateFreightReply
        {
            Amount = amount.ToString(CultureInfo.InvariantCulture),
            TariffVersion = tariff,
        };
    }
    catch (OperationCanceledException)
    {
        _log.LogError("Legacy rater did not return within 10s; recycling worker.");
        _lifetime.StopApplication(); // supervisor restarts a clean process
        throw new RpcException(new Status(StatusCode.DeadlineExceeded, "legacy rater timed out"));
    }
}

Stopping the process on a hung call is deliberate. An STA thread stuck inside a 32-bit DLL cannot be aborted in .NET 8; a fresh process is the only reliable recovery. Run the worker as a Windows service with automatic restart, or under IIS with an application pool that has a ping timeout, and let the supervisor do the work.

Call it from the 64-bit slice

Bind the worker to loopback only. It has no authentication, so it must not be reachable from anywhere else.

{
  "Kestrel": {
    "Endpoints": {
      "Grpc": { "Url": "http://127.0.0.1:5241", "Protocols": "Http2" }
    }
  }
}

In the 64-bit application, register a typed client with a retry that is safe for a read-only operation:

builder.Services
    .AddGrpcClient<LegacyBridge.LegacyBridgeClient>(o =>
        o.Address = new Uri(builder.Configuration["LegacyBridge:Url"]!))
    .ConfigureChannel(c => c.HttpHandler = new SocketsHttpHandler
    {
        EnableMultipleHttp2Connections = true,
        KeepAlivePingDelay = TimeSpan.FromSeconds(30),
    });

Then wrap the client in a domain interface — IFreightRater — so that application code never references the generated gRPC types. When you finally port the calculation, you change one registration and no call sites. That is the whole reason to spend a day on this instead of setting PlatformTarget to x86.

If you already have YARP in front of the legacy app, as in the routing shim, keep the worker off that path. It is an internal dependency of one slice, not a public route.

Test it against the recorded truth

The worker is a rewrite of a call path, so it gets the same treatment as any other slice. Record the legacy component's answers first, from the legacy host, across the inputs that matter: zero weight, maximum weight, the ZIP codes with special tariffs, the service code that is known to be broken. Store them as approved files, the way we do in Golden-master tests for legacy web apps, then run the same table through the gRPC client.

Watch three specific diffs. Decimal handling, where a Currency value that used to be truncated now rounds. Culture, where the worker process runs under a different regional setting than the old host and parses 1,234.50 differently — set InvariantGlobalization or pin CultureInfo explicitly and record which you chose. And empty-versus-null, where COM Empty and Null both arrive as something in C# and only one of them matches what the old caller saw.

What we will not do here

We do not recommend keeping this bridge quietly forever. It is scaffolding, and scaffolding that stays up becomes a second legacy system, this one with your name on it. Put the retirement condition in writing when you build it: the worker goes away when the FoxPro data lands in SQL Server, or when the freight calculation is ported with its tests. Track it where the work is tracked, not in someone's memory.

We also do not run these workers on machines the 32-bit provider was never installed on properly. VFPOLEDB and the Access engines have real installer requirements, and a 32-bit ACE install can conflict with a 64-bit Office install on the same box. If the worker has to share a server with Office, test that combination early; it has cost us more time than any code in this post.

If you are staring at one of these dependencies and cannot tell whether it is a ten-minute PlatformTarget change or a four-week problem, that is a measurement question, and it is the kind of thing a codebase assessment answers with a working module rather than an opinion.