Row-Level Multitenancy in Go: What I Learned Building Proctura
Row-Level Multitenancy in Go: What I Learned Building Proctura
The idea behind Proctura is simple: universities run coding exams where students write and submit real code, not pseudocode on paper. The catch is that every university — each called a tenant — shares the same database, same API, and same codebase. Their data must never cross. Getting that boundary right was the central challenge of the project.
Three Ways to Isolate Tenants
There are three common approaches:
- Database-per-tenant — each tenant gets their own database. Strong isolation, but operationally expensive at scale.
- Schema-per-tenant — one database, separate Postgres schemas. Good isolation, tricky migrations.
- Row-level (shared schema) — one database, one schema, every row has a
tenant_idcolumn. Simplest to operate, but requires discipline in every query.
I chose row-level. Proctura is university-scale, not enterprise-scale — tens of tenants, not thousands. The operational simplicity of a single database outweighs the downsides, as long as you're rigorous about scoping.
Resolving the Tenant from the Request
Each university gets a subdomain: unilag.proctura.com, covenant.proctura.com. The tenant middleware resolves this from the Host header and attaches the tenant to the Gin context before any handler runs:
func ResolveTenant(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
subdomain := c.GetHeader("X-Tenant-Subdomain") // local dev override
if subdomain == "" {
host := c.Request.Host
parts := strings.SplitN(host, ".", 2)
if len(parts) >= 2 {
subdomain = parts[0]
}
}
var tenant models.Tenant
if err := db.Where("subdomain = ? AND is_active = true", subdomain).
First(&tenant).Error; err != nil {
response.NotFound(c, "school not found or inactive")
c.Abort()
return
}
c.Set("tenant", &tenant)
c.Set("tenantID", tenant.ID)
c.Next()
}
}
For local dev I set X-Tenant-Subdomain in my HTTP client rather than messing with /etc/hosts. Every authenticated route goes through this middleware first.
Baking TenantID into the JWT
Authentication and tenancy have to move together. When a user logs in, their JWT claims include both their role and their tenant:
type Claims struct {
UserID string `json:"user_id"`
TenantID string `json:"tenant_id"`
Email string `json:"email"`
Role string `json:"role"`
jwt.RegisteredClaims
}
The auth middleware validates the token and sets these values on the Gin context. This means handlers have both tenantID and role available without hitting the database again — and it makes cross-tenant requests structurally impossible, not just forbidden by a check.
Scoping Every Query
The real discipline with row-level multitenancy is making sure tenant_id appears in every single query. Miss it once and you've got a data leak. I made this explicit in every service method:
func (s *Service) GetExam(tenantID, examID string) (*models.Exam, error) {
var exam models.Exam
err := s.db.
Preload("Questions.TestCases").
Where("id = ? AND tenant_id = ?", examID, tenantID).
First(&exam).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrExamNotFound
}
return &exam, nil
}
The AND tenant_id = ? is the critical part. Without it, a user who guesses a valid exam UUID could read another school's exam. Every list, get, update, and delete in the codebase follows this pattern.
RBAC: Four Roles, Clear Boundaries
The platform has four roles with a clear hierarchy:
| Role | Scope |
|---|---|
super_admin | Manages schools (creates/deactivates tenants) |
school_admin | Manages lecturers and students within their school |
lecturer | Creates courses, exams, questions, and test cases |
student | Takes exams, writes and submits code |
Role enforcement is a Gin middleware that checks the role from JWT claims:
func RequireRole(roles ...string) gin.HandlerFunc {
return func(c *gin.Context) {
userRole := c.GetString("role")
if slices.Contains(roles, userRole) {
c.Next()
return
}
response.Forbidden(c, "you do not have permission to perform this action")
c.Abort()
}
}
Routes look like this:
lecturerRoutes.POST("/exams", middleware.RequireRole("lecturer"), exam.CreateExam)
studentRoutes.POST("/submissions", middleware.RequireRole("student"), submission.Submit)
The Exam State Machine
An exam moves through a fixed lifecycle: draft → scheduled → active → closed. Invalid transitions are rejected at the service layer:
var validTransitions = map[models.ExamStatus]models.ExamStatus{
models.ExamStatusDraft: models.ExamStatusScheduled,
models.ExamStatusScheduled: models.ExamStatusActive,
models.ExamStatusActive: models.ExamStatusClosed,
models.ExamStatusClosed: models.ExamStatusDraft,
}
A lecturer can only edit an exam in draft state. Once it's scheduled, the content is locked. This prevents a lecturer from changing questions or test cases after students have already started.
Code Execution with Judge0
When a student submits their code, it gets executed against the test cases defined by the lecturer. I used Judge0 via RapidAPI — it supports 50+ languages and handles the sandbox isolation.
The flow is async: submit the code, get a token back, then poll for the result.
// Submit code, get a token
token, err := j.judgeClient.Submit(SubmitRequest{
SourceCode: answer.Code,
LanguageID: exam.LanguageID,
ExpectedOutput: &testCase.ExpectedOutput,
})
// Poll until done
for IsProcessing(result.Status.ID) {
time.Sleep(1 * time.Second)
result, err = j.judgeClient.GetResult(token)
}
Judge0 status IDs 1 and 2 mean "In Queue" and "Processing". Anything higher is a final state. I store the Judge0 token on the SubmissionAnswer record so I can retrieve results later without re-submitting.
The grading runs as an async background job. When a student submits their exam, a task is pushed to the queue. The worker picks it up, runs each answer against its test cases, and writes the scores back. This keeps the /submit endpoint fast — students don't wait for execution to complete.
Lessons Learned
- Test the scoping discipline early. I added a simple test that creates two tenants, creates a resource under tenant A, then queries it as tenant B — and asserts it returns not-found. Running that type of check early would have caught a handful of places where I'd forgotten the
tenant_idclause. - Async grading has to be idempotent. If the worker crashes mid-grading and re-processes the same submission, you can't end up double-scoring answers. I handle this by checking whether an answer already has a Judge0 token before re-submitting.
- Judge0 rate limits are real. RapidAPI's free tier throttles concurrent submissions. For a real exam with 50 students submitting at the same time, you'd need to think about queuing submissions and back-off logic.
The source for the backend is on GitHub.