skip to content

Why keep a separate JSON wire struct rather than putting json tags on the domain type?

level: seniorimportance: should knowfreq 42%

answer

  1. two jobs, two rates of change
  2. in Go the struct is the schema
  3. who can move the payload by accident
  4. one file a reviewer can actually see
  5. the previous release's payloads, replayed

basics

~20 s

Tagging the domain type welds the published payload to your internal model, so every refactor becomes a wire change. A small tagged struct plus a conversion function keeps the contract in one reviewable file and lets the two change at different rates.

solid answer

~50 s

When the domain type carries the tags, the published contract is scattered across whatever struct the business logic happens to use, and any change to that struct — a rename, a split, a type someone embeds later — can move the wire. A dedicated wire struct inverts that: a dumb, fully tagged, exported-fields-only representation of the payload, plus a function that converts to and from the domain type. Three things get easier. Refactoring is free, because nothing internal is published. The contract becomes reviewable, because a diff that touches the payload touches exactly one file. And you can serve two payload versions at once during a migration — a v3 and a v4 wire struct over the same domain type — which is impossible when the domain type *is* the payload. The price is a conversion function: boring code the compiler checks.

code

go · 18 lines
go
// domain: unexported fields, invariants, free to refactor
type Profile struct {
	fullName string
	joined   time.Time
}

// wire: every field tagged, nothing but data, changed only on purpose
type profileV4 struct {
	Name     string `json:"name"`
	JoinedAt string `json:"joined_at"`
}

func (p Profile) wire() profileV4 {
	return profileV4{
		Name:     p.fullName,
		JoinedAt: p.joined.Format(time.RFC3339),
	}
}

go deeper

for a junior

Know that the struct you marshal is exactly what clients see, and that writing a small struct whose only purpose is JSON is normal, not wasteful.

for a middle

Explain what the conversion function buys: tagged exported fields on one side, unexported fields and invariants on the other, and one small file to review whenever the payload moves.

for a senior

Show the enforcement — replaying earlier releases' payloads in a test — and argue the cost of the extra type against the risk of publishing your domain model by accident.

for a principal

Own the boundary rule for the codebase: which packages may define wire types, whether a handler may ever marshal a domain type directly, and how the team is expected to pay for that discipline.

## The coupling you create by tagging a domain type `encoding/json` reads a struct's exported fields by reflection and names them from their tags. That makes the struct definition itself the schema — there is nowhere else to put the contract, no separate mapping file, no annotation on a getter. So the moment you write `json:"..."` on the type your handlers and business logic pass around, that type has two jobs: it models the domain *and* it is the published payload. Those two jobs change at completely different rates, and they answer to different people. The symptoms show up in both directions: - **Inward.** A rename that makes the code clearer becomes a wire event. Splitting one struct into two, promoting a field into an embedded type, or someone adding an exported field for internal bookkeeping all reach clients you cannot upgrade. - **Outward.** The wire starts dictating the model. Fields become exported that should not be. A time is stored as a pre-formatted string because that is what the payload wants. A validated value object is demoted to a bare `string` because the type would otherwise have to know how to encode itself. ## The shape of the alternative Define a second struct whose only job is the payload. It is typically unexported, lives next to the handler or in a small package that owns the contract, has a tag on every field, and contains nothing but data. Beside it live two functions: one that builds it from the domain type, and one that validates and converts back. That gives you four concrete things. **Refactoring is free.** The domain type can rename fields, unexport them, add invariants enforced by a constructor and hold values that `encoding/json` could never see. Nothing internal is published, so nothing internal is frozen. **The contract is reviewable in one place.** Any change to the payload is a diff in one small file with no logic in it. A reviewer skimming a large change for correctness will miss a tag edit buried in a domain type; they will not miss it in a file that exists solely to define the wire. **Two versions can coexist.** During a migration you can hold `profileV3` and `profileV4` over the same domain type, choose between them by route or by media type at the edge, and retire the older one when its clients are gone. With tags on the domain type there is exactly one payload and no way to serve both. **Formatting has a home.** Time formatting, redaction, flattening a nested value, joining two domain fields into one key — all of it lives in the conversion function as ordinary Go code you can read and test, rather than in custom marshaling behaviour attached to a type that other code also uses. ## "Isn't that just duplication?" It duplicates the *field list*, and that is the point. The duplication is where two rates of change are decoupled, and the compiler holds the seam: when either side moves, the conversion function stops building. The cost is real but bounded and mechanical; the cost it avoids — a wire change nobody intended, discovered by users — is neither. The version of this that genuinely is a smell is a single struct wearing tags for three systems at once: JSON for clients, one set of tags for a database mapper, another for validation. That struct is the contract for three consumers that will never agree on when it may change, and it is the shape most likely to produce an accidental publication. ## What actually enforces it Structure alone is a convention; a test is enforcement. Keep the payloads your previous releases produced under `testdata/` and, on every build, decode each of them into the current wire struct and assert that the fields clients depend on are still populated. A renamed tag turns that test red instead of blanking a field in production, and the file names give a reviewer the release history of the payload at a glance. It is the one test that a marshal-then-unmarshal round trip cannot replace, because the round trip moves with the code and the stored payload does not. When the wire struct is unexported, that test naturally lives in the same package, which is another small argument for keeping the contract type close to the code that serves it rather than in a shared types package everybody imports.

  • What test actually stops an accidental wire change from shipping?
    One that decodes payloads captured from previous releases, stored under `testdata/`, into today's wire struct and asserts the fields clients rely on are still populated. A tag rename turns the build red rather than blanking a field in production. A marshal-then-unmarshal round trip cannot do this: both halves move with the code, so it agrees with whatever you just changed.
  • Doesn't the conversion function just duplicate the struct?
    It duplicates the field list, which is where the two rates of change are separated, and the compiler flags whichever half you forget. It also gives formatting, redaction and flattening a plain-Go home instead of attaching encoding behaviour to a type the rest of the system uses. The cost is mechanical and bounded; the coupling it removes is neither.
  • How would you serve two payload versions from one domain type?
    Two wire structs and two conversion functions, chosen at the edge by route or media type. The handler picks a wire type; the domain layer never learns that a second version exists. Retire the older struct — and its conversion function and its stored test payloads — once traffic from clients that need it has gone.
  • When is tagging the domain type actually fine?
    When there is no separate audience: an internal tool, a config file read at startup, a cache entry your own process writes and reads, or a short-lived service you redeploy with every consumer. The discipline is paid for by clients you cannot change, so where none exist it is overhead. The judgement call is noticing when a payload quietly acquires such clients.

The domain type is the workshop and the wire struct is the shipping crate. You rearrange the workshop weekly; the crate's dimensions are on someone else's loading dock and change only by agreement.

saying these in an interview costs you the question

  • Puts json, database and validation tags on one struct and calls it DRY
  • Dismisses the conversion function as boilerplate to be deleted
  • Renames a domain field and checks only that the code compiles
  • Treats a wire struct as duplication rather than a contract boundary
  • Ships a payload change with no test over earlier releases' payloads
  • Exports a field purely so the encoder can reach it