Stage 7 · Master
Phase 3 — User Service
CRUD APIs
Wire handlers and services on top of the user and membership repositories, producing one REST surface backed by two aggregates that change for different reasons.
One Process, Two Resource Families
user-service exposes two resource families from the same binary: /v1/users for the profile aggregate, and /v1/organizations/{orgID}/memberships for the relationship aggregate. Nesting memberships under an organization path — rather than a flat /v1/memberships — makes the tenant scope visible in every URL a client writes, which matters the moment search and pagination need that same scope for tenant isolation.
Profile Endpoints Reuse the Phase 2 HTTP Pattern
Organization Service's DTO, service-layer, and handler lessons already established the wire-model boundary, interface-where-consumed repository seam, and JSON error envelope. User Service reuses that stack; the only profile-specific wrinkle worth naming here is PATCH semantics for optional identity fields.
LastName *string `json:"last_name,omitempty"`
Phone *string `json:"phone,omitempty"`
AvatarURL *string `json:"avatar_url,omitempty"`
}Pointer fields let the profile endpoint tell 'caller omitted phone' from 'caller intentionally cleared phone', which matters for a durable identity record read by many later services.
func (s *Service) Update(ctx context.Context, id string, req UpdateRequest) (User, error) {
u, err := s.repo.ByID(ctx, id)
if err != nil {
return User{}, err
}
if req.FirstName != nil {
u.FirstName = *req.FirstName
}
if req.LastName != nil {
u.LastName = *req.LastName
}
if req.Phone != nil {
u.Phone = *req.Phone
}
if req.AvatarURL != nil {
u.AvatarURL = *req.AvatarURL
}
return u, s.repo.Update(ctx, u)
}The method is intentionally narrow: identity fields can change without touching membership rows, roles, or tenant-scoped authorization state.
func (h *Handler) update(c *gin.Context) {
var req UpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
respond.Error(c, http.StatusBadRequest, "invalid_body", "request body is not valid JSON")
return
}
u, err := h.svc.Update(c.Request.Context(), c.Param("id"), req)
if err != nil {
respond.Error(c, http.StatusNotFound, "user_not_found", "no profile matches that id")
return
}
respond.JSON(c, http.StatusOK, toResponse(u))
}The full create/get/update handler shape belongs to Organization Service's handler lesson; this excerpt keeps attention on partial profile edits.
Nesting Membership Under the Organization
package membership
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/hoa-platform/backend/pkg/respond"
)
type Handler struct {
repo *Repository
}
func NewHandler(repo *Repository) *Handler {
return &Handler{repo: repo}
}
func (h *Handler) Routes(r *gin.Engine) {
r.POST("/v1/organizations/:orgID/memberships", h.invite)
}
type inviteRequest struct {
UserID string `json:"user_id"`
Role string `json:"role"`
}
func (h *Handler) invite(c *gin.Context) {
orgID, err := uuid.Parse(c.Param("orgID"))
if err != nil {
respond.Error(c, http.StatusBadRequest, "invalid_org_id", "organization id must be a uuid")
return
}
var req inviteRequest
if err := c.ShouldBindJSON(&req); err != nil {
respond.Error(c, http.StatusBadRequest, "invalid_body", "request body is not valid JSON")
return
}
userID, err := uuid.Parse(req.UserID)
if err != nil {
respond.Error(c, http.StatusBadRequest, "invalid_user_id", "user_id must be a uuid")
return
}
id, err := uuid.NewV7()
if err != nil {
respond.Error(c, http.StatusInternalServerError, "id_generation_failed", "could not create membership id")
return
}
m := Membership{ID: id, UserID: userID, OrganizationID: orgID, Role: Role(req.Role), Status: StatusInvited}
if err := h.repo.Create(c.Request.Context(), m); err != nil {
if err == ErrAlreadyMember {
respond.Error(c, http.StatusConflict, "already_member", "this user already has a membership in this organization")
return
}
respond.Error(c, http.StatusInternalServerError, "membership_create_failed", "could not create membership")
return
}
respond.JSON(c, http.StatusCreated, gin.H{"id": m.ID.String(), "status": string(m.Status)})
}The orgID path parameter — read via Gin's c.Param, populated from the :orgID segment registered above — is parsed and validated before the body — a malformed tenant reference should never reach a query, even a read-only one.
Nothing here proves the caller may invite members into orgID. For now, user-service accepts trusted internal headers from platform infrastructure so this module can focus on tenant-shaped data; the API Gateway phase replaces that assumption with verified JWT claims and strips client-supplied X-User-Id, X-Org-Id, and X-User-Roles before forwarding.
Exercising Both Resource Families
| Route family | Scope source | Failure this shape prevents |
|---|---|---|
| /v1/users/{id} | Profile id | A profile edit accidentally changing role or organization state. |
| /v1/organizations/{orgID}/memberships | Path tenant | An invite body silently choosing a different organization than the URL displayed. |
Applied exercise
Design the response for a duplicate invite
Product wants the second invite in the curl sequence above to feel helpful, not just correct.
- Write the JSON error body you would return for the 409 response, including a field a client UI could use to show 'already invited' instead of a generic failure.
- Decide whether the response should include the existing membership's current status (invited/active/suspended) and justify your choice.
- Identify one piece of information this response must never leak to the caller.
Deliverable
A JSON error body plus two sentences of justification.
Completion checks
- The error body includes a stable machine-readable code, not just a human message.
- The justification names a concrete leak this design avoids.
Why does UpdateRequest use *string fields instead of plain string fields?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.