Stage 7 · Master
Phase 6 — Flat Service
Relationships
Introducing the building hierarchy without breaking flats
Why Buildings Arrive Second, Not First
CRUD gave you a flat with no notion of which physical building it sits in. That was deliberate: buildings introduce a foreign key, and a foreign key you add after data exists behaves very differently from one you design in up front. This lesson is as much about safe schema evolution as it is about the buildings table itself.
Expand-Then-Contract Migration
You cannot add a NOT NULL foreign key column to a table that already has rows without a value for it. The safe sequence is: create the new table, add the column nullable, backfill it, then tighten the constraint. Three migrations, each one a complete deployable step.
CREATE TABLE buildings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
name TEXT NOT NULL,
address TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX buildings_tenant_id_idx ON buildings (tenant_id);DROP TABLE IF EXISTS buildings;-- Step 1: add nullable so the migration does not lock on a NOT NULL
-- rewrite of an already-populated table.
ALTER TABLE flats ADD COLUMN building_id UUID REFERENCES buildings(id);
-- Step 2: in a fresh environment there is nothing to backfill; in a
-- populated one, this is where an operator-run backfill script would
-- assign every existing flat to a real building before step 3 runs.
-- Step 3: tighten once every row has a value. Ship this as its own
-- migration in a follow-up deploy for a populated database — bundled
-- here because this lesson's local environment starts empty.
ALTER TABLE flats ALTER COLUMN building_id SET NOT NULL;
-- Unit numbers are unique per building now, not per tenant — two
-- buildings in the same HOA can each have an "A-101".
DROP INDEX IF EXISTS flats_tenant_unit_number_key;
CREATE UNIQUE INDEX flats_building_unit_number_key
ON flats (building_id, unit_number);
CREATE INDEX flats_building_id_idx ON flats (building_id);Dropping the old tenant-scoped unique index and replacing it with a building-scoped one is a real constraint change, not just an addition.
DROP INDEX IF EXISTS flats_building_id_idx;
DROP INDEX IF EXISTS flats_building_unit_number_key;
CREATE UNIQUE INDEX flats_tenant_unit_number_key ON flats (tenant_id, unit_number);
ALTER TABLE flats DROP COLUMN building_id;Building Domain, Repository, and FK Error Mapping
package domain
import (
"errors"
"time"
"github.com/google/uuid"
)
type Building struct {
ID uuid.UUID
TenantID uuid.UUID
Name string
Address string
CreatedAt time.Time
}
var ErrBuildingNotFound = errors.New("building: not found")type Flat struct {
ID uuid.UUID
TenantID uuid.UUID
BuildingID uuid.UUID // new: every flat now belongs to exactly one building
UnitNumber string
FloorNumber int
AreaSqft float64
Status FlatStatus
CreatedAt time.Time
UpdatedAt time.Time
}The createFlatRequest DTO in flat_handler.go gains a matching BuildingID uuid.UUID \json:"building_id" binding:"required"\`` field, parsed from the request body the same way UnitNumber already is.
package postgres
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/hoa-platform/backend/services/flat-service/internal/domain"
)
type BuildingRepository struct {
pool *pgxpool.Pool
}
func NewBuildingRepository(pool *pgxpool.Pool) *BuildingRepository {
return &BuildingRepository{pool: pool}
}
func (r *BuildingRepository) Create(ctx context.Context, b domain.Building) (domain.Building, error) {
const q = `
INSERT INTO buildings (tenant_id, name, address)
VALUES ($1, $2, $3)
RETURNING id, created_at`
row := r.pool.QueryRow(ctx, q, b.TenantID, b.Name, b.Address)
if err := row.Scan(&b.ID, &b.CreatedAt); err != nil {
return domain.Building{}, err
}
return b, nil
}
// Exists is used by FlatRepository.Create to validate a building_id before
// attempting the insert, so the FK violation path below is a defensive
// backstop rather than the primary error path.
func (r *BuildingRepository) Exists(ctx context.Context, tenantID, id uuid.UUID) (bool, error) {
const q = `SELECT 1 FROM buildings WHERE tenant_id = $1 AND id = $2`
var exists int
err := r.pool.QueryRow(ctx, q, tenantID, id).Scan(&exists)
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
return err == nil, err
}FlatRepository.Create now accepts a building_id. Two failure modes matter: the building does not exist at all (caught by the explicit Exists check, returned as a clean 422), and — the case a naive implementation misses — the building exists but belongs to a different tenant. Because BuildingRepository.Exists filters by tenant_id too, a cross-tenant building_id fails the exact same way as a nonexistent one: the caller gets 'building not found,' never a hint that the ID belongs to someone else's HOA.
type BuildingChecker interface {
Exists(ctx context.Context, tenantID, id uuid.UUID) (bool, error)
}
type FlatService struct {
repo FlatRepository
buildings BuildingChecker
}
func NewFlatService(repo FlatRepository, buildings BuildingChecker) *FlatService {
return &FlatService{repo: repo, buildings: buildings}
}cmd/api/main.go's wiring changes to service.NewFlatService(flatRepo, postgres.NewBuildingRepository(pool)).
func (s *FlatService) Create(ctx context.Context, f domain.Flat) (domain.Flat, error) {
ok, err := s.buildings.Exists(ctx, f.TenantID, f.BuildingID)
if err != nil {
return domain.Flat{}, err
}
if !ok {
return domain.Flat{}, domain.ErrBuildingNotFound
}
f.Status = domain.FlatStatusVacant
return s.repo.Create(ctx, f)
}This is tenant isolation enforced twice, on purpose: once in the flats table's own tenant_id column, and again on every foreign key it references. A single missed check anywhere in that chain would let tenant A attach a flat to tenant B's building.
Rehearsing the Expand-and-Contract Cutover
Applied exercise
Backfill a populated environment safely
Staging already has 40 flats created before this migration existed. Running migration 0003 as written will fail: SET NOT NULL rejects rows where building_id is still NULL.
- Split migration 0003 into three real, independently deployable migrations: add nullable column, backfill script, tighten to NOT NULL.
- Write the backfill as a script that creates one 'Unassigned' building per tenant and points every NULL building_id flat at it.
- Confirm the tightening migration only runs after the backfill has zero remaining NULL rows, and have it fail loudly if it does not.
Deliverable
Three migration files replacing 0003, plus a backfill query that is safe to re-run.
Completion checks
- Running the three migrations in order against a seeded dataset succeeds.
- No flat is left with a NULL building_id.
- The backfill query is idempotent — running it twice does not create duplicate 'Unassigned' buildings per tenant.
Why split the building_id column addition into add-nullable, backfill, then set-not-null instead of one migration?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.