All posts
goarchitecturebackendproduction

Why Status Is a Function, Not a Column

How I built a UAE document compliance engine for 178+ employees — and why storing computed state would have made it worse.

September 15, 20258 min read

The Manpower Management System tracks UAE document compliance for 178+ employees across 6 companies. Five mandatory document types — Passport, Residence Visa, Emirates ID, Work Permit, ILOE Insurance — each with their own fine schedules, grace periods, and alert windows. The question at the start of the project: where does document status live?

The obvious answer: a `status` column. Run a nightly cron, recompute, update. That's what most people build.

I didn't do that.

The problem with storing derived state

A stored status is a snapshot. It was correct as of the last cron run. A recruiter opening the dashboard at 9 AM on the exact day an Emirates ID expires — what do they see? Yesterday's "Valid." That's wrong, and wrong in a way that matters: UAE document violations carry real financial penalties.

More practically: stored state drifts. Grace periods mean the same document can move through five states over weeks without any data changing. Storing that requires either running the cron very frequently (expensive, complex) or accepting staleness (wrong).

The function

Status is always derived at read time. Five inputs, one output:

go
func ComputeStatus(
    expiryDate *time.Time,
    graceDays  int,
    alertDays  int,
    docNumber  string,
    now        time.Time,
) string {
    if expiryDate == nil {
        return StatusIncomplete
    }
    // severity-first: penalty/grace/expiring before incomplete
    // boundary rule: valid THROUGH expiry day (grace starts day after)
    switch {
    case daysUntilExpiry < 0 && (graceDays == 0 || -daysUntilExpiry > graceDays):
        return StatusPenaltyActive
    case daysUntilExpiry < 0 && graceDays > 0 && -daysUntilExpiry <= graceDays:
        return StatusInGrace
    case daysUntilExpiry >= 0 && daysUntilExpiry <= alertDays:
        return StatusExpiringSoon
    }
    if docNumber == "" {
        return StatusIncomplete
    }
    return StatusValid
}

No DB reads. No network calls. No side effects. You can call this in a tight loop over hundreds of employees and it's just arithmetic.

The boundary bug, and why it was easy to fix

Bug

Early on, documents were flipping to "expiring_soon" one day before they should. The dashboard metric cards showed "Valid" while the alerts list showed "Expiring Soon" for the same document — because they called ComputeStatus at slightly different times, and one compared with `<` while the other used `<=`.

The UAE rule is: a document is valid through its expiry day. The morning of the expiry day, it's still valid. The following morning, it's in grace (or expired, depending on the document type). My original comparison was treating expiry day itself as the first grace day.

Fix: strip time components from both `now` and `expiryDate` before comparison. The function now works only with dates, not datetimes. One change, one place, instantly consistent across the entire system.

If status had been a stored column, this bug would have required a migration, a backfill, a cron rerun, and careful verification that the update didn't race with reads. As a function, it was a one-line fix and a deploy.

The UAE fine schedule

DocumentGraceFine TypeRateCap
Passport0 daysDailyAED 0—
Residence Visa0 daysDailyAED 50/day—
Emirates ID30 daysDailyAED 20/dayAED 1,000
Work Permit50 daysOne-timeAED 500AED 500
ILOE Insurance0 daysOne-timeAED 400AED 400

The fine calculation follows the same pattern — pure function, injected `now`. Monthly fine types are rounded up to the nearest month. Caps are applied after the full calculation. All configurable per-company through an admin UI backed by a `compliance_rules` table.

Multi-tenant isolation

Six companies share one database. A user authenticated as a company_owner for Company A must never see Company B's employees, documents, or compliance data — even if they know the IDs.

The solution is an `InjectCompanyScope` middleware that runs after JWT auth. It reads `user_companies` (the join table mapping users to companies they can access), and attaches the allowed company IDs to the request context. Every handler that touches company-scoped data reads that context and filters by it.

go
// InjectCompanyScope middleware — runs after Auth, before handlers
func InjectCompanyScope(pool *pgxpool.Pool) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            userRole, _ := r.Context().Value(ctxkeys.UserRole).(string)

            // admins see everything — skip the lookup
            if userRole == "admin" || userRole == "super_admin" {
                next.ServeHTTP(w, r)
                return
            }

            userID, _ := r.Context().Value(ctxkeys.UserID).(string)
            rows, _ := pool.Query(r.Context(),
                `SELECT company_id::text FROM user_companies WHERE user_id = $1`, userID)
            // ... collect IDs, attach to context
            ctx := context.WithValue(r.Context(), ctxkeys.CompanyScope, ids)
            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
}

A company_owner who crafts a URL with another company's employee ID gets an empty response — the SQL always has `AND company_id = ANY($n)` baked in, and the allowed IDs come from the context, not the request.

What the pure-function approach bought me

  • ›Zero cron complexity — no scheduled jobs, no drift, no "what if the cron fails?"
  • ›Instant correctness — status at 11:59 PM and 12:00 AM are both right for their respective moments
  • ›Trivial testability — pass in a fixed `now`, assert the output. One function, no mocks.
  • ›Fast bug fixes — the boundary issue was a one-liner because the logic had one home
  • ›Fine calculation is auditable — given any point in time, you can explain exactly how the fine was computed

The system is in production for a UAE manpower firm managing 6 companies. Compliance dashboards run fast because status is computed in Go, not fetched from a stale column. Documents never show the wrong state.