Skip to content

Handlers & Services ​

HTTP handlers and business logic patterns for the Go backend.

Overview ​

The backend uses a three-layer architecture:

Handler (HTTP) -> Service (Business Logic) -> Repository (Data Access)

Handler Pattern ​

Handlers are responsible for:

  • Parsing HTTP requests
  • Validating input
  • Calling services
  • Returning HTTP responses

Handler Struct ​

go
type Handler struct {
    service   PersonService
    validator *validator.Validator
}

func NewHandler(svc PersonService, v *validator.Validator) *Handler {
    return &Handler{
        service:   svc,
        validator: v,
    }
}

List Handler ​

go
func (h *Handler) ListPersons(w http.ResponseWriter, r *http.Request) {
    // Parse query parameters using shared pagination helper
    params := &PersonQueryParams{
        PaginationParams: *db.ParsePaginationParams(r),
    }
    params.Search = r.URL.Query().Get("search")
    params.OrderBy = r.URL.Query().Get("ordering")

    // Call service
    result, err := h.service.List(r.Context(), params)
    if err != nil {
        response.HandleError(w, err)
        return
    }

    response.OK(w, result)
}

Get Handler ​

go
func (h *Handler) GetPerson(w http.ResponseWriter, r *http.Request) {
    // Extract path parameter (Go 1.22+ method pattern)
    id := r.PathValue("personID")

    // Call service
    person, err := h.service.GetByID(r.Context(), id)
    if err != nil {
        response.HandleError(w, err)
        return
    }

    response.OK(w, person)
}

Create Handler ​

go
func (h *Handler) CreatePerson(w http.ResponseWriter, r *http.Request) {
    // Parse request body
    var req CreatePersonRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        response.BadRequest(w, "Invalid JSON format")
        return
    }

    // Validate request
    if err := h.validator.Struct(&req); err != nil {
        response.ValidationError(w, formatValidationErrors(err))
        return
    }

    // Call service
    person, err := h.service.Create(r.Context(), &req)
    if err != nil {
        response.HandleError(w, err)
        return
    }

    response.Created(w, person)
}

Update Handler ​

go
func (h *Handler) UpdatePerson(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("personID")

    var req UpdatePersonRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        response.BadRequest(w, "Invalid JSON format")
        return
    }

    if err := h.validator.Struct(&req); err != nil {
        response.ValidationError(w, formatValidationErrors(err))
        return
    }

    person, err := h.service.Update(r.Context(), id, &req)
    if err != nil {
        response.HandleError(w, err)
        return
    }

    response.OK(w, person)
}

Delete Handler ​

go
func (h *Handler) DeletePerson(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("personID")

    if err := h.service.Delete(r.Context(), id); err != nil {
        response.HandleError(w, err)
        return
    }

    response.NoContent(w)
}

Custom Action Handler ​

go
func (h *Handler) SearchPersons(w http.ResponseWriter, r *http.Request) {
    query := r.URL.Query().Get("q")
    if len(query) < 2 {
        response.BadRequest(w, "Search query must be at least 2 characters")
        return
    }

    limit := parseIntParam(r, "limit", 10)

    results, err := h.service.Search(r.Context(), query, limit)
    if err != nil {
        response.HandleError(w, err)
        return
    }

    response.OK(w, results)
}

Service Pattern ​

Services handle business logic and orchestration.

Service Interface ​

go
type PersonService interface {
    List(ctx context.Context, params *PersonQueryParams) (*db.PaginatedResult[PersonListResponse], error)
    GetByID(ctx context.Context, id string) (*PersonDetailResponse, error)
    Create(ctx context.Context, req *CreatePersonRequest) (*PersonDetailResponse, error)
    Update(ctx context.Context, id string, req *UpdatePersonRequest) (*PersonDetailResponse, error)
    Delete(ctx context.Context, id string) error
    Search(ctx context.Context, query string, limit int) ([]PersonListResponse, error)
}

Service Implementation ​

go
type personService struct {
    repo        PersonRepository
    nameRepo    PersonNameRepository
    addressRepo PersonAddressRepository
    logger      *slog.Logger
}

func NewPersonService(
    repo PersonRepository,
    nameRepo PersonNameRepository,
    addressRepo PersonAddressRepository,
    logger *slog.Logger,
) PersonService {
    return &personService{
        repo:        repo,
        nameRepo:    nameRepo,
        addressRepo: addressRepo,
        logger:      logger,
    }
}

List Implementation ​

go
func (s *personService) List(ctx context.Context, params *PersonQueryParams) (*db.PaginatedResult[PersonListResponse], error) {
    persons, total, err := s.repo.FindAll(ctx, params)
    if err != nil {
        s.logger.Error("failed to list persons", "error", err)
        return nil, errors.NewInternalError("Failed to retrieve persons", err)
    }

    results := make([]PersonListResponse, len(persons))
    for i, p := range persons {
        results[i] = toPersonListResponse(p)
    }

    return &db.PaginatedResult[PersonListResponse]{
        Count:   total,
        Results: results,
    }, nil
}

Create Implementation ​

go
func (s *personService) Create(ctx context.Context, req *CreatePersonRequest) (*PersonDetailResponse, error) {
    // Business validation
    existing, _ := s.repo.FindByEmail(ctx, req.PrimaryEmail)
    if existing != nil {
        return nil, errors.NewValidationError("primary_email", "Email already exists")
    }

    // Convert DTO to model
    person := req.ToModel()

    // Create in repository
    if err := s.repo.Create(ctx, person); err != nil {
        s.logger.Error("failed to create person", "error", err)
        return nil, errors.NewInternalError("Failed to create person", err)
    }

    // Return full response
    return s.GetByID(ctx, person.PersonID)
}

Update with Partial Updates ​

An update must write only the columns the request changed. Do not load the row, assign the request onto it and write every column back: that silently undoes any change another user made in between, to any column. It is how a demographics save once restored a FERPA opt-out that had just been cleared, and how /me rewrote the password hash on every call.

Build the write with db.Patch (internal/shared/db/patch.go) and the request-side helpers in internal/shared/patch:

go
// repository.go — the table, key and actor; the service decides the columns.
func (r *paymentPlanRepository) Patch(ctx context.Context, id int32, build func(p *db.Patch)) error {
    p := db.NewPatch("studentfinance.payment_plans", "plan_id", id).
        Actor("updated_by", audit.GetPersonID(ctx))
    build(p)
    return p.Apply(ctx, r.db, "Payment plan", nil)
}

// service.go
func (s *billingService) UpdatePaymentPlan(ctx context.Context, id int32, dto *PaymentPlanUpdateDTO) (*PaymentPlanDTO, error) {
    err := s.planRepo.Patch(ctx, id, func(p *db.Patch) {
        e := dto.Expected
        if dto.Name != nil { // NOT NULL column: a null is ignored
            patch.Field(p, dto, e, "name", func(d *PaymentPlanUpdateDTO) *string { return d.Name })
        }
        // Nullable column: a key sent as null clears it.
        patch.Field(p, dto, e, "description", func(d *PaymentPlanUpdateDTO) *string { return d.Description })
    })
    if err != nil {
        return nil, err // 404, 409 edit_conflict, or a mapped DB error
    }
    // ...reload the row and return its DTO
}

What each piece does:

PieceEffect
patch.Field(p, dto, expected, key, get)If the body contained key, Set its column; if _expected also contained it, Expect that value. FieldAs when the column name differs from the JSON key; Value when the value needs converting first
Set(col, val)Writes col = val; nil writes NULL
Expect(col, val)The UPDATE matches only while col IS NOT DISTINCT FROM val. An empty expectation (nil, "") matches NULL or '', because forms show both as a blank box and imported rows store either
Actor(col, id)Stamps updated_by whenever the patch writes anything. updated_at = NOW() is written too, unless WithoutTimestamp()
Apply(ctx, q, resource, fieldOf)Runs it. Row gone → 404. An expected column moved → nothing written, 409 edit_conflict with conflicts: [{field, current}]. fieldOf maps a column back to its JSON key when they differ
ApplyTx(ctx, db, resource, fieldOf, before)The same, with a preliminary write in one transaction, committed only if the patch wrote — clearing a sibling's is_primary before this row takes it, for example. Run as two statements, a refused patch would leave the person with no primary at all
MapError(f)Translates a database error (a trigger's message, say) instead of returning a 500

Table and column names are interpolated into the SQL, so they must come from code, never from a request; every name is checked against SafeIdentifier and a bad one panics.

A patch with no Expect is unconditional, which keeps scripts and older clients working. Edits to different fields of one record both land; only a change to a field the request is also changing is a conflict.

On the frontend, src/utils/patch.ts buildPatch builds a body holding only the fields that differ from what the dialog loaded, plus their originals under _expected; editConflicts() reads a 409 and composables/useEditConflict.ts explains it to the user.

Converted so far: person, person names, addresses, phones and emails (primary cleared in the same transaction), student, enrollment, auth users, grade definitions, billing methods, method configs and payment plans, and graduation records. Other domains still use read-modify-write; convert an update when you touch it.

Route Registration ​

go
func RegisterRoutes(reg *authz.Registrar, prefix string, h *Handler) {
    p := prefix + "/people"

    // One binder per permission node. Reads sit on VIEW, writes on EDIT, so
    // seeing a person and changing one are separately grantable.
    view := reg.Node("PEOPLE.VIEW")
    edit := reg.Node("PEOPLE.EDIT")

    // Collection endpoints
    view.Handle("GET "+p+"/persons", h.ListPersons)
    edit.Handle("POST "+p+"/persons", h.CreatePerson)

    // Item endpoints
    view.Handle("GET "+p+"/persons/{personID}", h.GetPerson)
    edit.Handle("PATCH "+p+"/persons/{personID}", h.UpdatePerson)
    edit.Handle("DELETE "+p+"/persons/{personID}", h.DeletePerson)

    // Custom actions
    view.Handle("GET "+p+"/persons/search", h.SearchPersons)

    // Subresources
    view.Handle("GET "+p+"/persons/{personID}/names", h.ListPersonNames)
    edit.Handle("POST "+p+"/persons/{personID}/names", h.CreatePersonName)
}

Authentication Context ​

Access the authenticated user's session data from the request context. The UserClaims struct is populated by the session middleware, which reads the sis_session HttpOnly cookie and validates it against Valkey.

go
func (h *Handler) CreatePerson(w http.ResponseWriter, r *http.Request) {
    // Get authenticated user from session
    claims := middleware.GetClaims(r.Context())
    if claims == nil {
        response.Unauthorized(w, "Not authenticated")
        return
    }

    // Check permissions
    if !claims.IsSuperuser && !claims.HasPermission("people.create") {
        response.Forbidden(w, "Insufficient permissions")
        return
    }

    // ... rest of handler
}

UserClaims Struct ​

The UserClaims struct is set by the middleware.Auth() session middleware:

go
type UserClaims struct {
    UserID      int32  `json:"user_id"`
    Email       string `json:"email"`
    PersonID    string `json:"person_id,omitempty"`
    IsSuperuser bool   `json:"is_superuser"`
    TokenType   string `json:"token_type"` // always "session"

    // Impersonation fields
    IsImpersonating   bool   `json:"is_impersonating,omitempty"`
    ImpersonatingUser int32  `json:"impersonating_user,omitempty"`
    ActualUserID      int32  `json:"actual_user_id,omitempty"`
    ActualPersonID    string `json:"actual_person_id,omitempty"`
}

Context Helper Functions ​

go
// Get full claims
claims := middleware.GetClaims(ctx)

// Get user ID (returns impersonated user ID if impersonating)
userID := middleware.GetUserID(ctx)

// Get actual user ID (ignoring impersonation)
actualID := middleware.GetActualUserID(ctx)

// Get person UUID
personID := middleware.GetPersonID(ctx)

// Check authentication status
if middleware.IsAuthenticated(ctx) { ... }

// Check superuser status
if middleware.IsSuperuser(ctx) { ... }

// Check impersonation
if middleware.IsImpersonating(ctx) { ... }

Response Helpers ​

go
// Success responses
response.OK(w, data)           // 200 OK
response.Created(w, data)      // 201 Created
response.NoContent(w)          // 204 No Content

// Error responses
response.BadRequest(w, "message")       // 400
response.Unauthorized(w, "message")     // 401
response.Forbidden(w, "message")        // 403
response.NotFound(w, "message")         // 404
response.ValidationError(w, errors)     // 400 with field details

// Automatic error handling
response.HandleError(w, err)  // Inspects error type and returns appropriate status

Helper Functions ​

go
func parseIntParam(r *http.Request, key string, defaultVal int) int {
    val := r.URL.Query().Get(key)
    if val == "" {
        return defaultVal
    }
    i, err := strconv.Atoi(val)
    if err != nil {
        return defaultVal
    }
    return i
}

func formatValidationErrors(err error) map[string]string {
    errors := make(map[string]string)
    for _, e := range err.(validator.ValidationErrors) {
        field := strings.ToLower(e.Field())
        errors[field] = getValidationMessage(e)
    }
    return errors
}

Student Information System Documentation