skip to content

When should you register a custom converter for a domain type instead of binding text and converting inside the handler?

level: seniorimportance: should knowfreq 42%

answer

  1. registry keyed by target type
  2. one registration, every route
  3. parse here, resolve later
  4. stateless and cheap under load
  5. failure must reach the binder's channel

basics

~10 s

Register one when the same textual form appears on many routes: the signature then carries the domain type and every route rejects bad text identically. Keep the converter pure, fast and free of lookups.

solid answer

~50 s

A custom converter is a function from raw text to a target type, registered in the binder's registry and invoked wherever a parameter of that type is declared. Register one when the form is **service-wide** — an account identifier, a slug, a signed token — so the handler signature states the domain type, the parse lives in one place, and every route produces the **same failure** for bad text. What it commits you to matters: the converter applies **application-wide**, it runs on untrusted input on every matching request, so it must be fast, allocation-light, stateless and safe under concurrency, and it must report failure through the binder's own failure channel so the result is a client error rather than a server error. It should not perform I/O — "is this text well formed" and "does this record exist" are different questions with different responses.

code

pseudocode · 6 lines
pseudocode
register_converter(AccountId, text -> {
  if (not matches(text, "acct_[0-9a-f]{16}")) fail_binding("expected acct_ followed by 16 hex digits")
  return AccountId(text)
})

route("/accounts/{id}/invoices", handler(id: AccountId) -> ...)

go deeper

for a junior

Know that the framework can be taught new types: you register a routine that turns text into your type, and then handlers can declare that type directly.

for a middle

Contrast the two placements — registered converter versus a parse in the handler — on where the rule lives, what the failure looks like, and what reaches the handler at all.

for a senior

Defend the boundaries: no I/O, no shared mutable state, a failure reported through the binder, and an accepted form that is effectively public API.

for a principal

Decide which domain types earn a converter across the estate, how their wire forms are minted so they cannot be confused, and how a change to an accepted form is rolled out.

## What a custom converter is The binder resolves a parameter's declared type against a registry of conversion routines. A **custom converter** is an entry a service adds to that registry for a type the framework knows nothing about: an account identifier with a prefix and a checksum, a short slug, a currency amount, a signed opaque token. Once registered, it is invoked by the binder wherever a parameter of that type appears, with no per-route wiring. The alternative is to declare the parameter as text and construct the domain value in the first lines of the handler. Both work. The choice is about where the parse lives and what happens when it fails. ## When registering wins | | Custom converter | Parse inside the handler | |---|---|---| | Where the rule lives | one registration | repeated in every handler | | Handler signature | states the domain type | states text | | Failure shape | uniform, produced by the binder | whatever each handler wrote | | Reaches the handler at all | only well-formed values | any text the client sent | | Applies to | every parameter of that type | only where the code was written | | Testability | one unit under test | as many as there are call sites | The case for registering is strongest when the form is used on many routes, when the failure should look the same everywhere, and when the domain type is the thing the rest of the code actually wants. The case for parsing inside the handler is strongest when the form is used once, when the failure needs a bespoke response, or when producing the value needs more than the text. ## What registering commits you to 1. **It is global.** Every parameter of that type, on every route, now goes through it — including routes added later by people who never read the registration. That is the point, and it is also the risk: a change to the converter changes the accepted wire format of every one of those endpoints at once. 2. **It runs on untrusted text, on every request.** It should be cheap and allocation-light, and it must not be a place where a pathological input costs disproportionate work. Backtracking-heavy pattern matching over caller-controlled text belongs nowhere near here. 3. **It must be stateless and concurrency-safe.** One registered instance typically serves all requests in flight. Mutable fields, cached last values and shared buffers are bugs waiting for load. 4. **It must fail through the binder's channel.** Reporting a failure the binder understands is what makes a malformed value a client error. An arbitrary exception escaping into the generic catch-all becomes a server error and misrepresents whose fault the request was. 5. **It must state its empty and absent policy.** Whether empty text is a failure or a legitimate value of the domain type is part of the contract; deciding it by accident produces inconsistent behaviour across routes. ## The line at I/O The most common design mistake is a converter that resolves rather than parses — one that takes an identifier and returns the record it names, by querying storage. It is tempting, because the handler then receives the entity directly. It is the wrong layer, for reasons that all show up in production: - **The two failures deserve different answers.** Text in the wrong shape is a malformed request; a well-formed identifier with no matching record is a different outcome entirely, and the handler often wants to choose the response. - **Conversion is not a place where latency is expected.** A lookup inside the binder is invisible to anyone reading the handler, and it happens before any timeout, authorisation check or caching the handler would have applied. - **Authorisation ordering breaks.** Fetching an entity before the pipeline has finished deciding whether this caller may see it inverts the order the service depends on. - **It fans out.** A converter that reads storage does so for every parameter of that type on every route, including ones that only needed the identifier. Parse in the converter; resolve in the handler or the service beneath it. ## Ambiguity between types A registry keyed by target type has one blind spot: two domain types can share a textual shape. If both accept the same characters, the declared type alone decides which converter runs, and a parameter declared with the wrong one converts happily into the wrong domain value. Distinct prefixes, checksums or lengths in the wire form make this detectable instead of silent, and are worth designing in when identifiers are minted. ## Treat the converter as published contract The set of textual forms a converter accepts *is* the API for that type, on every endpoint that uses it. Tightening it rejects requests that previously worked; loosening it is nearly impossible to walk back. Version it with the same care as a route, document the accepted form once, and test it directly as a unit rather than only through the endpoints that happen to use it.

  • Why should a converter not look the value up in storage and return the entity?
    Because malformed text and a missing record are different outcomes that deserve different responses, and the lookup would run inside argument assembly — before authorisation decisions, invisible to anyone reading the handler, and repeated for every parameter of that type across the service. Parse in the converter, resolve afterwards.
  • What makes a custom converter risky under concurrency?
    A single registered instance usually serves every request in flight, so any mutable state it keeps — a cached last value, a shared buffer, a reusable parser object that is not safe for concurrent use — is shared across requests. Converters should be pure functions of their input text and hold nothing between calls.
  • Two domain types accept the same textual shape. What goes wrong and how do you prevent it?
    The registry is keyed by declared type, so a parameter declared with the wrong one converts successfully into the wrong domain value, with no error anywhere. Designing distinguishable wire forms — a type prefix, a checksum, a fixed length — turns that silent mix-up into a conversion failure.

saying these in an interview costs you the question

  • Having a converter query storage and return the entity it found
  • Keeping mutable state in a converter shared across concurrent requests
  • Throwing an arbitrary error that surfaces to the client as a server fault
  • Assuming a registration affects only the route that prompted it
  • Changing an accepted wire form without treating it as an API change