Stage 7 · Master
Phase 6 — Flat Service
Validation
Constraints in two places, for two different reasons
Flat Invariants Added to the Existing Validation Contract
Organization Service's validation lesson owns validator wiring, and User Service's error-mapping lesson owns the 422-versus-409 distinction. Flat Service contributes only the inventory-specific constraints: legal status values, positive area, non-negative floor, and clean SQLSTATE translation when those guarantees are bypassed.
Promoting Status to an ENUM and Adding CHECKs
-- A native ENUM rejects an invalid status at the type level, before a
-- CHECK constraint would even run. It also documents the legal set in
-- \d flats output for anyone poking around with psql.
CREATE TYPE flat_status AS ENUM ('vacant', 'occupied', 'under_maintenance', 'blocked');
ALTER TABLE flats
ALTER COLUMN status DROP DEFAULT,
ALTER COLUMN status TYPE flat_status USING status::flat_status,
ALTER COLUMN status SET DEFAULT 'vacant';
ALTER TABLE flats
ADD CONSTRAINT flats_area_sqft_positive CHECK (area_sqft > 0),
ADD CONSTRAINT flats_floor_number_non_negative CHECK (floor_number >= 0);ALTER TABLE flats
DROP CONSTRAINT IF EXISTS flats_area_sqft_positive,
DROP CONSTRAINT IF EXISTS flats_floor_number_non_negative;
ALTER TABLE flats
ALTER COLUMN status DROP DEFAULT,
ALTER COLUMN status TYPE TEXT,
ALTER COLUMN status SET DEFAULT 'vacant';
DROP TYPE IF EXISTS flat_status;Application Validation and CHECK-Violation Mapping
package validate
import "fmt"
type FieldError struct {
Field string
Message string
}
func (e FieldError) Error() string {
return fmt.Sprintf("%s: %s", e.Field, e.Message)
}
// Flat mirrors the database constraints from migration 0004 exactly. If
// a rule changes here, it must change there too — that duplication is
// the point of this lesson, not an oversight.
func Flat(unitNumber string, floorNumber int, areaSqft float64) []FieldError {
var errs []FieldError
if unitNumber == "" {
errs = append(errs, FieldError{"unit_number", "must not be empty"})
}
if floorNumber < 0 {
errs = append(errs, FieldError{"floor_number", "must be zero or greater"})
}
if areaSqft <= 0 {
errs = append(errs, FieldError{"area_sqft", "must be greater than zero"})
}
return errs
}func (r *FlatRepository) Create(ctx context.Context, f domain.Flat) (domain.Flat, error) {
const q = `
INSERT INTO flats (tenant_id, building_id, unit_number, floor_number, area_sqft, status)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, created_at, updated_at`
row := r.pool.QueryRow(ctx, q, f.TenantID, f.BuildingID, f.UnitNumber, f.FloorNumber, f.AreaSqft, f.Status)
if err := row.Scan(&f.ID, &f.CreatedAt, &f.UpdatedAt); err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
switch pgErr.Code {
case "23505": // unique_violation
return domain.Flat{}, domain.ErrDuplicateUnitNumber
case "23514": // check_violation
return domain.Flat{}, domain.ErrInvalidFlat
case "23503": // foreign_key_violation
return domain.Flat{}, domain.ErrBuildingNotFound
}
}
return domain.Flat{}, err
}
return f, nil
}This mapping is the safety net for the case application validation was skipped or is out of sync with the database — the caller still gets a domain error, never a raw pgconn.PgError leaking SQLSTATE codes.
Constraint Bypass Drill
| Bypass path | Database response | Repository response |
|---|---|---|
| Negative area through SQL | 23514 check_violation | ErrInvalidFlat |
| Missing building row | 23503 foreign_key_violation | ErrBuildingNotFound |
| Repeated unit in one building | 23505 unique_violation | ErrDuplicateUnitNumber |
Applied exercise
Close the gap between the two validation layers
A teammate adds blocked_reason TEXT to the flats table for status 'blocked' and requires it to be non-empty whenever status is 'blocked' — but only then. A plain CHECK on one column cannot express 'required if another column has a specific value.'
- Write a multi-column CHECK constraint: CHECK (status <> 'blocked' OR blocked_reason IS NOT NULL).
- Add the matching application-level rule in validate.Flat so the 422 path catches it before the database does.
- Write one test proving the CHECK rejects a direct SQL insert with status='blocked' and no reason, and one proving the application validator rejects the same input before any query runs.
Deliverable
Migration 0005 adding the column and constraint, plus the updated validator and its test.
Completion checks
- status='occupied' with blocked_reason NULL is still accepted.
- status='blocked' with blocked_reason NULL is rejected at both layers.
- The two rejection paths return the same information, not just the same HTTP status.
A native Postgres ENUM was chosen over a CHECK IN (...) constraint for the status column. What does the ENUM add beyond rejecting bad values?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.