Practical guide
The object that breaks encapsulation just to be created
Decoding JSON directly into User forces fields to be exported and allows invalid states; a transport DTO preserves the invariant and encapsulation.
An endpoint creates users from JSON. The shortest solution is to decode the body directly into the domain object:
type User struct {
Email string `json:"email"`
Name string `json:"name"`
}
func createUser(w http.ResponseWriter, r *http.Request) {
var user User
if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
if err := user.Validate(); err != nil {
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
return
}
save(user)
}It is a common pattern because encoding/json fills exported fields. It also creates a design problem: during decoding User may exist with empty email, empty name or a combination that the domain rejects.
To let the decoder build it, we made public the very fields we wanted to protect.
The transport gains permission to break the invariant#
Let's assume that every user needs a normalized email and a non-empty name. Validate catches the problem at the end, but doesn't handle all the places where the value can be created or modified:
user := User{}
user.Email = "not-an-email"
existing.Email = ""The public API allows more states than the contract. Any package can skip validation, modify an already saved user, or build one halfway.
The handler is not the only consumer of those fields. Tests, jobs, migrations and other modules also see them. A need for the JSON adapter has become a system-wide promise.
The official encoding/json documentation explains the mechanism: exported struct fields participate in the JSON representation. That decoder requirement does not force the domain entity to have the same shape.
Separate the input message from the valid object#
The handler can decode its own representation of the transport:
type createUserJSON struct {
Email string `json:"email"`
Name string `json:"name"`
}
func createUser(w http.ResponseWriter, r *http.Request) {
var input createUserJSON
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
user, err := users.New(input.Email, input.Name)
if err != nil {
writeUserError(w, err)
return
}
save(user)
}createUserJSON may be incomplete. Its job is to represent what arrived over the network, including missing data. It does not claim to be a user.
users.New receives the full intent and only returns a value that satisfies the invariant:
package users
type User struct {
email string
name string
}
func New(email, name string) (User, error) {
normalized, err := normalizeEmail(email)
if err != nil {
return User{}, ErrInvalidEmail
}
name = strings.TrimSpace(name)
if name == "" {
return User{}, ErrEmptyName
}
return User{email: normalized, name: name}, nil
}
func (u User) Email() string { return u.email }
func (u User) Name() string { return u.name }Now the transport boundary translates data. The users module governs what a valid user means.
DTO is not accidental duplication#
createUserJSON and User contain email and name, but represent different responsibilities:
| Type | May be incomplete | Owner | Reason for change |
|---|---|---|---|
createUserJSON | yes | HTTP adapter | change input format |
users.User | not after New | module users | user rules change |
A new field in the JSON does not have to enter the domain. A new standardization does not have to change the payload. Explicit translation prevents both contracts from evolving as if they were one.
Removing the DTO reduces lines today, but merges two APIs: the one that accepts external bytes and the one that represents a valid user.
Refresh does not mean reopening all fields#
Generic setters reproduce the problem after creation:
func (u *User) SetEmail(email string) {
u.email = email
}The method allows an empty email and does not express why it changes. An operation with intent preserves the rule:
func (u *User) ChangeEmail(email string) error {
normalized, err := normalizeEmail(email)
if err != nil {
return ErrInvalidEmail
}
u.email = normalized
return nil
}ChangeEmail can add policies, raise an event, or reject the change based on state. The consumer does not repair the object from the outside; it requests a valid transition.
Persistence needs another translation#
A database may also require flat fields. The same rule applies: a row does not have to be the entity.
type userRow struct {
Email string
Name string
}
func restore(row userRow) (users.User, error) {
return users.New(row.Email, row.Name)
}If the persisted data may be historical or corrupt, restore may have a specific contract. The important thing is not to open the domain fields just to facilitate Scan or an ORM.
The test protects the boundary#
The useful case does not inspect private fields. It checks that no invalid user can escape:
func TestNewRejectsInvalidEmail(t *testing.T) {
_, err := users.New("not-an-email", "Ada")
if !errors.Is(err, users.ErrInvalidEmail) {
t.Fatalf("expected invalid email, got %v", err)
}
}
func TestChangeEmailKeepsPreviousValueOnFailure(t *testing.T) {
user, _ := users.New("old@example.com", "Ada")
err := user.ChangeEmail("invalid")
if !errors.Is(err, users.ErrInvalidEmail) {
t.Fatalf("expected invalid email, got %v", err)
}
if user.Email() != "old@example.com" {
t.Fatalf("email changed after rejection: %q", user.Email())
}
}The second test rejects an implementation that assigns first and validates later. The guarantee preserves both the error and the previous state.
The transport may receive invalid data. The domain object does not need to be converted to that data in order to understand it.