In Go, a request struct decoded from JSON carries an empty TenantID three layers deep. How do you keep invalid decoded values out of the rest of the service?
answer
- which layer is allowed to say no?
- the decoded struct is not the request
- two types, one conversion
- unexported fields plus a constructor
- give the identifier its own named type
basics
~20 sConvert at the edge. The handler decodes a wire struct, then builds a separate domain value through a constructor that returns an error, and only that value travels inward. Deeper layers then have no way to receive an unchecked request.
solid answer
~50 sThe decoded struct is a picture of the payload, not a request the service can act on, so stop passing it around. At the handler, decode into a wire type and then call a constructor - `NewCreateUser(req) (CreateUser, error)` - that checks every rule once and returns a domain value whose fields are unexported and reachable only through methods. Give identifiers their own named types, `type TenantID string`, so a bare `string` cannot be passed where a tenant id belongs without an explicit conversion. Deeper layers take `CreateUser`, so no path exists along which an empty tenant arrives; the query layer stops defensively re-checking and the error is reported once, at the edge, naming the wire field the client sent. Go cannot forbid a type's zero value, so the guarantee is by construction and package boundary rather than by the compiler.
code
go · 20 linestype createUserRequest struct {
TenantID string `json:"tenant_id"`
Email string `json:"email"`
}
type TenantID string
type CreateUser struct {
tenant TenantID
email string
}
func NewCreateUser(req createUserRequest) (CreateUser, error) {
if req.TenantID == "" {
return CreateUser{}, errors.New("tenant_id is required")
}
return CreateUser{tenant: TenantID(req.TenantID), email: req.Email}, nil
}
func (c CreateUser) Tenant() TenantID { return c.tenant }go deeper
Know that a decoded struct is only as good as the payload, and that a check right after decoding is what keeps a bad request from going further. Be able to write that check.
Explain the two-type arrangement: a wire struct with json tags, a domain type with unexported fields, and a constructor that returns a value or an error. Say why the inner layers then need no checks of their own.
Show the operational payoff: one rejection point, an error naming the wire field the client sent, no defensive re-checking downstream, and a clear account of what the compiler does and does not enforce here.
Own where the line sits for the organisation - which services get a conversion layer and which get a Validate call, and how error shapes stay consistent for the teams calling you.
## The failure being diagnosed An internal service has a handler per operation, each one decoding a request struct from the body. One request type has a `TenantID string`. A caller omits the field, the decode succeeds, and the handler passes the struct on. Three layers down, a query is built with an empty tenant filter, or a lookup in a per-tenant map returns the zero value and something dereferences it. The stack trace points at code that is entirely correct; the defect was admitted at the front door and only became visible where the value was finally used. The question an interviewer is really asking is **which layer rejects a bad request**, and the good answer is: exactly one, the outermost, and in a way that makes the inner layers structurally unable to receive one. ## The three candidate layers, and why two of them are wrong - **The layer that finally chokes.** Cheapest to write, worst to operate: the error surfaces far from its cause, the message names an internal concept rather than a request field, and the caller gets a 500 for what was their mistake. It also spreads - every layer starts defensively re-checking, because none can trust its inputs. - **Every layer.** Feels safe, is not. The checks drift apart, the same rule is expressed three ways, and a reader can no longer tell which check is load-bearing. - **The edge.** One check, at the point where untrusted bytes become a Go value, and a type system arrangement that stops an unchecked value from going any further. ## Converting rather than validating The move is to have two types instead of one. The **wire type** mirrors the payload - plain exported fields, json tags, no rules. The **domain type** is what the service actually operates on, and it can only be built by a constructor that enforces the rules: ``` type TenantID string type CreateUser struct { tenant TenantID email string } func NewCreateUser(req createUserRequest) (CreateUser, error) { if req.TenantID == "" { return CreateUser{}, errors.New("tenant_id is required") } if req.Email == "" { return CreateUser{}, errors.New("email is required") } return CreateUser{tenant: TenantID(req.TenantID), email: req.Email}, nil } func (c CreateUser) Tenant() TenantID { return c.tenant } ``` The fields are unexported, so no other package can assemble a populated `CreateUser` without going through `NewCreateUser`. Everything below the handler takes `CreateUser`, never `createUserRequest`. There is no longer a code path along which an empty tenant reaches the query layer, so the query layer does not need a check for it - and the absence of that check is now correct rather than an oversight. ## Named types are half the value `type TenantID string` is a distinct type, not an alias. A function taking a `TenantID` cannot be passed a `string` variable without an explicit conversion, which means a user id and a tenant id cannot be swapped by accident at a call site - a bug that plain `string` parameters make invisible and that no amount of validation catches. (An untyped string *constant* still converts implicitly, which is what makes `TenantID("acme")` and literal test data pleasant to write.) ## The honest limits - **Go always allows the zero value.** `var c CreateUser` and `CreateUser{}` compile anywhere the type is visible, so you cannot make an invalid value unconstructible the way some languages can. What you get is that a *populated* value can only come from the constructor, and a zero value is usually obviously wrong at the point of use. Keep the type in a small package so the constructor is the only interesting thing in it. - **It is more code.** Two structs and a conversion function per operation is real boilerplate. It pays for itself where a request fans out into several layers, where the same field feeds a query, and where the service is called by teams you do not control. For a two-handler internal tool, a `Validate() error` at the edge is a perfectly reasonable stopping point. - **Conversion is where the error message is born.** Return errors that name the wire field - `tenant_id`, not `tenant` - because the client reads the payload, not your struct. Collect them rather than returning the first, so a caller fixing three fields needs one round trip. ## What good sounds like Name the layer, name the mechanism, and be explicit about the guarantee's edges: validation happens once at the boundary; the result is a distinct type with unexported fields and named identifier types; deeper layers accept only that type and therefore stop re-checking; the compiler enforces the shape but not the zero value, and package size is what keeps the remaining gap small.
- Does making the domain type's fields unexported actually stop another package from building an invalid one?It stops a *populated* invalid one - no other package can set those fields, so the constructor is the only way to get real data in. It does not stop the zero value: `var c CreateUser` and `CreateUser{}` compile wherever the type is visible. Go has no way to forbid a type's zero value, so keep the type in a small package where the constructor is the obvious entry point.
- What does type TenantID string buy over passing a plain string?It makes the identifier non-interchangeable. A `string` variable holding a user id cannot be passed where a `TenantID` is expected without an explicit conversion, so a call-site argument swap becomes a compile error instead of a production incident. Untyped string constants still convert implicitly, so literals and test data stay readable.
- When is this conversion layer not worth the boilerplate?When the request does not travel: a small internal tool where the handler decodes, uses the value on the next three lines, and returns. There the second type buys nothing a `Validate() error` call at the top of the handler does not. The conversion earns its keep once a request fans out across layers, or once callers you do not control depend on the error messages.
Customs at the airport, not a guard outside every office: you are checked once on entry, and after that every room can assume the people in it were checked.
saying these in an interview costs you the question
- Re-checks the same field defensively in every layer
- Lets the decoded wire struct travel through the whole service
- Claims unexported fields make the zero value impossible
- Reports the failure where it crashes, not where it entered
- Passes bare strings for tenant and user identifiers alike