Legacy .NET That Still Pays, Part 6: Prove It in the App, Then Promote It

dotnetcsharparchitecturetesting
June 29, 2026·9 min read
Part 6 of Legacy .NET That Still Pays →

A timespan validator in our service started life as a private helper, with a comment pointing at the ticket that would move it. It lived there until it had proved itself. Then it moved into our shared library, with its tests, and a third change deleted the local copy and pointed the service at the library version.

Three pieces of work for one small method sounds like overkill. It turned out to be worth it, because proving the helper in the app first turned up a parsing surprise that would otherwise have gone straight into a library other services depend on. This post is about promoting code to a shared library that way: build it where it's needed, promote it once it's proven, then retrofit the app.

This is Part 6 of Legacy .NET That Still Pays, and the last in this run. The lifecycle was already a habit in our codebase before I used it. I'm writing up how it works and what it caught. The library and method names are left out.

The gap: bad intervals fail silently

The admin UI from Part 3 lets support engineers change settings at runtime. Before a save reaches the database, validators check each value: is it a valid integer, a valid boolean, a valid time of day.

There was no validator for time intervals, the settings that say "wait two minutes" or "retry every half second". The time-of-day validator didn't cover them, because an interval can have days and fractions of a second, and a time of day can't.

Without a validator, someone could type a bad interval and save it. The typed setting would fail to parse it and quietly fall back to its default. The service would carry on with the default, and the person who made the change would think it had worked. That's the worst kind of config bug: no error, just behaviour that doesn't change when you expect it to.

The weak paths: library first, or copies forever

There are two tempting ways to add a validator like this, and both have real costs.

The first is to write it straight into the shared library on day one. It feels tidy, since that's where validators live. But the library is a separate repository, with its own pipeline and its own release, and nothing in the service can use the new method until a new package is published and consumed. You're designing a public API for other teams before you've used it once yourself, and every mistake you find after that is a breaking change or a second release.

The second is to write a private helper in the service and leave it there. It's quick and it works. Then another service needs the same thing and writes its own, slightly different, and now there are two validators that disagree about what a valid interval is.

The better path: three steps

The lifecycle we use has three steps, and each one is a separate, small change.

Step one: build it in the app

The validator went into the service as a static method, next to where it's used, with a comment pointing at the promotion ticket:

csharp
// TODO: promote to the shared library (promotion ticket in the backlog)
internal static bool IsValidInterval(string value)
{
    return value == null || TimeSpan.TryParseExact(value, "c", null, out _);
}

A null value is allowed, meaning "not set", to match the library's other validators. The format is "c", the constant format, because that's the format the typed setting uses when it parses the value at runtime.

The validator has to agree with the parser, not with your idea of what's valid. If the two disagree, the validator either rejects values the service would happily use, or accepts values it will silently throw away.

What proving it taught me

I wrote the tests first, and two of them failed in a way I didn't expect. I'd listed "1:2:3" and "00:00" as invalid. The constant format accepts both: single-digit components are fine, and the seconds can be left off.

My first instinct was that the validator was too loose. It wasn't. It matched exactly what the runtime parser accepts, and that's the whole point, so it was my test cases that were wrong. I corrected them.

That's the surprise that would have shipped to every consumer if I'd written the library version first. Either the library would have carried a stricter rule that disagreed with the parser, or someone would have "fixed" it later, as a breaking change, in a package other teams depend on.

I also checked every existing interval default in the service against the new validator, to make sure turning it on wouldn't reject a value the service already shipped with. They all passed.

Wiring it up, and a simpler design from review

My first design for wiring used each key's default value as a proxy for its type. If a setting's default parsed as an interval, validate that setting as an interval.

It worked, but it depended on every setting's default being known. As Part 3 mentioned, defaults are only known for classes carrying the settings attribute. When I checked coverage, a noticeable number of interval settings were missed because of that, and the design needed an exclusion list for settings whose defaults happened to parse as intervals but weren't.

Review suggested something simpler. Our interval settings already follow a naming convention, so match on the key name instead:

csharp
var intervalKeys = categories
    .SelectMany(c => c.Settings.Keys)
    .Where(key => key.Contains(":Interval", StringComparison.OrdinalIgnoreCase))
    .Distinct();
 
foreach (var key in intervalKeys)
    validators.Add(new KeyValidator(key, SettingValidators.IsValidInterval));

It's more explicit, it doesn't need defaults, and it uses a convention the codebase already has. Review also changed the method's visibility to match the library's validators, which made step two a straight move rather than a redesign.

Step two: promote it

Once the validator had shipped in the service, it moved into the shared library, alongside the other validators, with a fuller set of tests following the library's own conventions. A new package was published.

Because the behaviour was already settled, there were no design decisions left to make in the library. It was the same method, the same null rule, the same format, with more tests around it.

Step three: retrofit the app

The last change updated the service to the new package, deleted the local copy and its tests, and pointed the wiring at the library version. The tests now live with the code they test, in the library.

The retrofit itself is a short checklist: update the package, delete the local method and its tests, point every caller at the library version, run the full suite, and search for the old name to make sure nothing still refers to it. The last step is the one that catches a second caller nobody remembered.

This wasn't the first time. An earlier validator for hexadecimal values had gone through exactly the same three steps, which is why I knew the route. It's a pattern in the codebase, not a one-off.

What makes something worth promoting

Not every helper should go to the library, and the questions I ask are simple.

Does a second caller exist, or is one certain? Has the behaviour settled, so the library won't need a breaking change next month? And does it depend on anything that belongs to the app, like its types or its config keys?

The interval validator passed all three. Other services have interval settings, the behaviour was pinned by tests and a real caller, and it's a pure function of a string. The key-name wiring didn't pass, because the naming convention belongs to our service, so that part stayed in the app.

Why null means valid

The null rule deserves a sentence of its own, because it looks like a gap. A null value means "not set", and the service will use the default. Every other validator in the library already treats null that way, so the new one does too. Consistency with the library's existing rules beats my local preference, especially in code other teams will call.

Why the order matters

Building in the app first means the design is tested against a real caller before anyone else depends on it. The mistakes are cheap, because they're private.

Promoting only once proven means the library gets code that's already been used, with its surprises already found. The library's API is decided by experience rather than by guessing what consumers will want.

Retrofitting straight after means the app doesn't keep a private fork of something the library now owns. The third step is the one that gets skipped, and it's the one that stops two copies drifting apart.

Where it falls over

Between step two and step three, there are two copies. The library has the method and the app still has its own. If the retrofit slips, they can drift, and someone reading the app has no way to know which one is canonical without finding the comment.

The local copy lingers until someone does the cleanup. I've seen that on another change of mine: an interim local copy of a library validation, added so a fix could ship before the library release, with a cleanup step to delete it afterwards. That step is easy to leave on a list forever.

It's also three pieces of work instead of one, across two repositories and two pipelines. For a tiny helper, that's hard to justify to someone who hasn't been bitten by a library design decision made too early. The honest answer is that it's worth it for anything other services will call, and probably not for something only one service will ever need.

And the key-name convention only catches keys that follow it. An interval setting named without the suffix won't be validated, and nothing will tell you.

Earn your place in the library

My position: code should prove itself in the app before it moves into a shared library. Build it where it's needed, promote it once it's been used, then retrofit the app and delete the local copy straight away.

Next time you're about to add something to a shared library, write it in the app first, with a comment pointing at the promotion ticket. Let it meet a real caller, and a set of tests that might surprise you. Then promote it, and put the retrofit on the same board as the promotion, so the third step doesn't get forgotten.

That's the end of this run of Legacy .NET That Still Pays: deferred logging, two-phase disposal, runtime config, async continuations, refactoring by shape and promoting code once it's proven. The rest of my writing is on the blog.