DEV Community

Ivan Rossouw
Ivan Rossouw

Posted on

Make Invalid Identifier Formats Unrepresentable

The dangerous identifier bug is not always malformed input. Sometimes both values are valid, both parse successfully, and both refer to the same conceptual thing. They simply use different textual representations.

That sounds cosmetic until a storage boundary compares them as raw strings.

I recently reviewed a committed .NET refactor built around exactly that failure mode. One boundary stored a compact canonical key. Elsewhere, a runtime identifier was converted with another valid representation. The lookup returned no rows. Then the reader supplied a plausible default, so the application continued without an exception.

The result was not obviously broken software. It was quietly wrong software.

Two valid values can still disagree

A Guid has several standard string formats. A database, cache, external API, or configuration catalogue may choose one of them as its canonical key. If another layer uses a different valid format, parsing both values proves very little. Raw equality still fails.

The risk grows when “not found” is intentionally convenient:

runtime identifier
    -> valid but non-canonical string
    -> exact lookup returns no row
    -> reader supplies defaults
    -> caller treats defaults as configured data
Enter fullscreen mode Exit fullscreen mode

Every step can be locally reasonable. Together they create a silent semantic failure.

That failure direction matters. A thrown exception attracts attention. A believable fallback can survive code review, automated tests, and monitoring because the system remains green.

A helper method is still a convention

The first repair is usually to change one conversion call. That fixes the immediate defect, but it leaves the real contract implicit:

Task<Settings> LoadAsync(string key);
Enter fullscreen mode Exit fullscreen mode

Nothing in this signature tells a caller which representation is required. Any string compiles. A nearby comment or helper improves discoverability, but the compiler still cannot help.

The same mistake can return six months later in a new call site, during a refactor, or inside a test fake that ignores the supplied key.

If representation changes lookup semantics, the representation is part of the type.

Put canonicality at the service seam

A small value type can make the contract explicit:

Task<Settings> LoadAsync(CatalogKey key);

var key = CatalogKey.From(runtimeId);
var settings = await store.LoadAsync(key);
Enter fullscreen mode Exit fullscreen mode

The exact implementation is less important than the responsibilities:

  • one factory converts the runtime identifier into the canonical form;
  • one parser validates compact input, normalises case, and rejects hyphenated or malformed input;
  • equality follows the canonical representation;
  • service contracts accept the semantic key, not an arbitrary string;
  • consumers unwrap the value only at the storage boundary.

Now a raw Guid or string cannot cross the seam accidentally. The caller must make the conversion decision explicitly, at a place where reviewers can see it.

This is a useful modular-architecture pattern: translate once at the boundary, then carry a truthful type inside the module.

Make the default value fail loudly

C# value types have one awkward edge: default(T) exists even when no public constructor permits an empty value.

Returning an empty string from an uninitialised key would recreate the original failure. The lookup would miss, the reader could fall back, and the invalid state would again appear healthy.

For a boundary type like this, reading an uninitialised value should fail loudly. If callers genuinely need optionality, represent it explicitly with a nullable key or a result type. Do not let “missing” masquerade as a valid empty key.

This is an important test case because factory-only tests cannot reach the runtime's zero-initialisation path.

Migrate incrementally without claiming total safety

Changing every string-based seam at once may create an unnecessarily large blast radius. The reviewed refactor migrated several related service contracts first and retained an architecture guard for older seams that still accepted raw strings.

That is a pragmatic transition:

  1. introduce the semantic type;
  2. migrate the highest-risk boundary cluster;
  3. test factories, parsing, equality, and the default instance;
  4. keep a focused guard around the remaining convention-based surface;
  5. remove the guard only when the raw-string path truly disappears.

The type protects only APIs that require it. Keeping the guard acknowledges that partial migration honestly.

The trade-off: useful friction

Strong boundary types create work. Signatures change. Call sites need explicit conversions. ORMs may require unwrapping to a local scalar before translating a query. Test fixtures that relied on permissive fakes may need more realistic assertions.

That friction is the point when the alternative is a silent miss.

A raw string optimises for movement. A semantic key optimises for correctness, discoverability, and reviewability. Use it where representation carries business or storage meaning, not for every incidental string in the application.

A practical checklist

Before leaving an identifier as a string, ask:

  • Can the same identifier have multiple valid representations?
  • Does the downstream system compare the representation exactly?
  • Can “not found” become a plausible default rather than an error?
  • Is the required format visible in the method signature?
  • Are uninitialised, malformed, and alternative-format paths tested?
  • Are remaining raw-string seams still guarded during migration?

If several answers make you uncomfortable, the string convention is already a domain concept. Give it a name, give it a type, and make the wrong representation difficult to express.

Top comments (0)