Skip to content

Backend Architecture ​

The SIS backend is built with Go using a domain-driven design approach.

Technology Stack ​

ComponentTechnology
LanguageGo 1.24+
HTTP RouterStandard library net/http (Go 1.22+ routing)
DatabasePostgreSQL (via lib/pq driver)
Session StoreValkey/Redis (required for session management)
AuthenticationSession-based (Valkey/Redis)
Validationgo-playground/validator/v10
LoggingStandard library log/slog

Directory Structure ​

sis_backendgo/
├── cmd/
│   └── api/
│       └── main.go              # Application entry point
├── internal/
│   ├── config/
│   │   └── config.go            # Environment configuration
│   ├── domain/                  # Business domains (20 modules)
│   │   ├── academic/
│   │   ├── audit/
│   │   ├── authentication/
│   │   ├── authorization/
│   │   ├── billing/
│   │   ├── communications/
│   │   ├── core/
│   │   ├── financialaid/
│   │   ├── formbuilder/
│   │   ├── grading/
│   │   ├── organization/
│   │   ├── people/
│   │   ├── proxy/
│   │   ├── registration/
│   │   ├── reporting/
│   │   ├── roles/
│   │   ├── sso/
│   │   ├── testscores/
│   │   ├── workflow/
│   │   └── workflowengine/
│   ├── middleware/              # HTTP middleware
│   │   ├── auth.go              # Session authentication
│   │   ├── bodylimit.go         # Request body size limits
│   │   ├── chain.go             # Middleware chaining
│   │   ├── cors.go              # CORS handling
│   │   ├── csrf.go              # CSRF token validation
│   │   ├── impersonation_audit.go # Impersonation audit logging
│   │   ├── locale.go            # Locale/language detection
│   │   ├── logging.go           # Request logging
│   │   ├── ratelimit.go         # Rate limiting
│   │   ├── security_headers.go  # Security response headers
│   │   └── stdlib.go            # Standard middleware
│   ├── router/
│   │   └── router.go            # Route registration
│   └── shared/                  # Shared utilities
│       ├── auth/                # Session service, cookies
│       ├── db/                  # Database utilities
│       ├── errors/              # Error types & constructors
│       ├── response/            # HTTP response helpers
│       └── validator/           # Input validation
├── migrations/                  # SQL migration scripts
├── go.mod                       # Go module definition
├── go.sum                       # Dependency checksums
└── .env                         # Environment variables

Domain Module Pattern ​

Each domain follows a consistent structure with 6-8 files:

domain/{name}/
├── model.go       # Database entity structs with db tags
├── dto.go         # Request/Response DTOs with validation
├── repository.go  # Data access layer (SQL queries)
├── service.go     # Business logic layer
├── handler.go     # HTTP request handlers
└── routes.go      # Route registration function

Complex Domains ​

Some domains have additional files for complex functionality:

domain/billing/
├── model.go
├── dto.go
├── repository.go
├── service.go
├── service_subresources.go    # Subresource business logic
├── handler.go
├── handler_subresources.go    # Subresource handlers
└── routes.go

Layered Architecture ​

┌─────────────────────────────────────────┐
│           HTTP Layer (handlers)          │
│   - Parse requests                       │
│   - Validate input                       │
│   - Call services                        │
│   - Return responses                     │
└───────────────────┬─────────────────────┘
                    │
                    ▼
┌─────────────────────────────────────────┐
│         Service Layer (services)         │
│   - Business logic                       │
│   - Authorization checks                 │
│   - Orchestration                        │
│   - Transaction management               │
└───────────────────┬─────────────────────┘
                    │
                    ▼
┌─────────────────────────────────────────┐
│         Data Layer (repositories)        │
│   - SQL queries                          │
│   - Data mapping                         │
│   - Connection management                │
└─────────────────────────────────────────┘

Key Patterns ​

Model Definition ​

go
type Person struct {
    PersonID            string     `db:"person_id"`
    PersonIDNumber      *string    `db:"person_id_number"`
    DisplayName         string     `db:"display_name"`
    DateOfBirth         *time.Time `db:"date_of_birth"`
    GenderID            *int32     `db:"gender_id"`
    StatusID            *int32     `db:"status_id"`
    CreatedAt           time.Time  `db:"created_at"`
    UpdatedAt           time.Time  `db:"updated_at"`
}

DTO with Validation ​

go
type CreatePersonRequest struct {
    DisplayName    string  `json:"display_name" validate:"required,max=200"`
    Email          string  `json:"email" validate:"required,email"`
    DateOfBirth    *string `json:"date_of_birth" validate:"omitempty,datetime=2006-01-02"`
    GenderID *int32  `json:"gender_id"`
}

type PersonResponse struct {
    PersonID      string  `json:"person_id"`
    DisplayName   string  `json:"display_name"`
    Email         string  `json:"email,omitempty"`
    DateOfBirth   *string `json:"date_of_birth,omitempty"`
    GenderCode    *string `json:"gender_code,omitempty"`
    GenderDisplay *string `json:"gender_display,omitempty"`
}

Repository Interface ​

go
type PersonRepository interface {
    FindByID(ctx context.Context, id string) (*Person, error)
    FindAll(ctx context.Context, params *QueryParams) ([]*Person, int, error)
    Create(ctx context.Context, person *Person) error
    Update(ctx context.Context, person *Person) error
    Delete(ctx context.Context, id string) error
    Search(ctx context.Context, query string, limit int) ([]*Person, error)
}

Service Interface ​

go
type PersonService interface {
    GetByID(ctx context.Context, id string) (*PersonResponse, error)
    List(ctx context.Context, params *QueryParams) (*PaginatedResponse, error)
    Create(ctx context.Context, req *CreatePersonRequest) (*PersonResponse, error)
    Update(ctx context.Context, id string, req *UpdatePersonRequest) (*PersonResponse, error)
    Delete(ctx context.Context, id string) error
}

Handler Pattern ​

go
type Handler struct {
    service   PersonService
    validator *validator.Validate
    logger    *slog.Logger
}

func NewHandler(svc PersonService, v *validator.Validate, l *slog.Logger) *Handler {
    return &Handler{service: svc, validator: v, logger: l}
}

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

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

    response.OK(w, person)
}

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

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

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

    response.Created(w, person)
}

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")

    // List and create
    view.Handle("GET "+p+"/persons", h.ListPersons)
    edit.Handle("POST "+p+"/persons", h.CreatePerson)

    // Single resource operations
    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)
}

Middleware Stack ​

Middleware is applied in order:

  1. RequestID - Adds unique request ID to context
  2. RealIP - Extracts real client IP
  3. Logger - Structured request/response logging
  4. Recoverer - Panic recovery with stack traces
  5. CORS - Cross-origin resource sharing headers
  6. SecurityHeaders - Sets security response headers
  7. BodyLimit - Enforces request body size limits
  8. RateLimit - Per-IP rate limiting
  9. Timeout - 60-second request timeout

Authentication Middleware ​

The auth middleware validates server-side sessions stored in Valkey/Redis. It reads the sis_session cookie from the request, looks up the session data in Valkey, and populates the request context with user claims.

go
func Auth(validator SessionValidator) func(next http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            // Validate session from cookie against Valkey
            claims, err := validator.ValidateSession(r.Context(), r)
            if err != nil {
                response.Unauthorized(w, "Invalid or expired session")
                return
            }

            // Add claims to context
            ctx := context.WithValue(r.Context(), ClaimsContextKey, claims)
            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
}

The SessionValidator implementation (SessionServiceAdapter) performs these steps:

  1. Reads the sis_session cookie from the request
  2. Looks up the session ID in Valkey and deserializes the SessionData
  3. Enforces absolute session lifetime cap
  4. Extends TTL if sliding window expiration is enabled
  5. For superusers, checks for active impersonation sessions in the database

Session Cookies ​

Three cookies are used for authentication:

CookieHttpOnlyPurpose
sis_sessionYesOpaque session ID sent on all /api requests
sis_csrfNoCSRF token readable by JavaScript
sis_logged_inNoFlag for frontend to detect active session

CSRF Protection ​

State-changing requests (POST, PUT, PATCH, DELETE) require a valid CSRF token. The frontend reads the sis_csrf cookie and sends it back in the X-CSRF-Token request header. The middleware verifies the header value matches the cookie value.

Error Handling ​

Custom Error Type ​

go
type AppError struct {
    Code       string            `json:"code"`
    Message    string            `json:"message"`
    Details    map[string]string `json:"details,omitempty"`
    HTTPStatus int               `json:"-"`
    Err        error             `json:"-"`
}

func (e *AppError) Error() string {
    return e.Message
}

Error Constructors ​

go
// Standard errors
errors.NewNotFoundError("person")           // 404
errors.NewValidationError("email", "invalid format")  // 400
errors.NewUnauthorizedError("Invalid credentials")    // 401
errors.NewForbiddenError("Access denied")             // 403
errors.NewInternalError("Database error", err)        // 500

// Domain-specific errors
errors.NewPrerequisiteNotMetError("MATH 101")
errors.NewTimeConflictError("Section conflicts with existing enrollment")
errors.NewCapacityExceededError("Section is full")

Response Helpers ​

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

// 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, returns appropriate status

Database Utilities ​

Query Builder ​

go
qb := db.NewQueryBuilder("persons")
qb.Where("status_id = ?", statusID)
qb.OrderBy("display_name ASC")
qb.Limit(params.PageSize)
qb.Offset((params.Page - 1) * params.PageSize)

countSQL, countArgs := qb.CountSQL()
selectSQL, selectArgs := qb.SelectSQL()
rows, err := database.QueryContext(ctx, selectSQL, selectArgs...)

Pagination ​

go
type QueryParams struct {
    Page     int    `json:"page"`
    PageSize int    `json:"page_size"`
    OrderBy  string `json:"order_by"`
    Search   string `json:"search"`
}

type PaginatedResponse struct {
    Count    int         `json:"count"`
    Next     *string     `json:"next"`
    Previous *string     `json:"previous"`
    Results  interface{} `json:"results"`
}

Configuration ​

Environment variables loaded via .env:

bash
# Server
SERVER_PORT=8000
SERVER_HOST=0.0.0.0
ENVIRONMENT=development

# Database
DB_HOST=localhost
DB_PORT=5433
DB_USER=postgres
DB_PASSWORD=postgres
DB_NAME=sis
DB_SSLMODE=disable
DB_MAX_CONNS=25
DB_MAX_IDLE_TIME=5m

# Valkey/Redis (required for sessions)
REDIS_URL=localhost:6379
REDIS_PASSWORD=
REDIS_DB=0

# Sessions
SESSION_TTL=4h
SESSION_SLIDING_EXPIRATION=true

# Cookies
COOKIE_DOMAIN=
COOKIE_PATH=/api
COOKIE_SECURE=           # defaults to true in production, false in development

# CORS
CORS_ALLOWED_ORIGINS=http://localhost:5173
CORS_ALLOW_CREDENTIALS=true

Application Startup ​

The main function orchestrates initialization:

  1. Load configuration from environment
  2. Initialize structured logger
  3. Connect to PostgreSQL with connection pooling (database/sql)
  4. Initialize Valkey/Redis (required for session management)
  5. Create session service (Valkey-backed)
  6. Instantiate all repositories
  7. Instantiate all services with dependencies
  8. Instantiate all handlers
  9. Create router with middleware stack
  10. Register all domain routes
  11. Start HTTP server with graceful shutdown

Student Information System Documentation