You know the method. The one whose XML doc says “only call this after calling that.” It compiles. The tests are green. It’s shipped fine for years.
Nothing is wrong with it. And yet that one sentence of documentation is quietly the root of five separate problems scattered across the codebase.
In my case it was a feature-support check: “does this hardware support this feature?” Correct code, always has been. But I kept noticing the same guard clause showing up everywhere, so last week I pointed Claude Code at it and asked why. The answer turned out to be a design decision made years ago — and a better one that was available the whole time.
The setup
Picture a platform that manages a fleet of physical devices. Different hardware supports different features. Before the platform sends a command — say, enable recovery mode — it needs to know whether that device can do the thing at all.
So there’s a check that answers exactly that:
public FeatureSupportResult CanSupport(CapabilityProvider provider) =>
!provider.Supports<RecoveryModeCapability>()
? FeatureSupportResult.NotSupported(
"Device does not support recovery mode", nameof(RecoveryModeCapability))
: FeatureSupportResult.Supported();
And when the platform actually wants to act, a second method builds the command:
public DeviceCommand GetCommand(CapabilityProvider provider, RecoveryModeOptions options)
{
var capability = provider.GetCapability<RecoveryModeCapability>()
?? throw new InvalidOperationException("CanSupport must be called first");
return capability.GetCommand(options);
}
Two methods. CanSupport asks the question; GetCommand performs the action. They’re connected by an unwritten rule: you must ask before you act. That rule lives in an exception message and a doc comment. It does not live anywhere the compiler can see it.
That’s the crack everything else leaks out of.
What Claude Code found
I gave Claude Code the simplest possible prompt:
“This
CanSupport/GetCommandpair is duplicated across the requirements classes. Trace how the result is used across the codebase and tell me what smells.”
It came back with five symptoms — and the useful part was that they all pointed at one cause.
1. A type that permits states the domain doesn’t have. FeatureSupportResult carries a bool, a reason string, and a list of missing capabilities. But the reason and the list are only meaningful when IsSupported is false. Nothing stops you constructing Supported() with a reason, or NotSupported("") with an empty one. That’s the textbook signature of a type that wants to be a discriminated union — the same modelling problem I take apart on an order model in make illegal states unrepresentable.
2. A timing rule enforced by an exception, repeated everywhere. The ?? throw new InvalidOperationException("CanSupport must be called first") guard was copy-pasted across roughly twenty requirements classes. The throw exists because the design can’t prove the check happened.
3. A proof object that proves nothing. Someone had written a FeatureSupportingDevice.Create(...) — clearly meant as a smart constructor whose existence proves support. But it called the check and discarded the result, so a downstream handler had to re-run the same check anyway.
4. The same question asked three times. One request evaluated hardware support in the API handler, again in an event handler, and once more inside GetCommand. Not a performance problem — a coherence problem. Three call sites can disagree.
5. Ten identical guards and a stringly-typed reason. Every handler hand-converted the result into the codebase’s Result<T> railway, and the Reason was free-form English — untestable except by substring assertion, and unable to tell “this hardware lacks the capability” apart from “I don’t recognise this hardware at all.”
Lay them side by side and they collapse into one sentence:
The check produces data when it should produce capability.
A bool can be ignored, forgotten, recomputed, and re-interpreted by every consumer. Those are all things you can do to a piece of data. None of them is a thing you can do to a proof you’re holding in your hand.
The idea: hand back the thing, not permission to ask for it
This is “parse, don’t validate” applied to hardware capabilities.
CanSupport is a validator: it checks, then forgets. The replacement — call it Resolve — is a parser: it checks, then keeps the result of the check inside the type it returns. In the success case it hands back the very thing the privilege consists of: a ready-to-use command factory.
Here’s the type I landed on with Claude Code as a sounding board:
public abstract record FeatureSupport<TOptions> where TOptions : IOptions
{
private FeatureSupport() { } // closed: only Granted and Denied can exist
public sealed record Granted(Func<TOptions, DeviceCommand> CreateCommand)
: FeatureSupport<TOptions>;
public sealed record Denied(SupportDenialReason Reason)
: FeatureSupport<TOptions>;
public TResult Match<TResult>(
Func<Granted, TResult> whenGranted,
Func<Denied, TResult> whenDenied) =>
this switch
{
Granted g => whenGranted(g),
Denied d => whenDenied(d),
_ => throw new UnreachableException()
};
}
Two things worth defending:
- There’s no
IsSupportedproperty.Matchis the only way to consume the value. The moment you expose a boolean, every consumer is free to recreate exactly the blindness you just removed. AndMatch’s two mandatory parameters are your exhaustiveness check — you can’t consume the value without handling both cases. - The evidence is a factory, not the raw capability.
GrantedcarriesFunc<TOptions, DeviceCommand>, not the hardware-layer capability itself. It’s the narrowest possible privilege: “you may build this one command, and nothing else.”
The reason becomes data too, so the two genuinely different failures stop sharing a string:
public abstract record SupportDenialReason
{
private SupportDenialReason() { }
public sealed record MissingCapabilities(IReadOnlyList<string> CapabilityNames)
: SupportDenialReason;
public sealed record UnknownHardware(byte HardwareId)
: SupportDenialReason;
}
What the refactor deletes
Here’s the before and after of the check itself:
// Before — two methods, one latent InvalidOperationException
public FeatureSupportResult CanSupport(CapabilityProvider provider) => /* ... */;
public DeviceCommand GetCommand(CapabilityProvider provider, RecoveryModeOptions options)
{
var capability = provider.GetCapability<RecoveryModeCapability>()
?? throw new InvalidOperationException("CanSupport must be called first");
return capability.GetCommand(options);
}
// After — one method, the throw is gone
public FeatureSupport<RecoveryModeOptions> Resolve(CapabilityProvider provider) =>
provider.GetCapability<RecoveryModeCapability>() is { } capability
? new FeatureSupport<RecoveryModeOptions>.Granted(capability.GetCommand)
: new FeatureSupport<RecoveryModeOptions>.Denied(
new SupportDenialReason.MissingCapabilities([nameof(RecoveryModeCapability)]));
Notice what happened to the timing rule. The capability is looked up once; if it’s there, its GetCommand method is the factory we hand back. There’s no later moment at which it could be absent, because we never look it up a second time. The exception had nothing left to guard, so it’s gone — not handled better, gone.
And the ten copy-pasted handler guards collapse onto the existing result railway:
// Before (×10, copy-pasted)
var result = capabilityService.CheckSupport(feature, hardwareId);
if (!result.IsSupported)
return Failure.Create<TResponse>(
DomainErrors.FeatureNotSupported(request.DeviceId, result.Reason));
await featureService.SetFeature(device, options);
// After
return await capabilityService.ResolveSupport(feature, hardwareId)
.ToResult(request.DeviceId)
.BindAsync(_ => featureService.SetFeature(device, options));
That single ResolveSupport at the edge is also where this refactor touches functional core, imperative shell: the shell asks the outside world once, and everything further in works with the evidence it was handed.
The whole table of defects doesn’t get fixed — most of it stops being possible to write:
| Defect before | Why it disappears |
|---|---|
Twenty "must be called first" throws | No second call to misorder; the factory only exists inside Granted. |
| Illegal states (supported-with-reason) | Unrepresentable. Granted has no reason field. |
| Smart constructor discarding its check | Impossible — it can’t produce the object without a Granted to capture. |
| Triple evaluation per request | Single resolution at the boundary; the evidence travels with it. |
| “Unknown hardware” disguised as “not supported” | A distinct UnknownHardware case the API can report differently. |
Where Claude Code actually earned its keep
The design was the easy part — an afternoon of back-and-forth. The migration is what usually kills a change like this: it touches the requirements classes, the configurators, the handlers, the event flow, and every test around them. That’s the reason a change this obviously-good sits on the backlog for a year.
I ran it in phases and let Claude Code do the mechanical work:
- Introduce the new union alongside the old type, with a temporary adapter so both coexist. Claude Code wrote the adapter.
- Convert the requirements classes. This is pure pattern-matching across ~20 near-identical files — exactly the “mechanical translation” AI is best at. I reviewed the diffs; I didn’t type them.
- Move the handlers onto the railway. One
ToResultextension, then find-and-rewrite every guard. - Carry the proof through the event flow — the riskiest phase. I had Claude Code write characterization tests before touching it, so I’d know immediately if behaviour drifted.
- Delete the old type once a grep proved nothing referenced it.
Each phase was independently mergeable and green before the next. The part I always dread — the tedious, error-prone sweep across dozens of files — is the part I no longer do by hand. It’s the shift I wrote about in from code writer to software architect: I keep the design, the machine does the fan-out.
When not to do this
It’d be dishonest to pretend every bool needs this treatment. There’s one path in the same codebase where all this ceremony would be overkill: a read-only admin screen that just displays “supported: yes/no” and never builds a command.
public bool IsSupported<TOptions>(IFeature<TOptions> feature, byte hardwareId) =>
ResolveSupport(feature, hardwareId) is FeatureSupport<TOptions>.Granted;
The boolean is fine there, because it’s consumed immediately for display and grants nobody anything. The original sin was never the boolean itself — it was using a boolean as the gate for a privilege handed out somewhere else entirely.
The honest trigger isn’t “unions are elegant.” It’s the symptoms: a privilege guarded only by a timing comment, a proof object that throws away its proof, a check evaluated more than once because its answer can’t travel. If you have those, the structure earns its keep. If you have a bool that’s read once and gates nothing, leave it alone.
Find the comment that warns you
Go find a method with a comment like // call X before Y — every codebase has one. Open Claude Code in that project:
claude
Then:
“This method’s doc says it must be called after [other method]. Trace every caller. Where could someone skip the check? Then propose a design where X returns the thing Y needs, so there’s no way to reach Y without going through X.”
Read what comes back. The remedy is almost always the same: don’t return permission to ask — return the answer, already shaped so the only thing you can do with it is the correct thing.
Comments