What to do with Crystal Reports during a .NET 8 migration

Most migration plans we are handed count screens and tables. They rarely count reports. Then somewhere in week five a controller mentions the aged receivables report that goes to the bank every quarter, and someone opens the reports folder and finds four hundred .rpt files.

Reports are the largest uncounted item in a typical WebForms, VB6, Classic ASP or Access migration, and they are the item most likely to have a hard runtime constraint attached. SAP's Crystal Reports runtime for Visual Studio targets .NET Framework. There is no supported in-process Crystal engine you can dotnet add package into a .NET 8 web app. Access reports run inside Access. Neither fact goes away because a plan does not mention it.

This is the sequence we use.

Step 1: count them before you estimate anything

Start with the file system and the database, not with the users. Users remember the reports they like.

Get-ChildItem -Path \\app01\c$\inetpub\wwwroot -Include *.rpt,*.rdl,*.rdlc -Recurse |
  Select-Object FullName, Length, LastWriteTime |
  Export-Csv .\report-inventory.csv -NoTypeInformation

That gives you the artifacts. It does not give you usage, and usage is the number that decides the work. Get usage from wherever the application records it. If reports are launched through a page, IIS logs are enough:

-- IIS logs bulk-loaded into a staging table
SELECT cs_uri_query AS report, COUNT(*) AS runs, MAX(log_date) AS last_run
FROM iis_log
WHERE cs_uri_stem LIKE '%/reports/%'
  AND log_date >= DATEADD(month, -13, GETDATE())
GROUP BY cs_uri_query
ORDER BY runs DESC;

Thirteen months, so that annual report shows up. If the application has no logging at all, add one line of logging to the report launcher and wait a month. A month of real data is cheaper than a quarter of arguing.

On every engagement where we have done this, the distribution has looked the same shape: a small number of reports run constantly, a long tail runs once or twice a year, and a substantial block has not run at all in thirteen months. The last group is the finding. It is also the only part of a report library you can migrate for free.

Step 2: sort into four buckets

Go through the inventory with the person who owns the numbers, usually the controller, and put every report in exactly one bucket.

Retire. No runs in thirteen months and nobody claims it. Do not delete the .rpt file; move it to an archive folder and record the date. If someone asks for it in month four, you restore it in an hour. In practice they rarely ask.

Export once. Runs once or twice a year, and what the recipient actually needs is the historical numbers, not the ability to re-run it. Generate the last few years as PDFs, put them somewhere permanent, and retire the definition.

Keep running as-is. Real, recurring, and complex. These stay on the old engine for now, on a report island (step 3). This bucket is usually larger than anyone wants it to be, and that is fine. A report that keeps working is not technical debt you are obliged to pay off this year.

Rebuild. Frequently run, or embedded in a workflow you are migrating anyway, or already broken. These get rebuilt on the new stack, budgeted as their own slices.

The point of the exercise is that only the fourth bucket costs migration hours. We have seen a four-hundred-report library come out as roughly 150 retire, 60 export once, 160 keep as-is, and 30 rebuild. Thirty reports is a plan. Four hundred is a reason to give up.

Step 3: build the report island

The keep-as-is bucket needs somewhere to live once the main application starts moving. That place is a small .NET Framework service on a Windows host that does nothing but render reports, sitting behind the same reverse proxy as everything else. If you have already stood up the shim from the routing shim tutorial, this is one more cluster in it.

The island exposes one endpoint:

// .NET Framework 4.8 host. This project does not move to .NET 8.
[HttpPost, Route("render/{reportName}")]
public HttpResponseMessage Render(string reportName, [FromBody] ReportRequest request)
{
    var path = ReportCatalog.ResolvePath(reportName); // allow-list only

    using var doc = new ReportDocument();
    doc.Load(path);
    doc.SetDatabaseLogon(_cfg.DbUser, _cfg.DbPassword, _cfg.DbServer, _cfg.DbName);

    foreach (var p in request.Parameters)
        doc.SetParameterValue(p.Key, p.Value);

    var stream = doc.ExportToStream(ExportFormatType.PortableDocFormat);

    var response = new HttpResponseMessage(HttpStatusCode.OK)
    {
        Content = new StreamContent(stream)
    };
    response.Content.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
    return response;
}

Three rules for the island, learned the hard way:

  • Allow-list report names. ResolvePath maps a known name to a known file. Never concatenate a caller-supplied path; a report renderer that accepts arbitrary paths is a file-read primitive with a database connection attached.
  • Give it a read-only database login. Reports read. If a report writes, that is a stored procedure doing something it should not, and you want to find out now rather than during cutover.
  • Serialize the renders. The Crystal runtime is not friendly under concurrency and has print-job limits. A queue with a small degree of parallelism and a timeout is more reliable than letting fifty users hit it at 8:59 on the first of the month.

New .NET 8 code then calls the island over HTTP and streams the PDF back. From the user's side, the report link works the way it always did. From your side, the last .NET Framework dependency is now one small service with one job, and it can be replaced or retired on its own schedule instead of blocking the migration.

Step 4: prove the rebuilt reports match

For the rebuild bucket, pick a renderer that runs on .NET 8. We generally use QuestPDF for documents whose layout is a contract, such as invoices and statements, and SQL Server Reporting Services when the customer already runs it and the report is a grid with grouping. Either way the report becomes: a SQL query or view, a model, and a layout.

Put the query in one place and test it separately from the layout. Almost every report defect we find is in the query, not the rendering: a join that silently drops rows with a null cost center, a date filter that is inclusive on one end, a subtotal computed over the visible page instead of the group.

Then do the parity test. Compare extracted text, not pixels:

[Theory]
[InlineData("AR-AGING", "2026-06-30")]
[InlineData("AR-AGING", "2026-09-30")]
public async Task RebuiltReport_MatchesLegacyTotals(string report, string asOf)
{
    var legacyPdf = await _island.RenderAsync(report, new { AsOf = asOf });
    var newPdf    = await _reports.RenderAsync(report, new { AsOf = asOf });

    var legacyNumbers = ExtractNumbers(PdfText.Extract(legacyPdf));
    var newNumbers    = ExtractNumbers(PdfText.Extract(newPdf));

    Assert.Equal(legacyNumbers, newNumbers);
}

// Every currency-looking token, in document order.
static IReadOnlyList<decimal> ExtractNumbers(string text) =>
    Regex.Matches(text, @"-?\(?\$?\d{1,3}(,\d{3})*(\.\d{2})?\)?")
         .Select(m => ParseAccounting(m.Value))
         .ToList();

Run it across a spread of parameter values, including a period with no data and a period that straddles a fiscal year end. Comparing the ordered list of numbers catches the failures that matter: a changed total, a dropped row, a shifted column. It ignores fonts and margins, which is correct, because nobody signs off a report on its kerning.

When a number does differ, resist the reflex to make the new report match. Twice now the legacy report has been the one that was wrong, and the parity test was the first thing in a decade to notice. That is a conversation with the controller, not a code change.

What we tell owners

Reports are not the hard part of a modernization, but they are the part most likely to blow an estimate, because nobody counts them until they are in the way. Counting them takes about a day. Sorting them takes a meeting. After that you are usually looking at rebuilding a few dozen, not a few hundred, and the rest either keeps running on an island you control or stops running because it already had.

If you want the number for your own system, the report inventory is one of the deliverables of a legacy codebase assessment. Or run the two queries above yourself; they are the same ones we would run.