A three‑part series · Part 2

The Bugs a Faithful Rewrite Surfaces

The latent defects a careful rebuild uncovers — and the judgment calls between preserving legacy behavior and quietly fixing it.

There's a paradox at the heart of a faithful rewrite. Your stated goal is to change nothing observable — and yet the act of rebuilding a system, line by line, in a modern stack, is one of the most thorough audits that system will ever get. You read every branch. You port every quirk. And in doing so, you inevitably discover things the old code had been quietly getting away with for years.

Here are three of them, and the judgment each one forced.

Story 1: the email that worked on Windows and failed on Linux

The migration also meant moving from Windows/IIS to Linux. Most of the stack didn't care. The email path did — and it failed in the most misleading way possible.

Transactional emails (password resets, notifications, contact messages) went out through AWS SES over SMTP. The legacy code used the classic .NET client, System.Net.Mail.SmtpClient, with TLS on port 587. We ported it faithfully:

EmailSender.cs (legacy)
using var client = new SmtpClient(host, 587)
{
    EnableSsl = true,
    Credentials = new NetworkCredential(user, pass)
};
client.Send(message);

On the Windows dev machine: emails delivered, every time. On the Linux server: SmtpException: 530 Authentication required — even though the credentials were byte‑identical and a direct SMTP test from the same server authenticated fine.

The root cause is a genuinely nasty cross‑platform difference. On port 587, SES advertises its AUTH capability only after the connection is upgraded via STARTTLS. System.Net.Mail.SmtpClient on Linux didn't re‑read the server's capabilities after the STARTTLS upgrade, so it never issued an AUTH command — and SES, seeing an unauthenticated sender, rejected the message. On Windows the same client did the right thing. Same code, same credentials, different platform, opposite outcome.

Windows Linux 530
Fig. 1Same code, same credentials: the email delivered on Windows and bounced with a 530 Authentication required on Linux — because the old SMTP client never re‑read the AUTH capability after STARTTLS.

Two lessons came out of this one, and the second is the more important:

  • The fix was to stop using a deprecated client. Microsoft itself no longer recommends System.Net.Mail.SmtpClient for new work. We swapped it for MailKit, which performs STARTTLS and authentication correctly and consistently across platforms. Because everything sat behind a single IEmailSender interface, this was a one‑file change in the Infrastructure layer — no controller, no business logic, no configuration touched.
EmailSender.cs (MailKit)
using var client = new MailKit.Net.Smtp.SmtpClient();
client.Connect(host, 587, SecureSocketOptions.StartTls);
client.Authenticate(user, pass);
client.Send(message);
  • The bug was hiding behind a silent catch. Only one of the email‑sending endpoints actually surfaced the failure (as an HTTP 500). The others wrapped the send in a try/catch that swallowed the exception and reported success to the client. Those endpoints looked perfectly healthy — they returned "OK" while quietly delivering nothing. If we'd trusted the happy‑path response, we'd have shipped an API that silently dropped every password‑reset email. A caught‑and‑ignored exception is a bug with the lights turned off. We replaced the silent swallow with real logging.

This is also a case for testing in the real environment, not just the one that's convenient. The failure was 100% reproducible — but only on Linux. A dev‑box‑only test suite would have called it green.

Story 2: the legacy bug we chose to fix

Parity is the default. But "reproduce the legacy behavior exactly" runs headfirst into "…even when the legacy behavior is wrong?" — and sometimes the answer is no.

One endpoint handled password resets. Reading the legacy implementation closely, we found it did something no one had intended: instead of emailing the user a secure reset link, it generated a new password server‑side and emailed that, using an ad‑hoc email body rather than the account system's managed template. It worked, technically. It was also a security and UX anti‑pattern that predated anyone currently on the project.

Here the parity rule and the "do no harm" rule point in opposite directions, and you have to make an explicit call:

  • If a client depended on the exact response, we had to preserve it.
  • If the behavior was a latent defect that no client depended on for correctness, reproducing it faithfully would mean knowingly shipping a bug.

We chose to fix it — deliberately, and with sign‑off — reworking the flow to send a proper reset link built from the account system's managed email template, while keeping the response shape the client expected unchanged. The client couldn't tell the difference; the user got a materially better and safer experience.

The rule we settled on: parity is the default; correctness is a deliberate, documented exception. You don't quietly "improve" things mid‑migration — that's how you introduce the exact regressions you're trying to avoid. But when you find a real defect that nothing depends on, you fix it on purpose, you write down why, and you get a human to agree.

new pw: •••••• emailed a password a secure reset link
Fig. 2The defect we fixed on purpose: stop emailing a freshly‑generated password, send a proper reset link from the account system's template — while keeping the response shape byte‑for‑byte identical.

Story 3: the stored procedure that had moved on

Part 1 described our decision to keep the database and call the existing stored procedures as‑is. That paid off enormously — but it also surfaced a quiet form of drift.

While porting the account‑creation path, our new strongly‑typed call into a stored procedure failed: the procedure now required parameters that the legacy application code had never passed. At some point over the years, the stored procedure had gained new required inputs, and the old data‑access layer had simply never been updated to match — the two had drifted, and the system had kept working only because of defaults and the particular way the old code happened to call in.

Rebuilding the call from scratch put the two back in the same room and made the mismatch obvious. We reconciled it (passing the parameters the procedure now expected, matching current behavior) and moved on. But it's a good reminder: a faithful rewrite is also a reconciliation. Long‑lived systems drift internally — app code and database, configuration and reality — and rebuilding one layer against another surfaces gaps that had been silently papered over.

The theme

None of these were exotic. A deprecated library with a platform‑specific quirk. A well‑intentioned old design that had aged into a defect. Two components that had drifted apart. What they have in common is that they were all already there — dormant in the legacy system, invisible until something forced a careful second look.

That's the underrated value of a disciplined migration. Done carelessly, a rewrite is a fountain of new bugs. Done carefully — one slice at a time, each behavior reproduced deliberately and verified — it becomes an audit that surfaces the old ones, on your terms, with the time to decide what to preserve and what to fix.

Which leaves the last and scariest question: once you've rebuilt the whole thing, how do you actually flip the switch — and prove, before you do, that you haven't broken anything?

Let's Talk About Your Project

A quick 30‑minute call is all it takes to find out if we're a good fit for each other. Book a time and we'll take it from there.

Book a Call