Stage 7 · Master
Phase 7 — Resident Service
Ownership
A legal record that outlives any single occupancy
Ownership Is Not 'Who Lives There'
An owner can rent their flat out and never set foot in it. A resident can occupy a flat they do not own. Collapsing ownership and occupancy into one relationship is the single most common modeling mistake in HOA software — this lesson builds ownership as a purely legal and financial record; the Relationships lesson later in this module builds physical occupancy as something separate.
Referencing a Flat You Do Not Own the Table For
flat-service owns the flats table in its own database. resident-service stores flat_id as a bare UUID with no foreign key — a cross-database FK is not possible, and even if it were, coupling two services' schemas that tightly defeats the point of splitting them. Instead, resident-service validates a flat_id by calling flat-service's API at the moment ownership is created.
package client
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"github.com/google/uuid"
)
var ErrFlatNotFound = errors.New("flat_client: flat does not exist for this tenant")
var ErrFlatServiceUnavailable = errors.New("flat_client: flat-service did not respond")
type FlatClient struct {
baseURL string
http *http.Client
}
func NewFlatClient(baseURL string) *FlatClient {
return &FlatClient{baseURL: baseURL, http: &http.Client{Timeout: 3 * time.Second}}
}
// Exists asks flat-service directly rather than trusting the caller's
// claim that a flat_id is valid. tenantID is forwarded as the same
// header the gateway would send, so flat-service applies its own
// tenant_id filter — a flat_id that belongs to a different tenant comes
// back as 404 from flat-service, which this client turns into the same
// ErrFlatNotFound a nonexistent flat would produce.
func (c *FlatClient) Exists(ctx context.Context, tenantID, flatID uuid.UUID) (bool, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
fmt.Sprintf("%s/flats/%s", c.baseURL, flatID), nil)
if err != nil {
return false, err
}
req.Header.Set("X-Tenant-Id", tenantID.String())
resp, err := c.http.Do(req)
if err != nil {
return false, ErrFlatServiceUnavailable
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
return true, nil
case http.StatusNotFound:
return false, nil
default:
var body map[string]any
_ = json.NewDecoder(resp.Body).Decode(&body)
return false, fmt.Errorf("flat_client: unexpected status %d: %v", resp.StatusCode, body)
}
}A 3-second client timeout is deliberate: ownership creation is a synchronous user-facing request, and it should fail fast with a clear 'try again' error rather than hang if flat-service is degraded.
Schema: Share Percentage and Deed Reference
CREATE TABLE flat_ownerships (
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),
share_percentage NUMERIC(5, 2) NOT NULL CHECK (share_percentage > 0 AND share_percentage <= 100),
deed_reference TEXT NOT NULL,
effective_from DATE NOT NULL,
effective_to DATE, -- NULL means "still the current owner"
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (effective_to IS NULL OR effective_to > effective_from)
);
CREATE INDEX flat_ownerships_tenant_flat_idx ON flat_ownerships (tenant_id, flat_id);
CREATE INDEX flat_ownerships_resident_id_idx ON flat_ownerships (resident_id);
-- A resident cannot hold two open (effective_to IS NULL) ownership
-- records for the same flat — that would be the same fact recorded twice.
CREATE UNIQUE INDEX flat_ownerships_open_resident_flat_key
ON flat_ownerships (flat_id, resident_id)
WHERE effective_to IS NULL;share_percentage is bounded 0–100 by CHECK, but the database cannot cheaply enforce 'all open shares for one flat sum to 100' across rows — that rule is enforced in the transaction below instead.
DROP TABLE IF EXISTS flat_ownerships;Enforcing 'Shares Sum to 100' With a Row Lock
Joint ownership means two or more residents each hold a percentage of one flat, and those percentages must sum to exactly 100 among currently open records. Two concurrent requests each adding a 60% share to the same flat must not both succeed. The fix is a transaction that locks the flat's existing open ownership rows before computing whether the new share fits.
package postgres
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/hoa-platform/backend/services/resident-service/internal/domain"
)
type OwnershipRepository struct {
pool *pgxpool.Pool
}
func NewOwnershipRepository(pool *pgxpool.Pool) *OwnershipRepository {
return &OwnershipRepository{pool: pool}
}
// CreateShare locks every open ownership row for this flat with
// SELECT ... FOR UPDATE before checking the running total. A concurrent
// second CreateShare call for the same flat blocks on that lock until
// the first transaction commits or rolls back, so the two calls are
// serialized instead of racing.
func (r *OwnershipRepository) CreateShare(ctx context.Context, o domain.FlatOwnership) (domain.FlatOwnership, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return domain.FlatOwnership{}, err
}
defer tx.Rollback(ctx)
const lockAndSum = `
SELECT COALESCE(SUM(share_percentage), 0)
FROM flat_ownerships
WHERE tenant_id = $1 AND flat_id = $2 AND effective_to IS NULL
FOR UPDATE`
var currentTotal float64
if err := tx.QueryRow(ctx, lockAndSum, o.TenantID, o.FlatID).Scan(¤tTotal); err != nil {
return domain.FlatOwnership{}, err
}
if currentTotal+o.SharePercentage > 100.0001 { // float tolerance for two-decimal percentages
return domain.FlatOwnership{}, domain.ErrOwnershipSharesExceed100
}
const insert = `
INSERT INTO flat_ownerships (tenant_id, flat_id, resident_id, share_percentage, deed_reference, effective_from)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, created_at`
if err := tx.QueryRow(ctx, insert, o.TenantID, o.FlatID, o.ResidentID, o.SharePercentage, o.DeedReference, o.EffectiveFrom).
Scan(&o.ID, &o.CreatedAt); err != nil {
return domain.FlatOwnership{}, err
}
if err := tx.Commit(ctx); err != nil {
return domain.FlatOwnership{}, err
}
return o, nil
}
// TransferOwnership closes the seller's open record and opens the
// buyer's in one transaction — a sale is one atomic fact, not two
// independent writes that could leave the flat with zero or two owners
// if the process crashed between them.
func (r *OwnershipRepository) TransferOwnership(
ctx context.Context, tenantID, flatID, sellerResidentID, buyerResidentID uuid.UUID,
deedReference string, effectiveDate string,
) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
const closeSeller = `
UPDATE flat_ownerships SET effective_to = $4
WHERE tenant_id = $1 AND flat_id = $2 AND resident_id = $3 AND effective_to IS NULL`
tag, err := tx.Exec(ctx, closeSeller, tenantID, flatID, sellerResidentID, effectiveDate)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return errors.New("ownership: seller has no open ownership record for this flat")
}
const openBuyer = `
INSERT INTO flat_ownerships (tenant_id, flat_id, resident_id, share_percentage, deed_reference, effective_from)
VALUES ($1, $2, $3, 100, $4, $5)`
if _, err := tx.Exec(ctx, openBuyer, tenantID, flatID, buyerResidentID, deedReference, effectiveDate); err != nil {
return err
}
return tx.Commit(ctx)
}SELECT ... FOR UPDATE takes row locks on the existing open ownership rows for this flat_id, so a second concurrent CreateShare call for the same flat waits for the first transaction to finish rather than reading a stale total.
package domain
import (
"errors"
"time"
"github.com/google/uuid"
)
type FlatOwnership struct {
ID uuid.UUID
TenantID uuid.UUID
FlatID uuid.UUID
ResidentID uuid.UUID
SharePercentage float64
DeedReference string
EffectiveFrom time.Time
EffectiveTo *time.Time
CreatedAt time.Time
}
var ErrOwnershipSharesExceed100 = errors.New("ownership: shares for this flat would exceed 100 percent")
var ErrFlatNotFoundForOwnership = errors.New("ownership: flat does not exist for this tenant")Service Layer: Validate the Flat Before Touching the Ledger
The repository from above assumes flat_id is already valid. That check belongs in the service layer, called once before CreateShare runs — this is also where FlatClient's distinction between 'does not exist' and 'flat-service is unreachable' turns into two different HTTP outcomes for the caller.
package service
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/hoa-platform/backend/services/resident-service/internal/client"
"github.com/hoa-platform/backend/services/resident-service/internal/domain"
)
type FlatChecker interface {
Exists(ctx context.Context, tenantID, flatID uuid.UUID) (bool, error)
}
type OwnershipRepository interface {
CreateShare(ctx context.Context, o domain.FlatOwnership) (domain.FlatOwnership, error)
}
type OwnershipService struct {
repo OwnershipRepository
flats FlatChecker
}
func NewOwnershipService(repo OwnershipRepository, flats FlatChecker) *OwnershipService {
return &OwnershipService{repo: repo, flats: flats}
}
func (s *OwnershipService) CreateShare(ctx context.Context, o domain.FlatOwnership) (domain.FlatOwnership, error) {
exists, err := s.flats.Exists(ctx, o.TenantID, o.FlatID)
if err != nil {
if errors.Is(err, client.ErrFlatServiceUnavailable) {
return domain.FlatOwnership{}, client.ErrFlatServiceUnavailable
}
return domain.FlatOwnership{}, err
}
if !exists {
return domain.FlatOwnership{}, domain.ErrFlatNotFoundForOwnership
}
return s.repo.CreateShare(ctx, o)
}The handler maps client.ErrFlatServiceUnavailable to 503 and domain.ErrFlatNotFoundForOwnership to 422 — the same distinction FlatClient.Exists already returns is preserved all the way to the HTTP response.
Forcing the Ownership Ceiling to Fail
Applied exercise
Reject ownership for a flat that belongs to a different tenant
flat-service's tenant filter means a cross-tenant flat_id already returns 404 from FlatClient.Exists — but nothing today calls Exists before CreateShare runs.
- Wire FlatClient.Exists into the ownership service layer before CreateShare is called.
- Handle ErrFlatServiceUnavailable distinctly from 'flat not found' — a downstream outage should return 503, not 422.
- Write a test using a fake FlatClient that returns each of the three outcomes (exists, not found, unavailable) and assert three different responses.
Deliverable
An updated OwnershipService.CreateShare that calls FlatClient.Exists first, plus the three-case test.
Completion checks
- A nonexistent or cross-tenant flat_id returns 422, not a foreign-key-style error.
- A flat-service outage returns 503, not 422 — callers must be able to tell 'invalid' apart from 'try again.'
- The existing shares-exceed-100 test still passes unchanged.
Why does resident-service call flat-service's HTTP API to check flat existence instead of a foreign key on flat_id?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.