Stage 7 · Master
Phase 7 — Resident Service
Relationships
Ownership and occupancy are different relationships to the same flat
Two Relationships, Not One
The Ownership lesson built a legal record: who holds title to a flat, and what share. It says nothing about who is physically living there today. This lesson adds occupancy — the time-bounded fact of a resident residing in a flat, whether or not they own it.
| Ownership | Occupancy | |
|---|---|---|
| Answers | Who legally owns this flat, and how much of it | Who currently lives here |
| Changes on | Sale, inheritance, deed transfer | Move-in, move-out, lease start/end |
| Can exist without the other? | Yes — an owner can rent the flat out and never occupy it | Yes — a tenant-resident occupies without any ownership record |
| History preserved by | effective_to on the ownership row | move_out_date on the occupancy row |
Schema: Occupancy With an Exclusion Constraint
A flat can have multiple concurrent occupants (a family), so a simple 'one open occupancy per flat' rule is wrong. What must never happen is two different *tenant-type* (rented, non-owner) occupancies overlapping for the same flat — that would mean the HOA's records show the same unit rented to two unrelated households at once. PostgreSQL's exclusion constraints, over a date range, enforce exactly that.
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TYPE occupancy_type AS ENUM ('owner_occupied', 'tenant');
CREATE TABLE occupancies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
flat_id UUID NOT NULL, -- owned by flat-service; no FK across services
resident_id UUID NOT NULL REFERENCES residents(id),
occupancy_type occupancy_type NOT NULL,
move_in_date DATE NOT NULL,
move_out_date DATE, -- NULL means still resident today
CHECK (move_out_date IS NULL OR move_out_date > move_in_date)
);
CREATE INDEX occupancies_tenant_flat_idx ON occupancies (tenant_id, flat_id);
CREATE INDEX occupancies_resident_id_idx ON occupancies (resident_id);
-- A resident cannot have two simultaneously-open occupancy rows for the
-- same flat (that would just be the same fact recorded twice).
CREATE UNIQUE INDEX occupancies_open_resident_flat_key
ON occupancies (flat_id, resident_id)
WHERE move_out_date IS NULL;
-- The real HOA rule: no two *tenant* occupancies for the same flat may
-- have overlapping date ranges. Family members occupying the same flat
-- concurrently is fine (they are separate rows, none of them
-- necessarily 'tenant'); renting the same unit to two different
-- households at once is not.
ALTER TABLE occupancies ADD CONSTRAINT occupancies_no_overlapping_tenancy
EXCLUDE USING gist (
flat_id WITH =,
daterange(move_in_date, COALESCE(move_out_date, 'infinity'::date), '[)') WITH &&
)
WHERE (occupancy_type = 'tenant');The EXCLUDE constraint only applies WHERE occupancy_type = 'tenant' — owner-occupied rows for the same flat, or family members sharing one occupied unit, never trip it. Open-ended occupancy is modeled as ending at 'infinity' so the daterange overlap check works even before a move-out date is known.
ALTER TABLE occupancies DROP CONSTRAINT IF EXISTS occupancies_no_overlapping_tenancy;
DROP TABLE IF EXISTS occupancies;
DROP TYPE IF EXISTS occupancy_type;Repository: Move-In and Move-Out Preserve History
package postgres
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/hoa-platform/backend/services/resident-service/internal/domain"
)
type OccupancyRepository struct {
pool *pgxpool.Pool
}
func NewOccupancyRepository(pool *pgxpool.Pool) *OccupancyRepository {
return &OccupancyRepository{pool: pool}
}
func (r *OccupancyRepository) MoveIn(ctx context.Context, o domain.Occupancy) (domain.Occupancy, error) {
const q = `
INSERT INTO occupancies (tenant_id, flat_id, resident_id, occupancy_type, move_in_date)
VALUES ($1, $2, $3, $4, $5)
RETURNING id`
row := r.pool.QueryRow(ctx, q, o.TenantID, o.FlatID, o.ResidentID, o.OccupancyType, o.MoveInDate)
if err := row.Scan(&o.ID); err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23P01" { // exclusion_violation
return domain.Occupancy{}, domain.ErrOverlappingTenancy
}
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return domain.Occupancy{}, domain.ErrResidentAlreadyOccupies
}
return domain.Occupancy{}, err
}
return o, nil
}
// MoveOut never deletes the row — it stamps move_out_date so
// "who has ever lived in this flat" remains a complete, queryable
// history. CurrentOccupants below is the only place that filters it away.
func (r *OccupancyRepository) MoveOut(ctx context.Context, tenantID, occupancyID uuid.UUID, moveOutDate string) error {
const q = `
UPDATE occupancies SET move_out_date = $3
WHERE tenant_id = $1 AND id = $2 AND move_out_date IS NULL`
tag, err := r.pool.Exec(ctx, q, tenantID, occupancyID, moveOutDate)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return domain.ErrOccupancyNotFoundOrAlreadyClosed
}
return nil
}
func (r *OccupancyRepository) CurrentOccupants(ctx context.Context, tenantID, flatID uuid.UUID) ([]domain.Occupancy, error) {
const q = `
SELECT id, tenant_id, flat_id, resident_id, occupancy_type, move_in_date, move_out_date
FROM occupancies
WHERE tenant_id = $1 AND flat_id = $2 AND move_out_date IS NULL`
rows, err := r.pool.Query(ctx, q, tenantID, flatID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []domain.Occupancy
for rows.Next() {
var o domain.Occupancy
if err := rows.Scan(&o.ID, &o.TenantID, &o.FlatID, &o.ResidentID, &o.OccupancyType, &o.MoveInDate, &o.MoveOutDate); err != nil {
return nil, err
}
result = append(result, o)
}
return result, rows.Err()
}
// History returns every occupancy this flat has ever had, open or
// closed, ordered oldest-first — this is what "resident history" means
// in this platform: never an UPDATE that erases the past, always a new
// row alongside every prior one.
func (r *OccupancyRepository) History(ctx context.Context, tenantID, flatID uuid.UUID) ([]domain.Occupancy, error) {
const q = `
SELECT id, tenant_id, flat_id, resident_id, occupancy_type, move_in_date, move_out_date
FROM occupancies
WHERE tenant_id = $1 AND flat_id = $2
ORDER BY move_in_date ASC`
rows, err := r.pool.Query(ctx, q, tenantID, flatID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []domain.Occupancy
for rows.Next() {
var o domain.Occupancy
if err := rows.Scan(&o.ID, &o.TenantID, &o.FlatID, &o.ResidentID, &o.OccupancyType, &o.MoveInDate, &o.MoveOutDate); err != nil {
return nil, err
}
result = append(result, o)
}
return result, rows.Err()
}23P01 is exclusion_violation — the SQLSTATE the overlapping-tenancy EXCLUDE constraint raises. Mapping it explicitly means a caller sees 'this flat is already rented for those dates,' not a raw Postgres error.
package domain
import (
"errors"
"time"
"github.com/google/uuid"
)
type OccupancyType string
const (
OccupancyOwnerOccupied OccupancyType = "owner_occupied"
OccupancyTenant OccupancyType = "tenant"
)
type Occupancy struct {
ID uuid.UUID
TenantID uuid.UUID
FlatID uuid.UUID
ResidentID uuid.UUID
OccupancyType OccupancyType
MoveInDate time.Time
MoveOutDate *time.Time
}
var (
ErrOverlappingTenancy = errors.New("occupancy: flat already has an overlapping tenant occupancy for these dates")
ErrResidentAlreadyOccupies = errors.New("occupancy: resident already has an open occupancy for this flat")
ErrOccupancyNotFoundOrAlreadyClosed = errors.New("occupancy: not found, or already moved out")
)Reproducing an Overlapping Tenancy
Applied exercise
Query occupancy history across a move-out and a new move-in
A dispute arises about who lived in flat A-101 during March 2023. The HOA needs a definitive answer from occupancy history, not memory.
- Seed a flat with three sequential occupancy rows: a tenant who moved out, then a different tenant who moved in later, ensuring the dates do not overlap.
- Write a repository method AsOf(ctx, tenantID, flatID, date) returning every occupancy row where move_in_date <= date AND (move_out_date IS NULL OR move_out_date > date).
- Write a test proving AsOf for the disputed March 2023 date returns exactly the correct historical resident, not the current one.
Deliverable
An AsOf repository method plus a passing test using three seeded occupancy rows.
Completion checks
- AsOf for a past date returns the historical occupant, not whoever occupies the flat today.
- AsOf for a date with no occupant returns an empty slice, not an error.
- No existing row was UPDATEd or deleted to set up the test — only new rows inserted.
Why does the EXCLUDE constraint apply only WHERE occupancy_type = 'tenant', instead of preventing any overlapping occupancy rows for a flat?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.