Skip to Content
Course content

214: Building a Multi-Tenant SaaS Backend in Scala

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Alright, let's dive into this. We're building "TaskFlow," a project management tool. The requirement is simple on paper: multi-tenancy. We want a single deployment of our Scala backend serving hundreds of different companies, but Company A must never see Company B's tasks. It sounds easy—just add a tenant_id column to every table—but as we're about to see, the "plumbing" of that is where things usually fall apart.

The "Manual Pass" Fatigue

My first instinct, and probably yours too, is to just pass the tenant ID everywhere. Let's look at how I started the TaskService. I wrote a method to fetch all tasks for a project, and it looked something like this:

case class Task(id: UUID, tenantId: UUID, title: String, projectId: UUID)

class TaskService(repo: TaskRepository) {
  def getTasksForProject(tenantId: UUID, projectId: UUID): List[Task] = {
    repo.findByProject(tenantId, projectId)
  }
}

It works. Great. But then I added ProjectService, UserService, BillingService... and suddenly, every single method signature in my entire application starts with tenantId: UUID. It's noisy, it's redundant, and honestly, it's a cognitive burden. I'm spending more time passing the ID around than actually writing business logic. There has to be a way to make the tenant context "ambient" without losing type safety.

Turning the ID into a Context

Since we're in Scala, I'm thinking about how to hide this boilerplate. I could use a ThreadLocal, but that's a nightmare for testing and doesn't play well with asynchronous code (which we're using). Instead, let's try using a context parameter. In Scala 3, we have using clauses, which are perfect for this.

I'll define a TenantContext case class and change my service to require it implicitly:

case class TenantContext(tenantId: UUID)

class TaskService(repo: TaskRepository) {
  def getTasksForProject(projectId: UUID)(using ctx: TenantContext): List[Task] = {
    repo.findByProject(ctx.tenantId, projectId)
  }
}

Now my business logic looks cleaner. When I call getTasksForProject(projId), the compiler just finds the TenantContext in scope. I feel like I've won. But here is where I hit a wall: I just wrote a new method to archive a task, and in my haste, I forgot to pass the tenantId to the repository's update call. I accidentally wrote a query that updated a task based only on its taskId.

This is the "SaaS Nightmare." One missing where` tenant_id = ? and Company A can archive Company B's data just by guessing a UUID. Relying on the developer to remember to use the context in every single repo call is a recipe for a security breach.

Enforcing Isolation at the Boundary

I need a guardrail. I can't trust myself (or anyone else on the team) to manually filter by tenant every time. The solution is to move the tenant filtering out of the service layer and bake it into the repository layer itself.

I'll create a base TenantRepository trait that handles the filtering logic. Instead of letting the repository methods take a tenantId as a raw argument, I'll force them to operate within a context that provides it.

trait TenantRepository[T] {
  // We define a protected method that the actual DB implementation uses
  protected def applyTenantFilter(query: Query[T])(using ctx: TenantContext): Query[T] = {
    query.where('tenant_id'.eq(ctx.tenantId))
  }
}

class TaskRepository extends TenantRepository[Task] {
  def findByProject(projectId: UUID)(using ctx: TenantContext): List[Task] = {
    val baseQuery = Query.for[Task].where('project_id'.eq(projectId))
    // I now FORCE the filter to be applied here
    db.run(applyTenantFilter(baseQuery))
  }
}

By wrapping the query execution in applyTenantFilter, I've created a bottleneck. If I try to write a repository method and I don't use that helper, the code will either not compile (because it needs the TenantContext) or it will be glaringly obvious during a code review that I'm bypassing the tenant filter. It's not 100% foolproof—I could still write a raw SQL string—but it moves the risk from "every single method" to "only when I bypass the framework."

Connecting the Dots at the API Level

The last piece of the puzzle: where does the TenantContext actually come from? I don't want to manually instantiate it in every Controller action. I'll set up a Middleware (or a Filter in Play/Http4s) that extracts the X-Tenant-ID header from the incoming request and puts it into the scope.

In a real-world ZIO or Cats Effect app, I'd use a Reader monad or a ZLayer to provide this. For our purposes, the flow looks like this: Request arrives → Middleware extracts TenantID → Middleware creates TenantContext → Context is passed into the Service → Service passes it to the Repository → Repository applies the SQL filter.

It's a bit more setup upfront, but now I can sleep at night knowing that a junior dev adding a "Delete Project" feature won't accidentally wipe out the entire database because they forgot a where clause.




📋 Practical Task

Exercise: Implementing a Tenant-Aware Audit Log

You are tasked with adding an AuditLogService to TaskFlow. This service records every major action a user takes (e.g., "Created Project", "Deleted Task"). To maintain strict isolation, the audit logs must also be tenant-isolated.

Requirements:

  • Create a AuditLog case class containing id: UUID, tenantId: UUID, action: String, and timestamp: LocalDateTime.
  • Implement an AuditLogRepository that extends the TenantRepository pattern discussed in the lesson.
  • Implement a logAction(action: String)(using ctx: TenantContext) method in the repository.
  • Ensure that the logAction method uses the applyTenantFilter logic (or an equivalent mechanism) to ensure the log is associated with the correct tenant from the context, rather than taking a tenantId as a direct parameter.
  • Write a small test case or main method that attempts to log an action for "Tenant A" and verify that the resulting record contains the correct tenantId from the TenantContext.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.