Stage 7 · Master
Phase 6 — Flat Service
CRUD
Flats as inventory, not form fields
What Flat Inventory Actually Owns
A flat is not a form a resident fills in — it is inventory the HOA tracks whether or not anyone has moved in yet. flat-service owns the durable fact of a unit's existence: its unit number, floor, area, and occupancy status. It does not own who lives there (that is resident-service, module 07) and it does not own what they owe (that is maintenance-service, module 08). Keeping that boundary sharp now is what lets those two services reference a flat by ID instead of duplicating its columns.
If you model a flat as 'a thing a resident belongs to' you will keep adding resident columns to the flats table. Model it as 'a physical unit that exists independent of any resident' and the schema stays stable even as residents move in, move out, and move in again.
Schema and Migration
flat-service gets its own PostgreSQL database — organization-service, user-service, and flat-service each own a schema no other service queries directly. Start with the narrowest table that satisfies 'list, create, read, update, delete a unit for one tenant.' Building hierarchy is deliberately left out until the next lesson.
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE TABLE flats (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
unit_number TEXT NOT NULL,
floor_number INTEGER NOT NULL,
area_sqft NUMERIC(8, 2) NOT NULL,
status TEXT NOT NULL DEFAULT 'vacant',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- A tenant cannot have two units with the same number. This is the only
-- invariant this lesson enforces in the database; status and area rules
-- arrive in the Validation lesson once the shape has settled.
CREATE UNIQUE INDEX flats_tenant_unit_number_key
ON flats (tenant_id, unit_number);
CREATE INDEX flats_tenant_id_idx ON flats (tenant_id);pgcrypto gives you gen_random_uuid() so the database — not the application — is the single source of primary keys.
DROP TABLE IF EXISTS flats;Domain Model and Sentinel Errors
package domain
import (
"errors"
"time"
"github.com/google/uuid"
)
// FlatStatus mirrors the status column. It stays a plain string type in
// this lesson; the Validation lesson promotes it to a Postgres ENUM once
// the set of legal values is settled.
type FlatStatus string
const (
FlatStatusVacant FlatStatus = "vacant"
FlatStatusOccupied FlatStatus = "occupied"
FlatStatusUnderMaintenance FlatStatus = "under_maintenance"
FlatStatusBlocked FlatStatus = "blocked"
)
type Flat struct {
ID uuid.UUID
TenantID uuid.UUID
UnitNumber string
FloorNumber int
AreaSqft float64
Status FlatStatus
CreatedAt time.Time
UpdatedAt time.Time
}
var (
ErrFlatNotFound = errors.New("flat: not found")
ErrDuplicateUnitNumber = errors.New("flat: unit number already exists for this tenant")
ErrFlatBelongsToTenant = errors.New("flat: does not belong to the requesting tenant")
)Tenant-Scoped SQL, Not Another Layering Tour
Organization Service's repository-layer lesson owns the Go layering pattern; this lesson only changes the inventory invariant. Every flat lookup keeps tenant_id in the predicate because UUID secrecy is not an authorization boundary.
package postgres
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/hoa-platform/backend/services/flat-service/internal/domain"
)
type FlatRepository struct {
pool *pgxpool.Pool
}
func NewFlatRepository(pool *pgxpool.Pool) *FlatRepository {
return &FlatRepository{pool: pool}
}
func (r *FlatRepository) Create(ctx context.Context, f domain.Flat) (domain.Flat, error) {
const q = `
INSERT INTO flats (tenant_id, unit_number, floor_number, area_sqft, status)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, created_at, updated_at`
row := r.pool.QueryRow(ctx, q, f.TenantID, 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) && pgErr.Code == "23505" {
return domain.Flat{}, domain.ErrDuplicateUnitNumber
}
return domain.Flat{}, err
}
return f, nil
}
func (r *FlatRepository) Get(ctx context.Context, tenantID, id uuid.UUID) (domain.Flat, error) {
const q = `
SELECT id, tenant_id, unit_number, floor_number, area_sqft, status, created_at, updated_at
FROM flats
WHERE tenant_id = $1 AND id = $2`
var f domain.Flat
row := r.pool.QueryRow(ctx, q, tenantID, id)
err := row.Scan(&f.ID, &f.TenantID, &f.UnitNumber, &f.FloorNumber, &f.AreaSqft, &f.Status, &f.CreatedAt, &f.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return domain.Flat{}, domain.ErrFlatNotFound
}
if err != nil {
return domain.Flat{}, err
}
return f, nil
}
func (r *FlatRepository) List(ctx context.Context, tenantID uuid.UUID, limit, offset int) ([]domain.Flat, error) {
const q = `
SELECT id, tenant_id, unit_number, floor_number, area_sqft, status, created_at, updated_at
FROM flats
WHERE tenant_id = $1
ORDER BY floor_number, unit_number
LIMIT $2 OFFSET $3`
rows, err := r.pool.Query(ctx, q, tenantID, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var flats []domain.Flat
for rows.Next() {
var f domain.Flat
if err := rows.Scan(&f.ID, &f.TenantID, &f.UnitNumber, &f.FloorNumber, &f.AreaSqft, &f.Status, &f.CreatedAt, &f.UpdatedAt); err != nil {
return nil, err
}
flats = append(flats, f)
}
return flats, rows.Err()
}
func (r *FlatRepository) Update(ctx context.Context, tenantID, id uuid.UUID, status domain.FlatStatus) (domain.Flat, error) {
const q = `
UPDATE flats
SET status = $3, updated_at = now()
WHERE tenant_id = $1 AND id = $2
RETURNING id, tenant_id, unit_number, floor_number, area_sqft, status, created_at, updated_at`
var f domain.Flat
row := r.pool.QueryRow(ctx, q, tenantID, id, status)
err := row.Scan(&f.ID, &f.TenantID, &f.UnitNumber, &f.FloorNumber, &f.AreaSqft, &f.Status, &f.CreatedAt, &f.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return domain.Flat{}, domain.ErrFlatNotFound
}
return f, err
}
func (r *FlatRepository) Delete(ctx context.Context, tenantID, id uuid.UUID) error {
const q = `DELETE FROM flats WHERE tenant_id = $1 AND id = $2`
tag, err := r.pool.Exec(ctx, q, tenantID, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return domain.ErrFlatNotFound
}
return nil
}23505 is Postgres's unique_violation SQLSTATE. Mapping it to a domain error here means the handler layer never sees pgconn types.
Only the Flat-Specific Service and HTTP Deltas
Organization Service's service-layer and handler-layer lessons already explain the repository interface, DTO binding, and error middleware shape. Flat Service adds only one new behavior here: creation always starts inventory as vacant, regardless of the caller's payload.
type FlatRepository interface {
Create(ctx context.Context, f domain.Flat) (domain.Flat, error)
Get(ctx context.Context, tenantID, id uuid.UUID) (domain.Flat, error)
List(ctx context.Context, tenantID uuid.UUID, limit, offset int) ([]domain.Flat, error)
Update(ctx context.Context, tenantID, id uuid.UUID, status domain.FlatStatus) (domain.Flat, error)
Delete(ctx context.Context, tenantID, id uuid.UUID) error
}
func (s *FlatService) Create(ctx context.Context, f domain.Flat) (domain.Flat, error) {
f.Status = domain.FlatStatusVacant
return s.repo.Create(ctx, f)
}
func (s *FlatService) List(ctx context.Context, tenantID uuid.UUID, limit, offset int) ([]domain.Flat, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
return s.repo.List(ctx, tenantID, limit, offset)
}The interface is still consumed by the service package, as in Organization Service. The only flat-owned rule is that a newly created physical unit cannot begin life as occupied or blocked.
type createFlatRequest struct {
UnitNumber string `json:"unit_number" binding:"required"`
FloorNumber int `json:"floor_number" binding:"required,min=0"`
AreaSqft float64 `json:"area_sqft" binding:"required,gt=0"`
}
func (h *FlatHandler) Create(c *gin.Context) {
tenantID := tenantctx.MustFromContext(c.Request.Context())
var req createFlatRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
return
}
flat, err := h.svc.Create(c.Request.Context(), domain.Flat{
TenantID: tenantID,
UnitNumber: req.UnitNumber,
FloorNumber: req.FloorNumber,
AreaSqft: req.AreaSqft,
})
switch {
case errors.Is(err, domain.ErrDuplicateUnitNumber):
c.JSON(http.StatusConflict, gin.H{"error": "unit_number already exists for this tenant"})
case err != nil:
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not create flat"})
default:
c.JSON(http.StatusCreated, flat)
}
}The excerpt is deliberately narrow: tenant extraction, create DTO fields, and duplicate-unit conflict mapping are the flat-specific review surface.
func NewRouter(h *FlatHandler) *gin.Engine {
r := gin.New()
r.Use(gin.Recovery())
flats := r.Group("/flats")
flats.POST("", h.Create)
flats.GET("", h.List)
flats.GET("/:id", h.Get)
flats.PATCH("/:id", h.UpdateStatus)
flats.DELETE("/:id", h.Delete)
return r
}Composition Root Deltas
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
pool, err := pgxpool.New(ctx, os.Getenv("FLAT_DATABASE_URL"))
if err != nil {
log.Error().Err(err).Msg("could not connect to flat database")
os.Exit(1)
}
defer pool.Close()
repo := postgres.NewFlatRepository(pool)
svc := service.NewFlatService(repo)
router := httphandler.NewRouter(httphandler.NewFlatHandler(svc))
srv := &http.Server{Addr: ":8081", Handler: router}
go func() {
log.Info().Str("addr", srv.Addr).Msg("flat-service listening")
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Error().Err(err).Msg("server error")
os.Exit(1)
}
}()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)
}Phase 1 owns typed config and graceful shutdown mechanics. The values worth checking here are FLAT_DATABASE_URL and the private service port 8081.
Tenant Isolation Smoke Test
Bring up Postgres, run the migration, start the service, and prove tenant isolation with two different tenant IDs before writing a single test.
| Failure | Expected signal | Why it matters |
|---|---|---|
| Same tenant creates A-101 twice | 409 Conflict | inventory names are scoped per HOA |
| Different tenant lists after tenant A creates | empty result | a valid ID or unit name never crosses the tenant boundary |
Applied exercise
Add a partial status update without losing concurrent edits
Two building managers open the same flat's admin page at the same time. One marks it under_maintenance; seconds later the other, looking at stale data, marks it occupied. The second write should not silently clobber the first.
- Add an
updated_atguard to the PATCH endpoint: the client sends theupdated_atit last read, and the UPDATE only applies if the row still has that value. - Return 409 Conflict with the current row when the guard fails, so the client can refresh and retry.
- Write a manual test that issues two concurrent PATCH requests with the same stale
updated_atand asserts exactly one succeeds.
Deliverable
An updated FlatRepository.Update signature that takes an expected updated_at, plus a passing concurrency test.
Completion checks
- A stale PATCH returns 409, not 200.
- The row's status reflects only the write that supplied the current updated_at.
- No new column was added — the guard reuses the existing updated_at.
Why does every repository method take tenantID as an explicit parameter instead of reading it from a package-level variable?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.