Database access belongs in repository classes, not in services or route handlers. Repositories abstract the data source behind a typed interface. [CWE-1057]
Why This Matters
Direct database calls in services tightly couple business logic to the data layer, preventing testing and making data source migrations costly
Impact: HIGH (Direct database calls in services tightly couple business logic to the data layer, preventing testing and making data source migrations costly)
The Repository pattern places a typed abstraction between business logic and data persistence. When a service calls supabase.from("users").select("*") directly, that service is coupled to Supabase's query API, its column naming conventions, and its error structure. You cannot unit test the service without a live database or an elaborate mock of the Supabase client. If you later need to read users from a cache, a different database, or an external API, every service that touches that table must change.
Repositories own the mapping between database rows and domain types, handle query construction and error translation, and expose a small surface area of typed methods (findById, create, update) that services consume.
Incorrect (Supabase queries scattered directly in the service layer):
// ❌ lib/services/project-service.tsimport { createClient } from "@/lib/supabase/server";export class ProjectService { async getProjectWithMembers(projectId: string, userId: string) { const supabase = await createClient(); // ❌ Raw Supabase query in the service — coupled to table schema const { data: project, error: projectError } = await supabase .from("projects") .select("id, name, description, created_at, owner_id") .eq("id", projectId) .single(); if (projectError) { throw new Error("Project not found"); } // ❌ Second query in the service — service knows about join tables const { data: members } = await supabase .from("project_members") .select(` user_id, role, users (id, full_name, avatar_url, email) `) .eq("project_id", projectId); // ❌ Authorization check uses raw query — logic is untestable const isMember = members?.some((m) => m.user_id === userId); if (!isMember) { throw new Error("Not a project member"); } // ❌ Manual mapping from snake_case DB columns — duplicated everywhere return { id: project.id, name: project.name, description: project.description, createdAt: project.created_at, ownerId: project.owner_id, members: members?.map((m) => ({ userId: m.user_id, role: m.role, name: (m.users as any).full_name, avatarUrl: (m.users as any).avatar_url, email: (m.users as any).email, })), }; } async archiveProject(projectId: string) { const supabase = await createClient(); // ❌ Another raw query — if the column name changes, every service breaks const { error } = await supabase .from("projects") .update({ archived_at: new Date().toISOString(), status: "archived" }) .eq("id", projectId); if (error) { throw new Error("Failed to archive project"); } }}
Correct (repository abstracts all data access behind typed methods):