Tenant models
Tenant-scoped queries on every read.
Multi-tenant SaaS = many isolated customers in one DB. Each tenant sees only their own data. Done right, it's invisible; done wrong, you leak customer A's data to customer B. This lesson covers the Grit pattern.
The model
type Tenant struct {ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`Name string `gorm:"not null" json:"name"`Slug string `gorm:"uniqueIndex;not null" json:"slug"`PlanID string `json:"plan_id"`CreatedAt time.Time `json:"created_at"`}// Every row in a tenant-scoped table gets a TenantIDtype Invoice struct {ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`TenantID uuid.UUID `gorm:"type:uuid;not null;index" json:"tenant_id"` // ← the scopeCustomerID uuid.UUID `gorm:"type:uuid;not null" json:"customer_id"`Total decimal.DecimalCreatedAt time.Time}
Two important pieces: a tenants table, and a TenantID column on every tenant-scoped row (orders, invoices, products — anything that "belongs to" a tenant).
The middleware that scopes queries
func TenantScope(db *gorm.DB) gin.HandlerFunc {return func(c *gin.Context) {userID, _ := c.Get("user_id")var user models.Userdb.First(&user, "id = ?", userID)c.Set("tenant_id", user.TenantID.String())c.Next()}}
Every protected request gets tenant_id on its context from the authenticated user's tenant.
Using it in handlers
func (h *InvoiceHandler) List(c *gin.Context) {tenantID := c.GetString("tenant_id")var invoices []models.Invoiceh.db.Where("tenant_id = ?", tenantID).Find(&invoices)c.JSON(200, gin.H{"data": invoices})}func (h *InvoiceHandler) Create(c *gin.Context) {tenantID, _ := uuid.Parse(c.GetString("tenant_id"))var inv models.Invoicec.ShouldBindJSON(&inv)inv.TenantID = tenantID // ← force the tenant, never trust inputh.db.Create(&inv)c.JSON(201, gin.H{"data": inv})}
Every read filters by tenant; every write forces the tenant from context, never from the request body. Never accept tenant_id in the request body — that'd let the user write to another tenant.
WHERE tenant_id = ? on a query. The query returns EVERY tenant's rows. Code review for it; add a lint rule that flags db.Find() without a Where clause in tenant- scoped handlers.Row-level security as belt-and-braces
Postgres supports row-level security (RLS) — DB-level filtering by a session var. If a handler forgets the filter, the DB itself rejects. Set up:
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;CREATE POLICY tenant_isolation ON invoicesUSING (tenant_id = current_setting('app.current_tenant')::uuid);-- Then in the middleware:db.Exec("SET LOCAL app.current_tenant = ?", tenantID)
Now even a buggy handler can't leak cross-tenant data. Worth the ~30 minutes to set up for high-stakes SaaS.
The Tenant model on the web side
On signup, create a tenant + assign the user as owner:
tenant := &Tenant{Name: input.CompanyName, Slug: slug.Make(input.CompanyName)}db.Create(tenant)user := &User{Email: input.Email,TenantID: tenant.ID,Role: "owner",}db.Create(user)
Quick check
Try it
Make one of your existing models tenant-scoped:
- Add
TenantID uuid.UUIDto the Product model. - Run
grit migrate. - Update the Product handler's List + Create to filter by / force tenant.
- Create two test tenants. Make sure tenant A's admin sees only tenant A's products.
Paste the WHERE clause + the force-from-context line in notes.md.
What's next
Tenants done. Next — role gates in the UI. Don't just block on the server; hide buttons the user can't use.
Spot a typo? Have an idea?
Help us improve this lesson. One click opens a GitHub issue with the lesson URL pre-filled — suggest clearer wording, report a bug, or request more depth. The course keeps improving thanks to learners like you.
Suggest an improvement on GitHub