Getting off server-side Excel automation during a .NET 8 migration

Almost every system in these stacks has an Excel habit. A WebForms page that builds a workbook for the controller, a VB6 form that opens Excel to print an invoice, an Access module with CreateObject("Excel.Application"), a nightly job that writes a spreadsheet to a share the bank picks up. The code was reasonable in 2004. On a .NET 8 slice it is a dead end, and it is usually the thing that blocks a migration nobody planned for.

This tutorial covers how we get off server-side Office automation: how to find every call site, what to replace it with, and how to prove the new file is the same file. It assumes .NET 8 and a legacy app you are strangling one slice at a time.

Why this has to go

Microsoft's own guidance is unambiguous: Office is not supported for server-side automation (Considerations for server-side Automation of Office). The failure modes we actually see on customer servers:

  • Orphaned processes. EXCEL.EXE instances accumulate because a COM reference was never released on an error path. Memory climbs until the app pool recycles at 3am and the report job produces a zero-byte file.
  • Interactive-user assumptions. Office wants a desktop, a user profile and a licensed identity. Under an app pool identity or a service account it works until a modal dialog appears, then it blocks forever.
  • Licensing. Running Office on a server to serve many users is a license question, not just a technical one.
  • Bitness and installs. A 64-bit .NET 8 process cannot load 32-bit Office interop, and nobody wants Office installed on a web server in the first place.

There is no version of this that gets better by moving to .NET 8. The interop dependency has to be replaced in the same slice as the code that calls it.

Step 1: find every call site

Before estimating anything, count. From the repository root:

# VB6, Access modules, Classic ASP
grep -rniE "CreateObject\(\"?(Excel|Word)\.Application" --include=*.bas --include=*.cls --include=*.frm --include=*.asp .

# .NET code-behind and class libraries
grep -rniE "Microsoft\.Office\.Interop|new (Excel|Word)\.Application|Workbooks\.(Add|Open)" --include=*.cs --include=*.vb .

# project references that give it away
grep -rniE "Microsoft\.Office\.Interop\.(Excel|Word)|Interop\.Excel" --include=*.csproj --include=*.vbproj --include=*.vbp .

Then look for the other two ways legacy apps make spreadsheets, because they need different answers:

# HTML tables served with an Excel content type (the "fake xls" trick)
grep -rniE "application/vnd\.ms-excel|content-type.*msexcel" --include=*.aspx --include=*.cs --include=*.vb --include=*.asp .

# OLEDB/Jet reading or writing workbooks
grep -rniE "Microsoft\.(Jet|ACE)\.OLEDB.*Excel|Extended Properties=\"?Excel" -r .

Put the results in a table with four columns: call site, who consumes the output, is the output read by a human or by a machine, and does formatting matter. That table decides the replacement for each one, and it is small enough to finish in a day on a typical system.

Step 2: pick the replacement per output

There are four sane targets and one to avoid.

Output todayReplace withWhen
.xlsx for people (formatting, formulas, multiple sheets)ClosedXMLDefault choice. Wraps the Open XML SDK in an API that reads like the interop code you are deleting.
.xlsx for a machine, large or streamingOpen XML SDK with SAX-style writingHundreds of thousands of rows, or memory limits. More code, much less memory.
CSV or fixed-width feedPlain writer, invariant cultureIf a bank or ERP parses it, it was never a spreadsheet. Stop pretending.
.xls (BIFF8) a partner insists onNPOIOnly when the consumer genuinely cannot read .xlsx. Verify that claim first.
PDF from Word interopA dedicated PDF library or a report engineCovered separately; do not route it through Office either.

What to avoid: replacing interop with a headless-Office-in-a-container arrangement. It moves the same fragility somewhere with fewer eyes on it.

Read the old code carefully before you choose. Interop code often relies on Excel to do work: a formula that recalculates on open, a pivot table refresh, an .xls template with named ranges. Formulas and named ranges ClosedXML handles. A pivot refresh at generation time it does not, and that is a design decision to make in the open, not a bug to discover in production.

Step 3: write the replacement behind the same seam

Do not scatter workbook code through the new slice. One interface, one implementation, so the next report is cheap and the harness has something to point at:

public interface IWorkbookWriter
{
    Task<byte[]> BuildAgingReportAsync(IReadOnlyList<AgingRow> rows, DateOnly asOf);
}

The ClosedXML implementation of a typical aging report, formatting and all:

public sealed class ClosedXmlWorkbookWriter : IWorkbookWriter
{
    public Task<byte[]> BuildAgingReportAsync(
        IReadOnlyList<AgingRow> rows, DateOnly asOf)
    {
        using var workbook = new XLWorkbook();
        var sheet = workbook.AddWorksheet("Aging");

        sheet.Cell("A1").Value = $"Accounts receivable aging as of {asOf:MM/dd/yyyy}";
        sheet.Range("A1:E1").Merge().Style.Font.SetBold();

        var header = sheet.Row(3);
        header.Cell(1).Value = "Customer";
        header.Cell(2).Value = "Current";
        header.Cell(3).Value = "31-60";
        header.Cell(4).Value = "61-90";
        header.Cell(5).Value = "Over 90";
        header.Style.Font.SetBold().Fill.SetBackgroundColor(XLColor.LightGray);

        var first = 4;
        for (var i = 0; i < rows.Count; i++)
        {
            var r = sheet.Row(first + i);
            r.Cell(1).Value = rows[i].CustomerName;
            r.Cell(2).Value = rows[i].Current;
            r.Cell(3).Value = rows[i].Days31To60;
            r.Cell(4).Value = rows[i].Days61To90;
            r.Cell(5).Value = rows[i].Over90;
            r.Cells(2, 5).Style.NumberFormat.Format = "#,##0.00";
        }

        var last = first + rows.Count - 1;
        var total = sheet.Row(last + 1);
        total.Cell(1).Value = "Total";
        for (var col = 2; col <= 5; col++)
        {
            total.Cell(col).FormulaA1 =
                $"SUM({sheet.Cell(first, col).Address}:{sheet.Cell(last, col).Address})";
            total.Cell(col).Style.NumberFormat.Format = "#,##0.00";
        }
        total.Style.Font.SetBold();

        sheet.Columns().AdjustToContents();
        sheet.SheetView.FreezeRows(3);

        using var stream = new MemoryStream();
        workbook.SaveAs(stream);
        return Task.FromResult(stream.ToArray());
    }
}

Note what is not in there: no Marshal.ReleaseComObject, no GC.Collect() pair, no try/finally that kills a stray process. That deleted ceremony is most of the reason interop code was long.

Serving it from a Blazor or Razor Pages slice:

app.MapGet("/reports/aging", async (
    IWorkbookWriter writer, IAgingQueries queries, DateOnly? asOf) =>
{
    var date = asOf ?? DateOnly.FromDateTime(DateTime.Today);
    var bytes = await writer.BuildAgingReportAsync(await queries.GetRowsAsync(date), date);
    return Results.File(bytes,
        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
        $"aging-{date:yyyyMMdd}.xlsx");
});

If the legacy page is still the front door, this endpoint sits behind the routing shim and the old URL is proxied to it. Nobody's bookmark changes.

If the output goes to a machine

When the consumer is a bank, a 3PL or an ERP import, drop the workbook entirely and write the flat file the specification actually asks for. Two rules we hold to: CultureInfo.InvariantCulture on every number and date, and the line ending, encoding and byte-order-mark decided explicitly rather than inherited from whatever the framework defaults to. Legacy VB6 wrote CRLF and Windows-1252; a naive .NET 8 rewrite writes LF and UTF-8 with a BOM, and the receiving system rejects the file on a Monday morning.

Step 4: prove the file did not change

This is the part teams skip. A workbook is not a text file, so byte comparison against the legacy output is useless: the ZIP container, the generator string and the internal ordering all differ. Compare the content instead. Read both workbooks, project each to a normalized text form, and use the golden-master approach:

static string Describe(Stream xlsx)
{
    using var wb = new XLWorkbook(xlsx);
    var sb = new StringBuilder();
    foreach (var ws in wb.Worksheets.OrderBy(w => w.Name, StringComparer.Ordinal))
    {
        sb.AppendLine($"# sheet {ws.Name}");
        foreach (var cell in ws.CellsUsed().OrderBy(c => c.Address.RowNumber)
                                           .ThenBy(c => c.Address.ColumnNumber))
        {
            var value = cell.CachedValue.ToString();          // computed, not the formula
            var format = cell.Style.NumberFormat.Format;
            sb.AppendLine($"{cell.Address.ToStringRelative()}|{value}|{format}");
        }
    }
    return sb.ToString();
}

[Fact]
public async Task AgingReport_MatchesLegacyOutput()
{
    var legacy = File.OpenRead("Fixtures/aging-legacy-20260131.xlsx");
    var rebuilt = new MemoryStream(await BuildRebuiltAging(new DateOnly(2026, 1, 31)));

    Assert.Equal(Describe(legacy), Describe(rebuilt));
}

Generate the fixtures from the legacy system first, for a frozen date and a restored copy of the database, and commit them. Then work through the diff. In our experience the diffs fall into three piles:

  • Rounding. The legacy report rounded in VB6 (banker's rounding in some paths, Format$ in others) and the new one rounds in SQL or C#. Decide which total is correct with the person who signs off the report, then encode it.
  • Dates and blank cells. Interop wrote an empty string where the rebuild writes a null, or wrote a date as text. Both render the same on screen and sort differently in Excel.
  • Real legacy bugs. A subtotal that excludes one aging bucket, a filter that silently drops rows with a null customer. Fix them deliberately, update the approved fixture, and write one line in the migration log saying you changed a number on purpose. That log is what keeps a fixed bug from being reported as a regression.

For the flat-file cases, keep byte-level comparison. There the bytes are the contract.

Step 5: retire the interop dependency for real

A slice is not finished while the old path can still be reached. Close it out:

  1. Remove the interop reference from the project and the Interop.Excel assemblies from the deployment.
  2. Uninstall Office from the server, or at minimum remove the service account's rights to launch it, and note the date.
  3. Add a check that fails the build if Microsoft.Office.Interop reappears in a project file. It comes back otherwise, usually in a hurry, usually on a Friday.
  4. Watch the app pool's memory for a week. The absence of a slow climb is the confirmation that the orphaned-process problem is gone.

What we would not do

We would not treat this as a standalone project. Replacing Excel automation across an entire system in one pass touches every module and delivers nothing a user can see. It rides along with the slice that owns each report, and it gets counted in that slice's estimate.

We would also not accept "the spreadsheet just needs to look the same" as a requirement without asking who reads it. Half the time the answer is that a machine reads it, and the correct output was never a spreadsheet.

If you want the harness mechanics in more depth, golden-master tests for legacy web apps builds the same idea for HTTP responses. The pattern is identical: record the old output, normalize what does not matter, and make the new implementation agree.