Stage 7 · Master
Phase 2 — Organization Service
DTOs
Fix the mass-assignment gap the Models lesson identified by giving the API its own request and response shapes, mapped explicitly in both directions.
DTOs Are the Fix for Last Lesson's Mass-Assignment Gap
internal/dto/organization.go defines exactly what a client is allowed to send and exactly what this service sends back — nothing more. CreateOrganizationRequest has no Status or CreatedAt field at all, which makes it structurally impossible for a client to set either one on creation; the service layer, not the request body, decides that every new organization starts as StatusActive.
ContactPhone string `json:"contact_phone"`
AddressLine1 string `json:"address_line1"`
AddressLine2 string `json:"address_line2"`
City string `json:"city"`
State string `json:"state"`
PostalCode string `json:"postal_code"`
Country string `json:"country"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type PageMeta struct {
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
type ListOrganizationsResponse struct {
Data []OrganizationResponse `json:"data"`
Meta PageMeta `json:"meta"`
}
// ToResponse maps a storage-layer model to its public response shape
// explicitly, field by field — never through reflection, so a new field
// added to model.Organization cannot silently leak into a response until
// someone deliberately adds it here too.
func ToResponse(o *model.Organization) OrganizationResponse {
return OrganizationResponse{
ID: o.ID,
Name: o.Name,
Slug: o.Slug,
RegistrationNumber: o.RegistrationNumber,
ContactEmail: o.ContactEmail,
ContactPhone: o.ContactPhone,
AddressLine1: o.AddressLine1,
AddressLine2: o.AddressLine2,
City: o.City,
State: o.State,
PostalCode: o.PostalCode,
Country: o.Country,
Status: string(o.Status),
CreatedAt: o.CreatedAt,
UpdatedAt: o.UpdatedAt,
}
}
Why UpdateOrganizationRequest Uses Pointer Fields, Not Value Fields
If ContactEmail were a plain string field, a client sending {\"name\": \"New Name\"} — intending to change only the name — would decode ContactEmail as Go's zero value: an empty string. The handler would then have no way to distinguish 'the client didn't mention this field' from 'the client wants to clear it to empty'. A pointer field solves this exactly: json.Unmarshal only allocates and sets a pointer field when that key is present in the payload, leaving omitted fields nil.
Without pointer fields, a support agent updating only an organization's name through a partial PATCH request would silently wipe every resident-facing contact detail — email, phone, address — to empty strings, because Go's JSON decoder cannot tell 'omitted' from 'zero value' for non-pointer types.
Response Shapes Are a Public Contract, Not a Struct Dump
OrganizationResponse deliberately re-declares every field rather than embedding model.Organization directly. Embedding would mean any future field added to the model — even an internal one never meant for clients, like an internal risk score added in a later phase — appears in every API response automatically, the moment someone adds it to the struct, with no second decision point. Explicit mapping through ToResponse means a new model field requires a second, deliberate choice: does this belong in the response too?
Proving the Bug the Pointer Fields Prevent, With a Test
package dto_test
import (
"encoding/json"
"testing"
"github.com/hoa-platform/backend/services/organization/internal/dto"
)
func TestUpdateOrganizationRequest_OmittedFieldsStayNil(t *testing.T) {
payload := []byte(`{"name": "New Name"}`)
var req dto.UpdateOrganizationRequest
if err := json.Unmarshal(payload, &req); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if req.Name == nil || *req.Name != "New Name" {
t.Fatalf("expected Name to be set to \"New Name\", got %v", req.Name)
}
if req.ContactEmail != nil {
t.Fatalf("expected ContactEmail to remain nil for an omitted field, got %v", *req.ContactEmail)
}
}
Applied exercise
Prove value fields would have failed this exact test
Seeing the correct behavior pass is convincing; seeing the broken alternative fail is more convincing.
- In a throwaway copy of the test file, define a second, local struct BrokenUpdateRequest with ContactEmail as a plain string (not a pointer).
- Unmarshal the same {"name": "New Name"} payload into it and assert ContactEmail == "" afterward.
- Explain in one sentence why that assertion passing is exactly the bug this lesson's pointer-field design prevents — an empty string is indistinguishable from 'client wants to clear this field'.
- Remove the throwaway struct and test — it was only for comparison, not part of the real test suite.
Deliverable
A short note confirming the value-field version cannot distinguish omitted from cleared, with the throwaway code removed afterward.
Completion checks
- The throwaway test correctly demonstrates ContactEmail == "" for the omitted field.
- No BrokenUpdateRequest type remains in the committed test file.
A client sends `PATCH /organizations/:id` with only `{"name": "New Name"}` in the body. If UpdateOrganizationRequest.ContactEmail were a plain `string` field instead of `*string`, what would ContactEmail decode to?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.