Backend Architecture
The SIS backend is built with Go using a domain-driven design approach.
Technology Stack
| Component | Technology |
|---|---|
| Language | Go 1.24+ |
| HTTP Router | Standard library net/http (Go 1.22+ routing) |
| Database | PostgreSQL (via lib/pq driver) |
| Session Store | Valkey/Redis (required for session management) |
| Authentication | Session-based (Valkey/Redis) |
| Validation | go-playground/validator/v10 |
| Logging | Standard 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 variablesDomain 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 functionComplex 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.goLayered 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
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
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
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
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
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
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:
- RequestID - Adds unique request ID to context
- RealIP - Extracts real client IP
- Logger - Structured request/response logging
- Recoverer - Panic recovery with stack traces
- CORS - Cross-origin resource sharing headers
- SecurityHeaders - Sets security response headers
- BodyLimit - Enforces request body size limits
- RateLimit - Per-IP rate limiting
- 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.
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:
- Reads the
sis_sessioncookie from the request - Looks up the session ID in Valkey and deserializes the
SessionData - Enforces absolute session lifetime cap
- Extends TTL if sliding window expiration is enabled
- For superusers, checks for active impersonation sessions in the database
Session Cookies
Three cookies are used for authentication:
| Cookie | HttpOnly | Purpose |
|---|---|---|
sis_session | Yes | Opaque session ID sent on all /api requests |
sis_csrf | No | CSRF token readable by JavaScript |
sis_logged_in | No | Flag 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
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
// 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
// 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 statusDatabase Utilities
Query Builder
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
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:
# 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=trueApplication Startup
The main function orchestrates initialization:
- Load configuration from environment
- Initialize structured logger
- Connect to PostgreSQL with connection pooling (
database/sql) - Initialize Valkey/Redis (required for session management)
- Create session service (Valkey-backed)
- Instantiate all repositories
- Instantiate all services with dependencies
- Instantiate all handlers
- Create router with middleware stack
- Register all domain routes
- Start HTTP server with graceful shutdown