← Concepts & practices
Choice Data modeling and type design

Domain model vs. DTO

Who owns the shape at the boundary?

An API payload can be a perfectly good starting point. The question is whether it should become the vocabulary and rules of the rest of your application. Follow one support ticket while the owner, boundary, and meaning change.

TypeScriptGo One ticket. Two owners of meaning.

01 / The decision

The endpoint is not the whole application.

A support desk opens ticket T-42. The endpoint returns requester_name, updated_at, and unread_count. A small internal list can render those fields directly. That is not a mistake; it is a contract with one owner and one meaning.

Read the starting shapeTypeScript and Go · direct use of the DTO
TypeScriptshared shape
contracts.ts · shared shape
export function sharedLabel(ticket: TicketDTO): string {
	return `${ticket.requester_name} · ${ticket.status}`;
}

export function sharedNeedsReply(ticket: TicketDTO): boolean {
	return ticket.status !== 'closed' && ticket.unread_count > 0;
}
Goshared shape
contracts.go · shared shape
func SharedLabel(ticket TicketDTO) string {
	return fmt.Sprintf("%s · %s", ticket.Requester, ticket.Status)
}

func SharedNeedsReply(ticket TicketDTO) bool {
	return ticket.Status != string(StatusClosed) && ticket.UnreadCount > 0
}

Both versions read the payload fields straight from TicketDTO. The shared shape is our fair baseline.

Then the endpoint belongs to a partner. Its snake_case names, timestamp strings, and future optional fields are now somebody else’s contract. Meanwhile, the desk wants a normalized requesterName, a checked status, and one answer to “can this ticket receive a reply?”

A DTO describes what crosses a boundary. A domain model describes what the application needs to mean and keep true. They may have the same fields today. They do not have to share an identity forever.

02 / The alternatives

Two valid starting points.

The alternatives are not “simple” and “professional.” They preserve different relationships. Share the shape when one owner and one meaning make the seam unnecessary. Map when the outside contract or the inside rule needs a home of its own.

A

Share the shape

Can every caller use the payload as-is?

One type keeps a thin screen and its endpoint aligned without a mapper or duplicated fields.

Reasonable for one team, one process, and one read-only meaning.
B

Map at the boundary

Where should the outside vocabulary stop?

The adapter parses and normalizes once; domain callers receive the shape and rules they own.

Reasonable when contracts evolve independently or several callers need one rule.
What each choice asks you to maintain
QuestionShare the shapeMap at the boundary
Who owns names?The transport contract becomes the caller’s vocabulary.The adapter owns wire names; the domain owns its names.
Where is parsing?Each caller may use strings and optional fields directly.One boundary validates and normalizes before domain use.
What changes together?Endpoint and callers can move in one coordinated change.DTO and domain model can evolve independently, with mapping tests.
What does it cost?Less code now; more boundary assumptions in every caller.More code now; a seam to review, test, and own.

A mapper is not automatically a class, and a DTO is not automatically an anemic domain. The useful question is whether the two shapes answer different questions. If they do, one shared type makes the difference harder to see.

03 / Change a constraint

Keep the ticket job fixed. Move the pressure.

Start with an internal screen. Then give the endpoint an independent owner, add a domain rule, or return a summary projection. Watch which contract becomes expensive to pretend is shared.

A controlled comparison

Hold the ticket job fixed. Change the boundary.

Runs the TypeScript decision model
EndpointTicket payload
Decision pressureAn independently changing API
CallerRender or reply
Starting recommendation: Map at the boundary That recommendation can change when the constraint changes.
Share the shape fragile

The API now owns your vocabulary

A wire rename, string format, or optional field becomes a domain change. Every caller learns the partner contract.

This is a decision model, not a benchmark. “Map” means a boundary adapter plus a domain shape; it does not require classes or an ORM.

The recommendation is conditional, not a score. Mapping is extra maintenance when no boundary needs it. Sharing is extra coupling when the payload and the domain have begun to change for different reasons.

04 / Compare the source

See where the seam lives.

The shared version reads the payload fields. The mapped version moves parsing and normalization to toTicket, then lets needsReply use the domain shape. Both files run the same ticket through the same observable result.

TypeScriptmapped boundary
contracts.ts · mapper
export function toTicket(dto: TicketDTO): Ticket {
	// Accept RFC 3339 only, as Go's time.Parse does; new Date() alone accepts many other strings.
	const updatedAt = new Date(dto.updated_at);
	if (!rfc3339.test(dto.updated_at) || Number.isNaN(updatedAt.valueOf())) {
		throw new Error('Invalid ticket timestamp');
	}
	if (!Number.isInteger(dto.unread_count) || dto.unread_count < 0) {
		throw new Error('Unread count must be a non-negative integer');
	}
	return {
		id: ticketId(dto.id),
		status: status(dto.status),
		requesterName: dto.requester_name.trim(),
		updatedAt,
		unreadCount: dto.unread_count
	};
}

export function needsReply(ticket: Ticket): boolean {
	return ticket.status !== 'closed' && ticket.unreadCount > 0;
}

export function summary(ticket: Ticket): TicketSummary {
	return {
		id: ticket.id,
		label: `${ticket.requesterName} · ${ticket.status}`,
		needsReply: needsReply(ticket)
	};
}
Gomapped boundary
contracts.go · mapper
func ToTicket(dto TicketDTO) (Ticket, error) {
	if !ticketIDPattern.MatchString(dto.ID) {
		return Ticket{}, fmt.Errorf("invalid ticket id: %s", dto.ID)
	}
	status := TicketStatus(dto.Status)
	if status != StatusNew && status != StatusOpen && status != StatusClosed {
		return Ticket{}, fmt.Errorf("unknown ticket status: %s", dto.Status)
	}
	if dto.UnreadCount < 0 {
		return Ticket{}, errors.New("unread count must be non-negative")
	}
	updatedAt, err := time.Parse(time.RFC3339, dto.UpdatedAt)
	if err != nil {
		return Ticket{}, fmt.Errorf("invalid ticket timestamp: %w", err)
	}
	return Ticket{
		ID: dto.ID, Status: status, RequesterName: strings.TrimSpace(dto.Requester),
		UpdatedAt: updatedAt, UnreadCount: dto.UnreadCount,
	}, nil
}

func NeedsReply(ticket Ticket) bool {
	return ticket.Status != StatusClosed && ticket.UnreadCount > 0
}

type TicketSummary struct {
	ID         string `json:"id"`
	Label      string `json:"label"`
	NeedsReply bool   `json:"needsReply"`
}

func Summary(ticket Ticket) TicketSummary {
	return TicketSummary{
		ID:         ticket.ID,
		Label:      fmt.Sprintf("%s · %s", ticket.RequesterName, ticket.Status),
		NeedsReply: NeedsReply(ticket),
	}
}
Read the TypeScriptA branded ID and checked local shape

TicketDTO keeps the wire spelling. toTicket is the only place that turns updated_at into a Date and checks the status. The assertion used for TicketId follows a runtime regex; the assertion alone would not validate a response.

Read the GoA struct tag and explicit parsing

Go’s JSON tags describe the DTO contract. ToTicket returns an error alongside the domain value, and time.Parse makes the timestamp conversion visible. The language changes the syntax, not the ownership decision.

See the complete programsCopyable source with both paths
TypeScriptcomplete file
contracts.ts
export type TicketStatus = 'new' | 'open' | 'closed';
export type TicketId = string & { readonly __ticketId: unique symbol };

export type TicketDTO = {
	id: string;
	status: string;
	requester_name: string;
	updated_at: string;
	unread_count: number;
};

export type Ticket = Readonly<{
	id: TicketId;
	status: TicketStatus;
	requesterName: string;
	updatedAt: Date;
	unreadCount: number;
}>;

export type TicketSummary = Readonly<{
	id: string;
	label: string;
	needsReply: boolean;
}>;

export const incomingTicket: TicketDTO = {
	id: 'T-42',
	status: 'open',
	requester_name: 'Mina Alvarez',
	updated_at: '2026-09-15T08:30:00.000Z',
	unread_count: 2
};

export function sharedLabel(ticket: TicketDTO): string {
	return `${ticket.requester_name} · ${ticket.status}`;
}

export function sharedNeedsReply(ticket: TicketDTO): boolean {
	return ticket.status !== 'closed' && ticket.unread_count > 0;
}

const rfc3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;

function ticketId(value: string): TicketId {
	if (!/^T-[0-9]+$/.test(value)) throw new Error(`Invalid ticket id: ${value}`);
	return value as TicketId;
}

function status(value: string): TicketStatus {
	if (value === 'new' || value === 'open' || value === 'closed') return value;
	throw new Error(`Unknown ticket status: ${value}`);
}

export function toTicket(dto: TicketDTO): Ticket {
	// Accept RFC 3339 only, as Go's time.Parse does; new Date() alone accepts many other strings.
	const updatedAt = new Date(dto.updated_at);
	if (!rfc3339.test(dto.updated_at) || Number.isNaN(updatedAt.valueOf())) {
		throw new Error('Invalid ticket timestamp');
	}
	if (!Number.isInteger(dto.unread_count) || dto.unread_count < 0) {
		throw new Error('Unread count must be a non-negative integer');
	}
	return {
		id: ticketId(dto.id),
		status: status(dto.status),
		requesterName: dto.requester_name.trim(),
		updatedAt,
		unreadCount: dto.unread_count
	};
}

export function needsReply(ticket: Ticket): boolean {
	return ticket.status !== 'closed' && ticket.unreadCount > 0;
}

export function summary(ticket: Ticket): TicketSummary {
	return {
		id: ticket.id,
		label: `${ticket.requesterName} · ${ticket.status}`,
		needsReply: needsReply(ticket)
	};
}

export function example() {
	const domainTicket = toTicket(incomingTicket);
	return {
		shared: {
			label: sharedLabel(incomingTicket),
			needsReply: sharedNeedsReply(incomingTicket)
		},
		mapped: summary(domainTicket),
		isoDate: domainTicket.updatedAt.toISOString(),
		statusIsClosed: domainTicket.status === 'closed'
	};
}

console.log(JSON.stringify(example(), null, 2));
Gocomplete file
contracts.go
package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"regexp"
	"strings"
	"time"
)

type TicketDTO struct {
	ID          string `json:"id"`
	Status      string `json:"status"`
	Requester   string `json:"requester_name"`
	UpdatedAt   string `json:"updated_at"`
	UnreadCount int    `json:"unread_count"`
}

type TicketStatus string

const (
	StatusNew    TicketStatus = "new"
	StatusOpen   TicketStatus = "open"
	StatusClosed TicketStatus = "closed"
)

type Ticket struct {
	ID            string
	Status        TicketStatus
	RequesterName string
	UpdatedAt     time.Time
	UnreadCount   int
}

var ticketIDPattern = regexp.MustCompile(`^T-[0-9]+$`)

func SharedLabel(ticket TicketDTO) string {
	return fmt.Sprintf("%s · %s", ticket.Requester, ticket.Status)
}

func SharedNeedsReply(ticket TicketDTO) bool {
	return ticket.Status != string(StatusClosed) && ticket.UnreadCount > 0
}


func ToTicket(dto TicketDTO) (Ticket, error) {
	if !ticketIDPattern.MatchString(dto.ID) {
		return Ticket{}, fmt.Errorf("invalid ticket id: %s", dto.ID)
	}
	status := TicketStatus(dto.Status)
	if status != StatusNew && status != StatusOpen && status != StatusClosed {
		return Ticket{}, fmt.Errorf("unknown ticket status: %s", dto.Status)
	}
	if dto.UnreadCount < 0 {
		return Ticket{}, errors.New("unread count must be non-negative")
	}
	updatedAt, err := time.Parse(time.RFC3339, dto.UpdatedAt)
	if err != nil {
		return Ticket{}, fmt.Errorf("invalid ticket timestamp: %w", err)
	}
	return Ticket{
		ID: dto.ID, Status: status, RequesterName: strings.TrimSpace(dto.Requester),
		UpdatedAt: updatedAt, UnreadCount: dto.UnreadCount,
	}, nil
}

func NeedsReply(ticket Ticket) bool {
	return ticket.Status != StatusClosed && ticket.UnreadCount > 0
}

type TicketSummary struct {
	ID         string `json:"id"`
	Label      string `json:"label"`
	NeedsReply bool   `json:"needsReply"`
}

func Summary(ticket Ticket) TicketSummary {
	return TicketSummary{
		ID:         ticket.ID,
		Label:      fmt.Sprintf("%s · %s", ticket.RequesterName, ticket.Status),
		NeedsReply: NeedsReply(ticket),
	}
}


func Example() (map[string]any, error) {
	dto := TicketDTO{
		ID: "T-42", Status: "open", Requester: "Mina Alvarez",
		UpdatedAt: "2026-09-15T08:30:00Z", UnreadCount: 2,
	}
	ticket, err := ToTicket(dto)
	if err != nil {
		return nil, err
	}
	return map[string]any{
		"shared": map[string]any{"label": SharedLabel(dto), "needsReply": SharedNeedsReply(dto)},
		"mapped": Summary(ticket), "isoDate": ticket.UpdatedAt.Format(time.RFC3339),
		"statusIsClosed": ticket.Status == StatusClosed,
	}, nil
}


func main() {
	result, err := Example()
	if err != nil {
		panic(err)
	}
	encoded, err := json.MarshalIndent(result, "", "  ")
	if err != nil {
		panic(err)
	}
	fmt.Println(string(encoded))
}

Save the TypeScript as contracts.ts and run node contracts.ts (Node 22.18 or later runs TypeScript directly). Save the Go as contracts.go and run go run contracts.go. Both print the shared and mapped results for ticket T-42.

05 / Try a decision

Name the owner before you name the type.

Choose the shape that preserves the required behavior. A shorter type is not a reason by itself.

An internal admin endpoint has one caller.

The team owns the endpoint and the screen, and there is no domain rule beyond displaying the fields. What is a sensible start?

A partner changes snake_case and adds fields independently.

Your ticket domain should keep its vocabulary and reject malformed status values. Which shape should own that seam?

The endpoint returns a row-count projection.

The response has only id, label, and count. A full ticket needs status and requester data. What should the screen receive?

Feedback stays on this page; it is not saved.

06 / Give it a real job

Map once when the ticket has more than one meaning.

At the endpoint, requester_name is a wire key. In the desk, the name is a person label. In a reply action, needsReply is a rule. Those callers can share the domain shape without sharing the partner’s spelling.

Outside

TicketDTO

JSON names, strings, optionality, and version drift.

Adapter owns

toTicket

Parse, normalize, reject, and test the seam.

Inside

Ticket

Domain names and the reply rule used by callers.

contracts.ts · call site
export function example() {
	const domainTicket = toTicket(incomingTicket);
	return {
		shared: {
			label: sharedLabel(incomingTicket),
			needsReply: sharedNeedsReply(incomingTicket)
		},
		mapped: summary(domainTicket),
		isoDate: domainTicket.updatedAt.toISOString(),
		statusIsClosed: domainTicket.status === 'closed'
	};
}

If the endpoint is internal and the screen only prints fields, stop before this seam. If the partner renames requester_name to requester, the adapter changes while the desk’s domain vocabulary stays put. That is the accepted cost paying for itself.

Build UIs?A component can render a payload directly, or receive a domain shape when the rule belongs to more than one screen.

Where it already is in your components

A read-only ticket row often receives the response shape and prints it. The component is a projection, not the owner of the transport boundary. React props and Svelte props can both make that direct relationship clear when the endpoint and row are released together.

When you have to own it

When a card, reply button, notification badge, and keyboard shortcut all need the same needsReply rule, map at the data boundary and pass a domain shape down. The components should not each parse status strings or repeat the partner’s field names.

A thin ticket row renders the DTO directly; the endpoint and the component share one small read-only meaning.

ReactAlready in your code
TicketRow.tsx
type TicketDTO = {
	id: string;
	status: string;
	requester_name: string;
	unread_count: number;
};

export function TicketRow({ ticket }: { ticket: TicketDTO }) {
	return (
		<li>
			<strong>{ticket.requester_name}</strong>
			<span>{ticket.status}</span>
			{ticket.unread_count > 0 && <b>{ticket.unread_count} unread</b>}
		</li>
	);
}

07 / The parts to watch

A mapper moves responsibility; it does not remove it.

These are the costs worth naming before the seam grows.

A mapper can drift

When a DTO gains a field, decide whether the domain needs it. When a domain rule changes, decide whether the external contract must change. Mapping tests should make an intentional omission visible.

A domain model can be too much

A plain domain object plus functions is enough for this lesson. Do not add a class, repository, ORM entity, or event system just to justify a second type.

Types do not validate JSON

TypeScript’s TicketDTO annotation does not inspect a network response. Go’s struct tags do not reject an unknown status either. The boundary function must parse and validate.

A projection is a third shape

A summary response with only id, label, and count is not a defective full ticket. Name it as a projection and keep its smaller contract explicit.

08 / Make the call

Choose the seam you can explain.

For the ticket list owned by one team, share the shape while the screen only displays the endpoint’s fields. For the partner endpoint or the reply rule used across screens, map at the boundary. For a summary endpoint, name the projection as its own small shape. Accept the mapper’s tests and field decisions in exchange for independent change.

WhyWhat job must the caller do?

Render fields, enforce a rule, or survive an outside contract?

WhatWhich shape crosses the seam?

DTO directly, domain model after mapping, or a named projection?

ConstraintWho else changes the contract?

A partner, a second screen with the same rule, or only the team that ships both sides?

FallbackWhat happens to input the domain can’t accept?

The adapter rejects it, so no caller sees a half-parsed ticket.

Reconsider whenWhat changed?

A second owner, a second meaning, or an independently deployed contract appears.

Further reading: Martin Fowler’s Data Transfer Object and Domain Model entries. They describe patterns; this lesson adds the conditional choice for this boundary.

09 / Take the idea with you

Explain the ticket without saying “DTO.”

“The partner sends its own field names and date strings. One function turns that into our ticket, and everything past it reads our ticket.” That tells a reviewer where the seam is and why it exists. When the reviewer wants the words, the partner’s shape is the DTO and ours is the domain model.

Before moving on, jot down one endpoint you render straight from the response, one you would map, and the change that would move the first into the second.

Connections to follow nextRelated lessons
  • The mapping layer gives the seam in toTicket a home of its own once several shapes need translating.
  • Parse, don’t validate is what toTicket does: it returns a checked shape instead of a yes or no.
  • Value objects keep a domain value’s meaning and rules together, the next step after a checked status.
  • Validation at the edge asks what the boundary must reject before any domain code runs.
  • API contracts looks at the partner’s side: the agreement the DTO is written against.

Take the ticket into your editor. Add a priority field to the partner DTO, decide whether Ticket needs it, and write the mapping test that makes that choice visible.

Back to Concepts & practices →