Stage 7 · Master
Phase 2 — Organization Service
Models
Give the organizations table a Go struct that mirrors it exactly, with a status type that cannot represent an invalid value by accident.
One Struct, One Row, No More Responsibilities
internal/model/organization.go exists to do exactly one job: represent a row from the organizations table as a Go value. It has no JSON tags (that is the DTO layer's concern, next lesson), no validation logic (the Validation lesson's concern), and no HTTP awareness whatsoever. Every field here corresponds to a column in docs/schema/organization.md — nothing more, nothing less.
A Status Type That Cannot Be Invalid by Accident
// Package model defines Go representations of database rows. Nothing in
// this package knows about JSON, HTTP, or validation — see internal/dto
// and the Validation lesson for those concerns.
package model
import (
"time"
"github.com/google/uuid"
)
// Status is the organization's lifecycle state, matching the CHECK
// constraint on the organizations.status column exactly.
type Status string
const (
StatusActive Status = "active"
StatusSuspended Status = "suspended"
StatusArchived Status = "archived"
)
// Valid reports whether s is one of the three statuses the database
// constraint allows. It exists so Go code can reject an invalid status
// before ever sending it to Postgres, not only after the constraint fires.
func (s Status) Valid() bool {
switch s {
case StatusActive, StatusSuspended, StatusArchived:
return true
default:
return false
}
}
// Organization mirrors one row of the organizations table exactly — see
// docs/schema/organization.md for the authoritative column list.
type Organization struct {
ID uuid.UUID
Name string
Slug string
RegistrationNumber string
ContactEmail string
ContactPhone string
AddressLine1 string
AddressLine2 string
City string
State string
PostalCode string
Country string
Status Status
CreatedAt time.Time
UpdatedAt time.Time
}
Why internal/model Never Imports dto or gin
The dependency direction is one-way: dto and handler packages will import model to build responses from it, but model must never import dto, gin, or anything HTTP-related. If model depended on dto, the two packages would form the kind of circular reasoning about responsibility that layered architecture exists to prevent — a model existing 'for' a specific response shape, rather than for the database row it actually represents.
The Mass-Assignment Risk This Motivates for the Next Lesson
It would be technically possible for a future handler to call c.ShouldBindJSON(&organization) directly against a model.Organization — Gin does not stop you. Doing so would let a client's JSON body set any exported field, including Status and CreatedAt, directly. A public organization-registration endpoint that bound straight into the model would let an attacker's request body set status to \"active\" and bypass whatever approval workflow a manager was supposed to perform — a real class of bug called mass assignment. The DTOs lesson immediately after this one exists specifically to make that impossible.
A Unit Test as the Diagnostic, Since There Is No Handler Yet
package model_test
import (
"testing"
"github.com/hoa-platform/backend/services/organization/internal/model"
)
func TestStatus_Valid(t *testing.T) {
cases := []struct {
status model.Status
want bool
}{
{model.StatusActive, true},
{model.StatusSuspended, true},
{model.StatusArchived, true},
{model.Status("deleted"), false},
{model.Status(""), false},
}
for _, tc := range cases {
if got := tc.status.Valid(); got != tc.want {
t.Errorf("Status(%q).Valid() = %v, want %v", tc.status, got, tc.want)
}
}
}
Applied exercise
Add a String method and use it to catch a subtle formatting bug
Status is a defined type over string, so fmt.Sprintf("%s", status) already works — but confirm that yourself instead of assuming it.
- Write a short table test asserting fmt.Sprintf("%s", model.StatusSuspended) == "suspended" without adding any new method.
- Now deliberately break it by testing fmt.Sprintf("%d", model.StatusSuspended) and observe what verb mismatch produces (it will not be a compile error).
- Explain in one sentence why %d against a string-based type does not fail at compile time, only at output.
Deliverable
A short test file addition plus a one-sentence explanation of the %d/%s verb mismatch behavior.
Completion checks
- The %s case passes exactly as described.
- The explanation correctly notes that fmt verb mismatches are a runtime formatting concern (producing a %!d(string=...) style output), not a compile-time type error.
Why does internal/model deliberately avoid JSON struct tags on Organization?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.