Stage 7 · Master
Phase 2 — Organization Service
Validation
Reject malformed input before it ever reaches a service method — and give the slug format a rule precise enough to write a regex for.
ShouldBindJSON Confirms Shape, Not Correctness
c.ShouldBindJSON already rejects a request body that isn't valid JSON or doesn't match CreateOrganizationRequest's field types. It does not reject an empty name, a slug containing uppercase letters or spaces, or a contact_email that isn't actually an email address — all of those are syntactically valid JSON strings. internal/validation closes that gap with a single shared *validator.Validate instance and one custom rule this platform actually needs: slug.
One Custom Rule, Not a General-Purpose Validation Framework
validator ships dozens of built-in tags (required, email, max, len) that cover almost everything this DTO needs. The only rule this platform actually invented is slug: lowercase letters and digits, hyphen-separated, no leading, trailing, or doubled hyphens — because a slug becomes part of a URL path and, later in this course, a Kubernetes-adjacent DNS-safe identifier, so both ends of that lifecycle need the same narrow shape enforced once, here.
package validation
import (
"regexp"
"github.com/go-playground/validator/v10"
)
var slugPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
// New builds a *validator.Validate with this platform's one custom rule
// registered. It is constructed once, in the composition root, and shared
// — validator.Validate caches struct reflection internally, so building a
// fresh instance per request would silently discard that cache every time.
func New() *validator.Validate {
v := validator.New()
_ = v.RegisterValidation("slug", func(fl validator.FieldLevel) bool {
return slugPattern.MatchString(fl.Field().String())
})
return v
}
// FieldErrors flattens validator's *validator.InvalidValidationError /
// validator.ValidationErrors into the plain map[string]string shape
// apperr.InvalidInput expects, so a handler never has to import validator
// itself to report a failure.
func FieldErrors(err error) map[string]string {
fieldErrs, ok := err.(validator.ValidationErrors)
if !ok {
return map[string]string{"body": err.Error()}
}
details := make(map[string]string, len(fieldErrs))
for _, fe := range fieldErrs {
details[fe.Field()] = fe.Tag()
}
return details
}
Wiring validate Tags Onto CreateOrganizationRequest
type CreateOrganizationRequest struct {
Name string `json:"name" validate:"required,min=2,max=200"`
Slug string `json:"slug" validate:"required,slug,max=100"`
RegistrationNumber string `json:"registration_number" validate:"required,max=50"`
ContactEmail string `json:"contact_email" validate:"required,email"`
ContactPhone string `json:"contact_phone" validate:"omitempty,max=30"`
AddressLine1 string `json:"address_line1" validate:"required,max=200"`
AddressLine2 string `json:"address_line2" validate:"omitempty,max=200"`
City string `json:"city" validate:"required,max=100"`
State string `json:"state" validate:"required,max=100"`
PostalCode string `json:"postal_code" validate:"required,max=20"`
Country string `json:"country" validate:"required,len=3"`
}
// UpdateOrganizationRequest's pointer fields use omitempty so a nil field
// (an omitted key) is never validated at all — only a present, non-nil
// value is checked against the same rules as its create-request sibling.
type UpdateOrganizationRequest struct {
Name *string `json:"name" validate:"omitempty,min=2,max=200"`
ContactEmail *string `json:"contact_email" validate:"omitempty,email"`
ContactPhone *string `json:"contact_phone" validate:"omitempty,max=30"`
AddressLine1 *string `json:"address_line1" validate:"omitempty,max=200"`
AddressLine2 *string `json:"address_line2" validate:"omitempty,max=200"`
}
Country requires exactly 3 characters — an ISO 3166-1 alpha-3 code (USA, CAN, MEX) — rather than a free-text country name, because a fixed-width code is what every downstream integration in later phases (tax rules, mailing formats) will actually key off of.
The Handler Gains a Second Dependency, Not a Second Layer
type OrganizationHandler struct {
svc *service.OrganizationService
+ validate *validator.Validate
}
-func NewOrganizationHandler(svc *service.OrganizationService) *OrganizationHandler {
- return &OrganizationHandler{svc: svc}
+func NewOrganizationHandler(svc *service.OrganizationService, validate *validator.Validate) *OrganizationHandler {
+ return &OrganizationHandler{svc: svc, validate: validate}
}
func (h *OrganizationHandler) Create(c *gin.Context) {
// Existing JSON binding remains unchanged.
+ if err := h.validate.Struct(req); err != nil {
+ respondError(c, apperr.InvalidInput("validation failed", validation.FieldErrors(err)))
+ return
+ }
// Existing service call and 201 response remain unchanged.
}
func (h *OrganizationHandler) Update(c *gin.Context) {
// Existing id parsing and JSON binding remain unchanged.
+ if err := h.validate.Struct(req); err != nil {
+ respondError(c, apperr.InvalidInput("validation failed", validation.FieldErrors(err)))
+ return
+ }
// Existing service call and 200 response remain unchanged.
}Get, List, Suspend, and Archive are unchanged — they take no request body, so there is nothing for this validator to check. Only the two handler methods that bind a JSON body gain the second validate.Struct call.
repo := repository.NewOrganizationPostgres(pool)
svc := service.NewOrganizationService(repo)
orgHandler := handler.NewOrganizationHandler(svc, validation.New())
engine := router.New(orgHandler)
Only the handler construction line changes; everything above and below it in main is identical to the REST API Design lesson's version.
fe.Tag() returns the short rule name that failed — "required", "email", "slug" — not a prose sentence. A machine-readable code per field lets a future admin console highlight the exact offending input and choose its own localized message, instead of parsing an English sentence back apart to find out which rule failed.
Confirming the Custom slug Rule Actually Rejects What It Should
Applied exercise
Find a slug the regex wrongly accepts or wrongly rejects
Regexes are easy to get subtly wrong at their edges. Probe this one directly instead of trusting it by inspection.
- Write a small Go program (or a quick REPL-style test) that checks slugPattern.MatchString against: "a", "a-b", "a--b", "-a", "a-", "123", "a_b".
- Record which of these the current pattern accepts and which it rejects.
- Explain which repeated group in the regex accepts "a-b" while rejecting "a--b", then add one edge case of your own that could falsify your explanation.
Deliverable
A short table of the seven test inputs, their pass/fail result, and one additional edge-case assertion.
Completion checks
- The table correctly identifies that "a--b" is rejected because every repeated hyphen must be followed immediately by one or more lowercase alphanumeric characters.
- The added edge case is executed against the regex rather than judged only by inspection.
Why does UpdateOrganizationRequest's validate tags use omitempty on every field, while CreateOrganizationRequest largely does not?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.