Guide - Making an inherited .NET codebase safe to change

The person who wrote it has left. It runs in production and earns money. Nobody currently on the team has opened most of it, and every estimate carries a silent multiplier because nobody is sure what a change will touch.

This is the order to work in, and the two decisions that determine whether the codebase gets better or just gets older.

Guide · for a developer or lead

Making an Inherited .NET Codebase Safe to Change

Will Pickeral, William Belle LLC · support@williambelle.co

The person who wrote it has left. It runs in production and earns money. Nobody currently on the team has opened most of it, changes take longer than they should, and every estimate carries a silent multiplier because nobody is sure what a change will touch.

This is the order to work in, and the two decisions that determine whether the codebase gets better or just gets older. It applies to a codebase inherited from a departed developer, from an agency, or from an AI coding tool — the arc is the same, and so is the fix.


Does it build from a clean checkout?

Before any judgment about the code, answer this: clone the repository into an empty directory on a machine that has never built it, and run the build.

It is a low bar and it fails more often than anyone expects. What it finds:

  • A dependency that only exists in somebody's local package folder.
  • A connection string, certificate, or key baked into a file that was never committed, so the build works only where a copy of it already sits.
  • A project referencing an SDK version nobody records anywhere.
  • A build step someone runs by hand before the real build, and remembers.

Fix these first, and fix them in the repository rather than in a document describing what to install. The condition you want is that a new developer is productive on their first afternoon, and every item on that list is a day of somebody's time, every time.

Add a global.json pinning the SDK version while you are here. Without it the build uses whatever the machine has, and "works on mine" becomes a real explanation rather than a joke.


Get it into a pipeline before you change anything

The instinct is to start improving. Do not, yet.

A build running on every push tells you within minutes when something breaks, and it is the only way to know whether a change you make later was safe. Making changes to an unfamiliar codebase without that is how a two-line fix becomes a Friday evening.

Build and test only — no deployment. That is a smaller piece of work than it sounds and it is worth having on its own. Setting one up in Azure DevOps covers the shape.


You do not have specifications. You have behavior.

The tests you wish existed do not, and writing tests for what the code should do is a trap: you do not know yet, and the parts you are most likely to guess wrong about are the parts somebody is depending on.

Write tests that record what it does — including the parts that look wrong.

Take the function you are about to change, feed it real inputs, and record the outputs as expectations. Every unwanted behavior you capture this way is a decision made visible, and half of them turn out to be relied on by something. That surprising branch in the discount calculation exists because one customer negotiated it in 2019 and nobody wrote it down anywhere else.

Test at the seam you can actually reach. In a codebase with no dependency injection and static calls to the database, unit tests are not available without a refactor you are not ready to do. An HTTP-level test against a real database in a container is worth more than a purist test you cannot write, and it catches the thing you are afraid of.

Cover the paths you are about to touch and the paths that would embarrass you if they broke. Not the whole codebase. Coverage as a goal produces tests written to raise a number.


Upgrade the framework in steps, and one project at a time

An application on an unsupported .NET version is a security problem, not a preference. But a framework upgrade is where inherited codebases most often stall, because it is attempted as one change.

Know which versions you can stop on. Even-numbered .NET releases are supported for three years, odd-numbered ones for eighteen months. Upgrading to an odd-numbered release means doing this again in a year, and that is sometimes right and always a decision rather than an accident.

The order that works:

  1. Central package management first. A Directory.Packages.props moves every version to one file, and the upgrade becomes a change in one place instead of an archaeology exercise across a dozen .csproj files.
  2. Update packages to their latest version on the current framework, before changing the framework itself. This separates "the library changed" from "the runtime changed", which are two different debugging sessions and much easier apart than together.
  3. Move projects one at a time, starting with the leaves — libraries with no dependencies of their own. A library can multi-target both frameworks during the transition, so the application keeps building while the projects underneath it move.
  4. The application project last, when everything it references has already moved.

The .NET Upgrade Assistant does the mechanical parts of this and is worth running. Read what it changes rather than accepting the result: it is good at project files and unopinionated about your code.

dotnet list package --vulnerable --include-transitive is the command to run before and after. --include-transitive is the important half — most vulnerable packages in a real solution arrive through something else, and the list without it looks reassuring and is not.


The rewrite is almost never the answer

Somebody will suggest it, usually within a month, and the argument is always the same: the code is bad, we understand the domain now, we would move faster.

What the argument leaves out is that the existing system encodes years of decisions nobody has written down — the special case for one customer, the retry that exists because a vendor's API is unreliable on Tuesdays, the validation that looks redundant and prevents a support call. A rewrite reproduces the features somebody remembers and loses the ones nobody does, and it discovers this one customer complaint at a time.

Meanwhile the business needs changes to the current system while the replacement is built, and either it gets them and the replacement never catches up, or it does not and the pressure to ship something incomplete becomes irresistible.

Replace it in pieces instead. Put the new code in front of the old, route one capability at a time to the new path, and delete the old path when nothing reaches it. Each step is releasable, reversible, and delivers something. The whole job may take longer on paper, and it is finishable, which the rewrite frequently is not.

The honest exception is a platform that is genuinely gone — a runtime with no supported upgrade path, or a dependency that no longer exists. That is a migration with no incremental route, and it should be recognized as the rare case rather than reached for as the default.


Then make it harder to break

Once there is a pipeline and tests around what you are changing, the cheap improvements are worth taking:

  • Turn on nullable reference types, file by file rather than solution-wide. #nullable enable at the top of a file you are already working in makes the compiler point at the null checks that were always missing there. Solution-wide, on a codebase of any size, produces thousands of warnings nobody reads.
  • Treat warnings as errors, after you have cleaned the existing ones. A warning nobody acts on is noise that hides the next real one.
  • Delete dead code rather than commenting it. It is in version control. Commented-out code is read by every future reader as possibly important, and it costs each of them the same minute.

None of these is urgent on its own. Together they are the difference between a codebase that gets better as it is worked in and one that only gets older.


The order, if you are starting now

  1. Build from a clean checkout. Fix what stops it, in the repository.
  2. Build and test in a pipeline, on every push, deploying nothing.
  3. Tests around the part you are about to change, recording what it does today.
  4. Make the change. The first real delivery, and the point where the work above starts paying.
  5. Package updates, then the framework, one project at a time.
  6. Nullable and warnings, as you go, in the files you are already in.

Steps 1 and 2 usually take days rather than weeks, and they are what turns an estimate into something you can defend.


What this does not solve

None of this tells you whether the architecture is right. It makes the codebase safe to change, which is the prerequisite for finding out — an architectural decision taken on a codebase nobody can safely modify is a guess with no way to test it.

It also does not fix a team's relationship with the code. If changes are frightening because nobody has time to understand them, tooling helps at the margin and the actual constraint is somebody's calendar.


Get a free 20-minute review — tell me what you inherited and where it hurts, and I'll tell you where I'd start, and why. Nothing to prepare.

Twenty minutes, and nothing to prepare.

Tell me what you need built or fixed, and I'll tell you where I'd start.

Or send it in writing →

Something this didn’t answer?

Ask it here and I will write back. I'll get back to you within one business day.