Skip to content

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 ​

go
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 ​

go
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:

go
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 ​

go
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) ​

go
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) ​

go
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:

go
// 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 ​

TagDescriptionExample
requiredField must be presentvalidate:"required"
omitemptySkip validation if emptyvalidate:"omitempty,email"
max=NMaximum length/valuevalidate:"max=200"
min=NMinimum length/valuevalidate:"min=1"
emailValid email formatvalidate:"email"
datetime=FORMATValid datetimevalidate:"datetime=2006-01-02"
oneof=A B COne of specified valuesvalidate:"oneof=ACTIVE INACTIVE"
gt=NGreater thanvalidate:"gt=0"
gte=NGreater than or equalvalidate:"gte=0"
lte=NLess than or equalvalidate:"lte=100"
gtefield=FGreater than or equal to another fieldvalidate:"gtefield=CreditHoursMin"

Complex Validation ​

go
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:

go
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 ​

go
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 ​

go
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 ​

go
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:

go
// 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
}

Student Information System Documentation