DTOs (Data Transfer Objects)
Request and response patterns for the Go backend.
DTO Pattern
DTOs are defined in dto.go files within each domain. They handle:
- Request validation using struct tags
- Response formatting with JSON tags
- Separating internal models from API contracts
Request DTOs
Create Request
type CreatePersonRequest struct {
DisplayName string `json:"display_name" validate:"required,max=200"`
PrimaryEmail string `json:"primary_email" validate:"required,email"`
DateOfBirth *string `json:"date_of_birth" validate:"omitempty,datetime=2006-01-02"`
GenderID *int32 `json:"gender_id"`
EthnicityID *int32 `json:"ethnicity_id"`
}Update Request
type UpdatePersonRequest struct {
DisplayName *string `json:"display_name" validate:"omitempty,max=200"`
PrimaryEmail *string `json:"primary_email" validate:"omitempty,email"`
DateOfBirth *string `json:"date_of_birth" validate:"omitempty,datetime=2006-01-02"`
GenderID *int32 `json:"gender_id"`
EthnicityID *int32 `json:"ethnicity_id"`
}A pointer field cannot tell "the client did not send this" from "the client sent null to clear it" — both decode to nil. An update DTO that is written with db.Patch (see Update with Partial Updates) therefore also records which keys the body contained, and carries the values the client originally loaded under _expected:
type PaymentPlanUpdateDTO struct {
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=200"`
Description *string `json:"description,omitempty" validate:"omitempty,max=500"`
// ...
// The values the client loaded for the fields it is changing. Not
// validated: it describes what is stored, which may predate today's rules.
Expected *PaymentPlanUpdateDTO `json:"_expected,omitempty" validate:"-"`
present patch.Present
}
func (d *PaymentPlanUpdateDTO) UnmarshalJSON(data []byte) error {
type alias PaymentPlanUpdateDTO
var a alias
if err := json.Unmarshal(data, &a); err != nil {
return err
}
*d = PaymentPlanUpdateDTO(a)
var err error
d.present, err = patch.Keys(data)
return err
}
// Has must be nil-safe: patch.Field calls it on Expected, which is nil when the
// client sent no _expected.
func (d *PaymentPlanUpdateDTO) Has(key string) bool { return d != nil && d.present.Has(key) }
// Mark records key as sent, for a field the handler sets in code after decoding.
func (d *PaymentPlanUpdateDTO) Mark(key string) { d.present.Mark(key) }The two members and two methods are copied into each DTO rather than embedded: an embedded helper's UnmarshalJSON would take over decoding of the whole body. A handler that sets a field in code after decoding must call Mark, or the patch never sees it.
Query Parameters
type PersonQueryParams struct {
db.PaginationParams // Embeds Page, PageSize, GetOffset(), GetLimit()
Search *string `json:"search"`
OrderBy string `json:"order_by"`
StatusCode string `json:"status_code"`
GenderCode string `json:"gender_code"`
}Response DTOs
List Response (Lightweight)
type PersonListResponse struct {
PersonID string `json:"person_id"`
DisplayName string `json:"display_name"`
PrimaryEmail *string `json:"primary_email,omitempty"`
StatusCode string `json:"status_code"`
StatusDisplay string `json:"status_display"`
}Detail Response (Full)
type PersonDetailResponse struct {
PersonID string `json:"person_id"`
PersonIDNumber *string `json:"person_id_number,omitempty"`
DisplayName string `json:"display_name"`
PrimaryEmail *string `json:"primary_email,omitempty"`
DateOfBirth *string `json:"date_of_birth,omitempty"`
GenderCode *string `json:"gender_code,omitempty"`
GenderDisplay *string `json:"gender_display,omitempty"`
EthnicityCode *string `json:"ethnicity_code,omitempty"`
EthnicityDisplay *string `json:"ethnicity_display,omitempty"`
StatusCode string `json:"status_code"`
StatusDisplay string `json:"status_display"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
// Nested resources
Names []PersonNameResponse `json:"names,omitempty"`
Addresses []PersonAddressResponse `json:"addresses,omitempty"`
Phones []PersonPhoneResponse `json:"phones,omitempty"`
Emails []PersonEmailResponse `json:"emails,omitempty"`
}Paginated Response
The shared db.PaginatedResult[T] generic type is used for list endpoints:
// From shared/db/pagination.go
type PaginatedResult[T any] struct {
Count int64 `json:"count"`
Next *string `json:"next"`
Previous *string `json:"previous"`
Results []T `json:"results"`
}Validation Tags
Use validate tags from go-playground/validator/v10:
Common Validators
| Tag | Description | Example |
|---|---|---|
required | Field must be present | validate:"required" |
omitempty | Skip validation if empty | validate:"omitempty,email" |
max=N | Maximum length/value | validate:"max=200" |
min=N | Minimum length/value | validate:"min=1" |
email | Valid email format | validate:"email" |
datetime=FORMAT | Valid datetime | validate:"datetime=2006-01-02" |
oneof=A B C | One of specified values | validate:"oneof=ACTIVE INACTIVE" |
gt=N | Greater than | validate:"gt=0" |
gte=N | Greater than or equal | validate:"gte=0" |
lte=N | Less than or equal | validate:"lte=100" |
gtefield=F | Greater than or equal to another field | validate:"gtefield=CreditHoursMin" |
Complex Validation
type CreateCourseRequest struct {
CourseCode string `json:"course_code" validate:"required,max=20"`
Title string `json:"title" validate:"required,max=200"`
Description *string `json:"description" validate:"omitempty,max=4000"`
CreditHoursMin float64 `json:"credit_hours_min" validate:"required,gte=0,lte=20"`
CreditHoursMax float64 `json:"credit_hours_max" validate:"required,gte=0,lte=20,gtefield=CreditHoursMin"`
DepartmentID int32 `json:"department_id" validate:"required,gt=0"`
StatusID int32 `json:"status_id" validate:"required,gt=0"`
}JSON Tags
Control JSON serialization:
type Response struct {
// Always included
ID int32 `json:"id"`
Name string `json:"name"`
// Omit if nil/empty
Description *string `json:"description,omitempty"`
// Never serialize (internal use)
PasswordHash string `json:"-"`
// Custom field name
CreatedAt time.Time `json:"created_at"`
}Converting Model to DTO
func toPersonResponse(p *Person) *PersonDetailResponse {
var dob *string
if p.DateOfBirth != nil {
s := p.DateOfBirth.Format("2006-01-02")
dob = &s
}
return &PersonDetailResponse{
PersonID: p.PersonID,
DisplayName: p.DisplayName,
PrimaryEmail: p.PrimaryEmail,
DateOfBirth: dob,
GenderCode: nilString(p.GenderCode),
GenderDisplay: nilString(p.GenderDisplay),
StatusCode: p.StatusCode,
StatusDisplay: p.StatusDisplay,
CreatedAt: p.CreatedAt.Format(time.RFC3339),
UpdatedAt: p.UpdatedAt.Format(time.RFC3339),
}
}
func nilString(s string) *string {
if s == "" {
return nil
}
return &s
}Converting DTO to Model
func (req *CreatePersonRequest) ToModel() *Person {
var dob *time.Time
if req.DateOfBirth != nil {
t, _ := time.Parse("2006-01-02", *req.DateOfBirth)
dob = &t
}
return &Person{
PersonID: uuid.NewString(),
DisplayName: req.DisplayName,
PrimaryEmail: &req.PrimaryEmail,
DateOfBirth: dob,
GenderID: req.GenderID,
EthnicityID: req.EthnicityID,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
}Handler Validation Pattern
func (h *Handler) CreatePerson(w http.ResponseWriter, r *http.Request) {
// Parse JSON body
var req CreatePersonRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.BadRequest(w, "Invalid JSON format")
return
}
// Validate request
if err := h.validator.Struct(&req); err != nil {
response.ValidationError(w, formatValidationErrors(err))
return
}
// Call service
person, err := h.service.Create(r.Context(), &req)
if err != nil {
response.HandleError(w, err)
return
}
response.Created(w, person)
}
func formatValidationErrors(err error) map[string]string {
errors := make(map[string]string)
for _, err := range err.(validator.ValidationErrors) {
field := strings.ToLower(err.Field())
errors[field] = getValidationMessage(err)
}
return errors
}Multiple Response Types
Different endpoints return different levels of detail:
// List endpoint - lightweight
func (s *service) List(ctx context.Context, params *QueryParams) (*db.PaginatedResult[PersonListResponse], error) {
persons, total, err := s.repo.FindAll(ctx, params)
if err != nil {
return nil, err
}
results := make([]PersonListResponse, len(persons))
for i, p := range persons {
results[i] = toPersonListResponse(p)
}
return &db.PaginatedResult[PersonListResponse]{
Count: total,
Results: results,
}, nil
}
// Detail endpoint - full data with nested resources
func (s *service) GetByID(ctx context.Context, id string) (*PersonDetailResponse, error) {
person, err := s.repo.FindByID(ctx, id)
if err != nil {
return nil, err
}
// Load related data
names, _ := s.nameRepo.FindByPersonID(ctx, id)
addresses, _ := s.addressRepo.FindByPersonID(ctx, id)
resp := toPersonDetailResponse(person)
resp.Names = toNameResponses(names)
resp.Addresses = toAddressResponses(addresses)
return resp, nil
}