Stage 7 · Master
Phase 3 — User Service
Validation
Move beyond null checks to enforce the domain rules that make a profile or membership meaningful: role restrictions, duplicate-invite prevention, and consistent 422 responses.
Malformed Input vs. a Broken Invariant
'email is blank' and 'this role assignment would leave the organization with zero admins' are both validation failures, but they are not the same kind of problem. The first is a malformed request — reject it before it touches any repository. The second is a business invariant that can only be checked by looking at existing data, which means it belongs in the service layer, not a struct tag.
| Check | Layer | Example |
|---|---|---|
| Field-level shape | DTO / handler | email is non-empty and looks like an email address |
| Cross-field consistency | Service | role must be one of the four defined Role constants |
| Data-dependent invariant | Service, backed by a repository read | cannot suspend the organization's last remaining org_admin membership |
One Error Shape for Every Field Failure
Organization Service's validation lesson owns edge parsing with go-playground/validator; user-service keeps the same 422 contract but makes the response useful for profile forms by returning field paths and messages rather than a single opaque string.
package user
import (
"net/mail"
"strings"
)
type FieldError struct {
Field string `json:"field"`
Message string `json:"message"`
}
type ValidationError struct {
Errors []FieldError
}
func (e *ValidationError) Error() string {
return "validation failed"
}
func ValidateCreate(req CreateRequest) *ValidationError {
var errs []FieldError
if strings.TrimSpace(req.Email) == "" {
errs = append(errs, FieldError{Field: "email", Message: "email is required"})
} else if _, err := mail.ParseAddress(req.Email); err != nil {
errs = append(errs, FieldError{Field: "email", Message: "email is not a valid address"})
}
if strings.TrimSpace(req.FirstName) == "" {
errs = append(errs, FieldError{Field: "first_name", Message: "first name is required"})
}
if strings.TrimSpace(req.LastName) == "" {
errs = append(errs, FieldError{Field: "last_name", Message: "last name is required"})
}
if len(errs) == 0 {
return nil
}
return &ValidationError{Errors: errs}
}mail.ParseAddress is stdlib, not a regex — email validation regexes are notoriously either too strict (rejecting valid addresses) or too permissive; parsing against RFC 5322 via the standard library avoids both failure modes.
A Rule That Only the Database Can Answer
The membership lifecycle from the previous lesson allows suspending an active membership. But suspending the last org_admin in an organization would strand that HOA community with no one able to manage it. This check cannot live in a struct validator — it requires counting current active org_admin memberships, which means it belongs in the service layer as a guarded operation.
package membership
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrLastAdmin = errors.New("cannot suspend the organization's last active org_admin")
type InvariantChecker struct {
pool *pgxpool.Pool
}
func NewInvariantChecker(pool *pgxpool.Pool) *InvariantChecker {
return &InvariantChecker{pool: pool}
}
// CanSuspend returns ErrLastAdmin if suspending membershipID would leave
// organizationID with zero active org_admin memberships.
func (c *InvariantChecker) CanSuspend(ctx context.Context, organizationID, membershipID uuid.UUID) error {
var role string
if err := c.pool.QueryRow(ctx,
`SELECT role FROM memberships WHERE id = $1 AND status = 'active'`,
membershipID,
).Scan(&role); err != nil {
return err
}
if role != string(RoleOrgAdmin) {
return nil
}
var activeAdmins int
if err := c.pool.QueryRow(ctx,
`SELECT count(*) FROM memberships
WHERE organization_id = $1 AND role = 'org_admin' AND status = 'active'`,
organizationID,
).Scan(&activeAdmins); err != nil {
return err
}
if activeAdmins <= 1 {
return ErrLastAdmin
}
return nil
}This check only runs when the target membership is itself an org_admin — every other role's suspension skips the count query entirely, keeping the common case cheap.
ValidationError maps to 422 with a field list a form can render inline. ErrLastAdmin maps to 409 Conflict — it's not that the request was malformed, it's that the current state of the world makes it unsafe to perform. Conflating the two into one generic 400 loses information a client actually needs.
Applied exercise
Classify three new rules
Product requests three new rules: (1) phone numbers must be E.164 format, (2) an organization cannot have more than one pending invite per email, (3) a resident cannot be promoted directly to org_admin without first being a board_member.
- For each rule, decide whether it is field-level validation or a data-dependent invariant, and justify the choice.
- For the invariant(s), name the specific query needed to check them.
- Propose the HTTP status code for each rule's failure response.
Deliverable
A three-row table: rule, layer, and status code, with one sentence of justification per row.
Completion checks
- At least one rule is correctly identified as requiring a database read.
- The status codes distinguish malformed input (422) from a state conflict (409).
Why can't the 'last org_admin' rule be enforced with a struct field validator?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.