Stage 7 · Master
Phase 3 — User Service
User Management
Separate the global identity of a person from their membership in any one HOA organization, then stand up the user-service module that will own that identity for the rest of the course.
A Person Outlives Any Single Organization
The Organization Service you finished in Phase 2 gave the platform a tenant: an HOA community with its own name, plan, and status. It is tempting to bolt a users table directly onto that service with an organization_id column and call identity solved. That shortcut breaks the first time a property manager runs the board for two different HOA communities, or a resident sells a flat in one community and buys into another. The person is the same; only their relationship to an organization changed.
User Service exists to own exactly one thing: the durable, tenant-agnostic profile of a person who can act inside the platform. It does not own passwords, tokens, or sessions — that authority belongs to the Authentication Service you build in the next module. It does not treat organization membership as a permanent attribute of identity either; membership has its own lifecycle, independent of a person's name or email address.
A user changes when a person updates their name, phone, or avatar. A membership changes when someone joins a board, moves out, or gets suspended from one community. Conflating the two forces every profile edit to carry tenant context it doesn't need, and every membership change to touch a row a dozen other services might be reading.
What User Service Will and Will Not Own
| Concern | Owner | Why |
|---|---|---|
| Name, phone, avatar, locale | User Service | Belongs to the person, not to any one organization. |
| Password hash, JWT issuance, refresh tokens | Auth Service (Phase 4) | Credential verification is a distinct authority with its own attack surface. |
| Which organizations a user belongs to, and their role in each | User Service (memberships) | The platform queries this constantly for directories and authorization. |
| Which unit or flat a resident occupies | Resident Service (Phase 7) | Occupancy is HOA-specific domain state, not identity. |
This boundary is why this module's lesson list reads user management, relationships, CRUD APIs, search, pagination — and never mentions login, hashing, or tokens. Every lesson here builds the directory of people and their organizational relationships. Every lesson in the next module builds the gate that decides whether a request really comes from the person it claims to be.
Bootstrapping the Module
user-service joins the go.work workspace exactly the way organization-service did in Phase 2: its own Go module, its own PostgreSQL database (hoa_user), its own migrations, and the shared platform packages for logging, request IDs, recovery, and JSON error envelopes that Phase 1 already gave every service. Nothing about bootstrapping a service is new — only the domain is, so this lesson moves quickly through scaffolding and spends its budget on the schema.
package respond
import "github.com/gin-gonic/gin"
func JSON(c *gin.Context, status int, body any) {
c.JSON(status, body)
}
func Error(c *gin.Context, status int, code, message string) {
JSON(c, status, gin.H{
"error": gin.H{"code": code, "message": message},
})
}The helper owns only wire formatting and takes *gin.Context directly rather than the lower-level http.ResponseWriter — every handler in this course already has a Context to hand it, so there is no reason to drop to a narrower interface. Request IDs and structured error classification are added later when the platform has those concepts; this first version is intentionally small but complete.
+ DatabaseURL string `env:"USER_SERVICE_DATABASE_URL" env-required:"true"`
+ Environment string `env:"APP_ENV" env-default:"development"`
}
func Load() (Config, error) {
var cfg Config
+ if err := sharedconfig.Load(&cfg); err != nil {
+ return Config{}, err
+ }
+ return cfg, nil
+}Phase 1 owns cleanenv loading through pkg/config. User Service contributes only its field tags and required database URL; it does not copy environment parsing.
Designing the users Table
user-service's primary key is a UUIDv7, not a sequential integer or a random UUIDv4. UUIDv7 embeds a millisecond timestamp in its high bits, so IDs generated close together sort close together. That property is inert today, but the Pagination lesson later in this module depends on it directly: keyset pagination needs a monotonically increasing key to seek from, and UUIDv7 gives us that without exposing a predictable sequential integer that would let one organization estimate how many users another organization has.
CREATE EXTENSION IF NOT EXISTS citext;
CREATE TABLE users (
id uuid PRIMARY KEY,
email citext NOT NULL UNIQUE,
first_name text NOT NULL,
last_name text NOT NULL,
phone text,
avatar_url text,
status text NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'suspended', 'deleted')),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
citext gives us case-insensitive uniqueness on email for free — 'Ana@hoa.dev' and 'ana@hoa.dev' collide, which is what a login-adjacent identifier should do even though login itself lives in Auth Service.
DROP TABLE users;
DROP EXTENSION IF EXISTS citext;
The .down.sql file undoes exactly what its paired .up.sql created, in dependency order: the table before the extension it depends on.
A Model That Refuses to Be Invalid
package user
import (
"errors"
"time"
"github.com/google/uuid"
)
var (
ErrEmailRequired = errors.New("email is required")
ErrNameRequired = errors.New("first and last name are required")
)
type Status string
const (
StatusActive Status = "active"
StatusSuspended Status = "suspended"
StatusDeleted Status = "deleted"
)
type User struct {
ID uuid.UUID
Email string
FirstName string
LastName string
Phone string
AvatarURL string
Status Status
CreatedAt time.Time
UpdatedAt time.Time
}
func New(email, firstName, lastName string) (User, error) {
if email == "" {
return User{}, ErrEmailRequired
}
if firstName == "" || lastName == "" {
return User{}, ErrNameRequired
}
id, err := uuid.NewV7()
if err != nil {
return User{}, err
}
now := time.Now().UTC()
return User{
ID: id,
Email: email,
FirstName: firstName,
LastName: lastName,
Status: StatusActive,
CreatedAt: now,
UpdatedAt: now,
}, nil
}uuid.NewV7() is the one line in this whole module that quietly enables keyset pagination three lessons from now — new IDs are time-ordered by construction, no extra column required.
Wiring the Entry Point
package main
import (
"context"
"net/http"
"os"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/hoa-platform/backend/pkg/respond"
"github.com/hoa-platform/backend/services/user-service/internal/config"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
// requestID assigns an identifier scoped to this one process's handling of
// a single request — the smallest useful building block for correlating
// log lines within user-service itself, well before any cross-service
// concern exists to justify more machinery than this.
func requestID() gin.HandlerFunc {
return func(c *gin.Context) {
if id, err := uuid.NewV7(); err == nil {
c.Set("request_id", id.String())
c.Writer.Header().Set("X-Request-Id", id.String())
}
c.Next()
}
}
func main() {
cfg, err := config.Load()
if err != nil {
log.Error().Err(err).Msg("config load failed")
os.Exit(1)
}
logger := zerolog.New(os.Stdout).With().Timestamp().
Str("service", "user-service").
Str("env", cfg.Environment).
Logger()
pool, err := pgxpool.New(context.Background(), cfg.DatabaseURL)
if err != nil {
logger.Error().Err(err).Msg("db connect failed")
os.Exit(1)
}
defer pool.Close()
engine := gin.New()
engine.Use(gin.Recovery())
engine.Use(requestID())
engine.Use(gin.Logger())
engine.GET("/healthz", func(c *gin.Context) {
respond.JSON(c, http.StatusOK, gin.H{"status": "ok"})
})
engine.GET("/readyz", func(c *gin.Context) {
if err := pool.Ping(c.Request.Context()); err != nil {
respond.Error(c, http.StatusServiceUnavailable, "db_unreachable", "database ping failed")
return
}
respond.JSON(c, http.StatusOK, gin.H{"status": "ready"})
})
logger.Info().Str("port", cfg.Port).Msg("user-service listening")
if err := engine.Run(":" + cfg.Port); err != nil {
logger.Error().Err(err).Msg("server stopped")
os.Exit(1)
}
}The middleware order — gin.Recovery(), then request ID, then access log — is deliberate and reappears verbatim in Auth Service and the Gateway. Phase 5 explains why that exact order matters for a service under attack; here it's enough to reuse it correctly. gin.Recovery() sits outermost so a panic anywhere in requestID, gin.Logger(), or a handler below it degrades to one 500 response instead of crashing the process.
Until the API Gateway module wires real JWT verification, handlers in this module read X-User-Id, X-Org-Id, and X-User-Roles as if infrastructure already validated them. That trust boundary is temporary and explicit — Phase 5's JWT Validation lesson replaces it with claims the Gateway itself verified, and strips any client-supplied copies of those headers before they reach this service.
Verifying the Skeleton Runs
Applied exercise
Draw the ownership line
A teammate proposes adding organization_id and role columns directly to the users table to 'save a join'.
- Write two concrete scenarios where a single organization_id column produces wrong or ambiguous behavior.
- Name one query pattern that would improve if organization_id lived on users instead of a separate membership table.
- Decide whether that improvement is worth the modeling cost, and write one sentence defending your answer.
Deliverable
A short written ruling: keep memberships separate or fold them into users, with the two scenarios and your reasoning.
Completion checks
- At least one scenario involves a person in more than one organization, or changing organizations.
- The ruling references the User Service vs. Auth Service boundary from this lesson.
Why does user-service generate UUIDv7 primary keys instead of UUIDv4?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.