Impact: HIGH (Business logic scattered across handlers and actions is untestable, duplicated, and impossible to reuse across entry points)
Route handlers and server actions are entry points, not business logic containers. When validation rules, authorization checks, data transformations, or orchestration logic lives inside a POST handler or "use server" function, that logic cannot be unit tested without simulating HTTP requests, cannot be reused when a second entry point (webhook, cron job, CLI) needs the same behavior, and becomes invisible to developers who assume thin controllers.
Services should return a discriminated union ServiceResult<T> rather than throwing exceptions or returning raw data. This forces callers to handle both success and failure paths explicitly and keeps HTTP status code decisions in the controller where they belong.
// The ServiceResult pattern — define once, use everywheretype ServiceResult<T> = | { success: true; data: T } | { success: false; error: string; code?: string };
Incorrect (fat server action with business logic, validation, and data access mixed together):
// ❌ app/actions/team.ts"use server";import { createClient } from "@/lib/supabase/server";import { revalidatePath } from "next/cache";export async function inviteTeamMember(formData: FormData) { const supabase = await createClient(); const email = formData.get("email") as string; const teamId = formData.get("teamId") as string; const role = formData.get("role") as string; // ❌ Validation logic embedded in the server action if (!email || !email.includes("@")) { return { error: "Valid email is required" }; } if (!["admin", "member", "viewer"].includes(role)) { return { error: "Invalid role" }; } // ❌ Authorization logic in the server action const { data: currentUser } = await supabase.auth.getUser(); const { data: membership } = await supabase .from("team_members") .select("role") .eq("team_id", teamId) .eq("user_id", currentUser.user?.id) .single(); if (membership?.role !== "admin") { return { error: "Only admins can invite members" }; } // ❌ Business rule (duplicate check) in the server action const { data: existing } = await supabase .from("team_invitations") .select("id") .eq("team_id", teamId) .eq("email", email) .eq("status", "pending") .single(); if (existing) { return { error: "Invitation already pending for this email" }; } // ❌ Data mutation directly in the server action const { error } = await supabase.from("team_invitations").insert({ team_id: teamId, email, role, invited_by: currentUser.user?.id, expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), }); if (error) { return { error: "Failed to send invitation" }; } // ❌ Side effects (email sending) mixed in with everything else await fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/email/send`, { method: "POST", body: JSON.stringify({ to: email, template: "team-invite", data: { teamId, role }, }), }); revalidatePath(`/dashboard/teams/${teamId}`); return { success: true };}
Correct (thin server action delegating to a service that returns ServiceResult):