skip to content

Accessors are usually defended as legitimate at boundaries - DTOs, wire formats, persistence. Compare how Go's encoding/json, Rust's serde and reflective serializers such as Jackson or System.Text.Json get at an object's state, and what each implies for whether the domain type and the wire type can be the same type.

level: seniorimportance: should knowfreq 35%

answer

  1. Go: unexported fields silently omitted -> publish or map
  2. serde derive expands inside the module -> reads private fields
  3. Bean convention = no-arg ctor + setters = no invariants
  4. Records, @JsonCreator, init/required removed that pressure
  5. Deserialization is where invariants die

basics

~20 s

Go's encoding/json sees only exported fields, so a serialized type must publish its state - which forces a separate wire struct. Rust's serde derive expands inside the type and reads private fields, so encapsulation survives but the wire shape silently tracks the representation. Jackson bypasses visibility reflectively.

solid answer

~50 s

Whether one type can serve as both domain type and wire type depends on how your serializer reaches state. - **Go**: `encoding/json` uses reflection but can only see **exported** fields; unexported ones are silently omitted. Making a type serializable therefore makes its state public to the whole program, so Go teams keep a separate wire struct plus explicit mapping - the boundary becomes a type that can version independently. - **Rust**: `#[derive(Serialize)]` is a compile-time macro expanded inside the type's module, so it reads private fields without publishing them. Encapsulation survives, and the risk flips: the wire format now tracks your private representation, so renaming a field is a protocol break unless you use `serde(rename)` or a separate DTO anyway. - **Jackson and System.Text.Json** reflect past visibility. Historically they demanded a no-arg constructor plus setters - a mutable, invariant-free type - which is where "the framework made me write a data bag" comes from. Constructor binding, Java records, C# `init` and `required` remove that pressure.

code

go · 6 lines
go
type Order struct {
    ID    string `json:"id"`
    total int    // unexported: silently absent from the JSON
}
// {"id":"a-1"}   -- no total, no error, no warning
// idiomatic fix: a separate wire struct + explicit mapping

go deeper

for a junior

Know that data crossing a wire is just data, and that the serializer's access to private state differs by language - Go can only see exported fields.

for a middle

Contrast the three mechanisms and explain why the old bean convention produced invariant-free types.

for a senior

Decide one type versus two from serializer capability plus independent-versioning needs, and secure the deserialization path so invariants survive binding.

for a principal

Set the policy for published contracts - schema ownership, compatibility rules, and whether domain types may ever be serialized directly - across services in several languages.

## Why the boundary is the interesting case Everyone agrees a domain object should protect its rules, and everyone agrees data crossing a wire is just data. The argument is about whether those must be different *types*. That is not a matter of taste - it is decided largely by a mechanical question: how does your serializer obtain the values it writes, and how does the deserializer put them back? ## Three mechanisms **Reflection constrained by visibility - Go.** `encoding/json` reflects over a struct but can only read exported (capitalised) fields. An unexported field is not an error and not a warning; it simply does not appear in the output. The consequence is severe and clarifying: to serialize state you must make it public to every package in your program. So in Go the choice "one type for domain and wire" costs you all field-level encapsulation, and idiomatic Go therefore defines a separate wire struct with `json:` tags and writes explicit mapping functions. The boilerplate buys a real property - the wire contract is a distinct type that can evolve on its own schedule, and a domain rename cannot silently change the protocol. **Compile-time code generation - Rust.** `#[derive(Serialize, Deserialize)]` is a procedural macro expanded in the type's own module, so the generated code has ordinary access to private fields. You can serialize a fully encapsulated struct with no visibility change. That is strictly more capable, and it moves the hazard rather than removing it: nothing now stands between your internal field names and the bytes on the wire, so renaming a private field is a silent protocol break. Mitigations exist - `#[serde(rename)]`, `rename_all`, `default`, `skip` - and they are effectively a schema declaration, at which point you have re-created the DTO as annotations rather than as a type. Which is fine, as long as you know that is what you did. The same story holds for any generated-codec approach: Swift's `Codable` with `CodingKeys`, Kotlin's `kotlinx.serialization` plugin. **Unconstrained reflection - Jackson, System.Text.Json.** JVM and .NET serializers can read and write non-public members given configuration, and historically the path of least resistance was the bean convention: a public no-arg constructor plus a setter per field. That combination is exactly an object with no invariants, because it must be constructible in an empty state and mutable into any state. This is the concrete origin of "our ORM and our JSON library forced us into data bags" - and it is no longer true. Jackson supports constructor binding via `@JsonCreator`, works with Java records, and has a Kotlin module for data classes; System.Text.Json binds to parameterised constructors and supports `init`-only setters and `required` members. The framework constraint that produced a generation of anemic models has been removed, and codebases that still cite it are citing history. **The single-type extreme - Python.** With pydantic or dataclasses the model class *is* the parsing, validation and wire type, and this is idiomatic rather than a compromise. The cost is a dependency direction: the framework's base class becomes a dependency of your core types, and the validation vocabulary of the boundary leaks into the domain. ## Drawing the line Two forces decide whether to keep one type or two: 1. **Can the serializer see private state?** If not (Go), one type means no encapsulation, so two types is nearly forced. If yes (serde, Jackson, System.Text.Json), one type is technically possible. 2. **Must the wire shape be able to differ from the field shape?** Independent versioning, a public API contract you cannot break, field names that must stay stable across an internal refactor, or fields that must never leave the process - each of these argues for a second type regardless of language, and each of them is a reason that has nothing to do with encapsulation dogma. When you do keep one type, the discipline that keeps it honest is that the accessors exist for the boundary and nothing else: they are read at the edge, they are not the way the rest of the system asks the object questions, and no business decision is made from them elsewhere. When an accessor added "for the serializer" starts appearing in a service that computes something from it, the boundary has leaked into the core, and that is the point where splitting the type stops being ceremony and starts paying. ## The reverse direction matters too Deserialization is where invariants die. If the framework can construct your type field by field, it can construct states your constructor would have rejected. Constructor or factory binding, validation invoked after binding, and immutable targets are the three ways to keep the guarantee - and their availability, again, is a language and library fact: records and `@JsonCreator` on the JVM, `required`/`init` in C#, `#[serde(try_from)]` in Rust, validators in pydantic, and explicit hand-written mapping in Go.

  • A team says their JSON library forces setters on every domain field. How would you challenge that today?
    The no-arg-constructor-plus-setters requirement is historical. Jackson binds through constructors with @JsonCreator and supports Java records; System.Text.Json binds parameterised constructors and honours init-only and required members; kotlinx.serialization and serde build immutable values directly. If mutability is still required, that is a library-configuration choice, not a law, and a mapping layer removes it entirely.
  • Rust's serde can serialize private fields. Why might you still write a separate DTO?
    Because encapsulation was never the only reason for two types. If the wire format is a published contract, coupling it to private field names means any internal rename is a protocol break, and per-field serde attributes gradually become an undeclared schema. A distinct DTO makes the contract explicit, versionable and reviewable, and lets the domain type change freely.

saying these in an interview costs you the question

  • Claiming Go's encoding/json can be configured to see unexported fields
  • Treating 'the framework needs setters' as a current constraint on the JVM or .NET
  • Assuming one type at the boundary is always wrong - in Python with pydantic it is the idiom
  • Forgetting that deserialization can construct states the constructor would reject
  • Adding an accessor for the serializer and then reading it from business logic, which quietly moves the boundary into the core

context