Strangling a VB6 app: COM interop as the seam

A Visual Basic 6 line-of-business application does not give you the seam a web application gives you. There is no URL to reroute, no reverse proxy to slide in front, no request boundary to split traffic on. That is why VB6 rewrites so often get proposed as one big-bang replacement, and why so many of them stall in month nine with two systems half-built.

There is a seam. It is COM. VB6 has spoken COM since 1998, and .NET 8 can still answer in that language. This tutorial walks through moving one module out of a running VB6 executable into a .NET 8 class library that the VB6 app calls in place, so the old application keeps shipping while the new codebase grows underneath it.

A note on the platform, because owners ask: the VB6 IDE has been out of support since 2008, but the VB6 runtime files that ship in Windows remain supported for the lifetime of the Windows versions they ship in. Microsoft states this in its VB6 support statement. Your application will probably start on Windows 11. That is not the same as being maintainable, and it is not a reason to stop.

Step 1: pick a seam worth moving

Open the .vbp file in a text editor before you open the IDE. It lists every form, module, class module and referenced OCX. That list is your inventory, and it is usually the first honest picture anyone has had of the system in a decade.

A good first module has three properties:

  • It is logic, not UI. A .bas or .cls file with calculations, rules, validation or file generation. Forms come much later; the grid on frmOrders is not your first slice.
  • Its inputs and outputs are values. Numbers, strings, dates, a recordset you can turn into a table. Modules that reach out to global state, open forms or poke App.Path need untangling first.
  • It matters. Pricing, commission, tax, interest, dosage, freight rating. Something the business would notice being wrong, so the migration produces evidence rather than a warm feeling.

Pricing and rating engines are the usual answer. They are pure computation, they are heavily branched, and nobody currently living wrote them.

Step 2: characterize the old module first

Do not port anything yet. Record what the module does today.

In VB6, add a temporary standard module that loops over a set of inputs, calls the function, and writes the results to a CSV:

Public Sub DumpPricingBaseline()
    Dim f As Integer, i As Long
    f = FreeFile
    Open App.Path & "\pricing-baseline.csv" For Output As #f
    For i = 1 To UBound(gCases)
        Print #f, gCases(i).Sku & "," & gCases(i).Qty & "," & _
                  Format$(CalcPrice(gCases(i).Sku, gCases(i).Qty, _
                                    gCases(i).CustomerId), "0.0000")
    Next i
    Close #f
End Sub

Pull the case list from production data, not from your imagination: the distinct SKU and quantity combinations that actually ran through the system last year, plus every customer with a special rate. A few thousand rows is fine, and it costs nothing to run.

That CSV is the contract. The .NET 8 implementation is done when it reproduces the file, and not before. The general pattern, including where to draw the boundary and how to keep the suite honest, is in characterization tests for code nobody understands.

Watch the numeric types while you are here. VB6 Currency is a scaled 64-bit integer with four decimal places, and it is not double. Port it to decimal in C#, then let the baseline file tell you whether the old code was rounding at each step or only at the end. Half the diffs on a pricing migration are rounding, and every one of them is a business decision, not a defect.

Step 3: build the .NET 8 class library as a COM server

Create the library and make one interface visible to COM. Keep the surface small and explicit; do not mark a whole assembly COM-visible and hope.

[ComVisible(true)]
[Guid("3F5A1C1E-8B4A-4C61-9F2E-7D5B0E9A4411")]
[InterfaceType(ComInterfaceType.InterfaceIsDual)]
public interface IPricingEngine
{
    decimal CalcPrice(string sku, int qty, int customerId);
}

[ComVisible(true)]
[Guid("9C2B7A44-1E05-4F8D-8A3C-2B6D4E77A902")]
[ClassInterface(ClassInterfaceType.None)]
[ProgId("Relic.PricingEngine")]
public sealed class PricingEngine : IPricingEngine
{
    public decimal CalcPrice(string sku, int qty, int customerId) => /* ported logic */;
}

The project file carries the two settings that matter:

<PropertyGroup>
  <TargetFramework>net8.0-windows</TargetFramework>
  <EnableComHosting>true</EnableComHosting>
  <Platforms>x86</Platforms>
  <RuntimeIdentifier>win-x86</RuntimeIdentifier>
</PropertyGroup>

EnableComHosting produces a YourLib.comhost.dll next to the assembly, which is what you register:

regsvr32 Relic.Pricing.comhost.dll

Microsoft documents the whole arrangement in exposing .NET components to COM.

The bitness is not optional. VB6 executables are 32-bit, so the library must be built x86 and the 32-bit .NET 8 runtime must be installed on the machine. A 64-bit build registers fine and then fails at CreateObject with an error that tells you nothing useful. Check this on day one.

Step 4: call it from VB6

.NET Core and .NET 5 onwards do not generate type libraries. There is no tlbexp step and nothing to add under Project > References, so early binding with a strongly typed variable is not available out of the box. Use late binding:

Private mPricing As Object

Private Function NewPrice(ByVal Sku As String, ByVal Qty As Long, _
                          ByVal CustomerId As Long) As Currency
    If mPricing Is Nothing Then Set mPricing = CreateObject("Relic.PricingEngine")
    NewPrice = mPricing.CalcPrice(Sku, Qty, CustomerId)
End Function

You lose IntelliSense and compile-time checking on that call. In exchange you get one narrow, greppable boundary between old and new. That is a good trade for a migration, and it is temporary: the VB6 side of it disappears slice by slice.

If you would rather not write to the registry on every workstation, turn on <EnableRegFreeCom>true</EnableRegFreeCom>. The build then emits a manifest you can ship beside the executable, which also lets two versions of the library exist during a pilot.

Step 5: run both, compare, then switch

Do not swap the call. Run both for a while.

Dim vbResult As Currency, netResult As Currency
vbResult = CalcPrice(Sku, Qty, CustomerId)
netResult = NewPrice(Sku, Qty, CustomerId)
If vbResult <> netResult Then LogDivergence Sku, Qty, CustomerId, vbResult, netResult
CalcPriceShim = vbResult   ' old code still wins

This is a shadow run. Real users, real inputs, old answer returned, differences logged. Two weeks of it will find the case your baseline CSV missed, because production always has one. When the divergence log has been empty for a full billing cycle, flip the last line to netResult, keep the shim in place, and leave yourself a configuration switch that puts the old path back without a rebuild.

Where AI helps, and where it does not

AI tooling is genuinely good at the first pass: reading a 4,000-line .bas file, listing the branches, drafting the C# translation, and generating hundreds of baseline cases from a data dictionary. We use it for exactly that, and it saves days.

It is unreliable on the things that decide whether the migration is correct. It will translate Currency to double and lose a hundredth of a cent per line. It will silently normalize VB6's default-property and implicit-conversion behavior. It cannot know that the ten-line block guarded by If CustomerId = 4471 is a workaround for one customer's contract from 2011, which is either a rule to keep or a bug to fix, and only a person at the customer can say which. A senior engineer decides what is a contract; the model drafts the code.

What you have after one module

One module of the system runs on .NET 8, in production, called by the application that still owns the screens. You have a baseline file, a divergence log, a build that produces both artifacts, and a measured number for how long a module of that size takes. The second module is estimated from that number instead of from a reading of the code.

That is the whole method: a seam, a harness, one slice, evidence. On the web side, the seam is a reverse proxy and the sequencing question is which page moves first, which we cover in sequencing a strangler migration on WebForms. On the desktop, the seam is COM. The discipline is the same.

If you have a VB6 application in production and want a second opinion on where its seam is, tell us what it does and a senior engineer will reply within one business day.