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
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;
} 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.
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.
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.
| Question | Share the shape | Map 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.
Hold the ticket job fixed. Change the boundary.
The API now owns your vocabulary
A wire rename, string format, or optional field becomes a domain change. Every caller learns the partner contract.
Map once at the boundary
The adapter owns wire names and rejection. The domain keeps its own names while the partner can evolve independently.
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.
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)
};
} 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
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));
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.
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.
TicketDTO
JSON names, strings, optionality, and version drift.
toTicket
Parse, normalize, reject, and test the seam.
Ticket
Domain names and the reply rule used by callers.
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.
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.
Render fields, enforce a rule, or survive an outside contract?
DTO directly, domain model after mapping, or a named projection?
A partner, a second screen with the same rule, or only the team that ships both sides?
The adapter rejects it, so no caller sees a half-parsed ticket.
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
toTicketa home of its own once several shapes need translating. - Parse, don’t validate is what
toTicketdoes: 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.