All posts

    Row-Level Multitenancy in Go: What I Learned Building Proctura

    April 20, 20268 min read
    Go
    PostgreSQL
    Multitenancy
    Backend
    SaaS

    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_id column. 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:

    RoleScope
    super_adminManages schools (creates/deactivates tenants)
    school_adminManages lecturers and students within their school
    lecturerCreates courses, exams, questions, and test cases
    studentTakes 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

    1. 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_id clause.
    2. 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.
    3. 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.