Crafting Resilient API Endpoints: Multi-Step Registrations with Rollback
In the ims-api project, we're building the backbone for an Institution Management System. A core feature is the ability for new institutions to register themselves, which involves creating not just an institution record but also an initial director user and their associated profile. This seemingly straightforward process quickly became complex due to the need for data integrity across multiple database operations.## The Challenge: Multi-Step RegistrationRegistering a new institution isn't a single database insert. It's a sequence of critical steps:1. Validate incoming request data: Ensure all required fields are present and correctly formatted.2. Verify uniqueness: Check that the chosen subdomain and director's email are not already in use.3. Create the institution record: Mark it as an initial TRIAL state.4. Create the director's user account: This involves interacting with the authentication system (Supabase Auth in our case).5. Create the director's profile: Link it back to the newly created institution and user.The major challenge? If any of these steps fail mid-process, we can't leave orphaned or inconsistent data. For example, if the institution is created but the user creation fails, we need to undo the institution record. This demands a robust rollback mechanism, especially when spanning multiple services or tables.## Our Solution: Atomicity and RobustnessTo address this, we implemented a service layer designed for atomicity and resilience, even when dealing with external services like Supabase Auth.First, we leveraged Zod for strict schema validation of the incoming request body. This ensures that only well-formed data proceeds to the business logic.typescriptimport { z } from 'zod';const registerInstitutionSchema = z.object({ name: z.string().min(3), subdomain: z.string().min(3).regex(/^[a-z0-9-]+$/), directorEmail: z.string().email(), directorPassword: z.string().min(8), // ... other fields});type RegisterInstitutionPayload = z.infer<typeof registerInstitutionSchema>;Next, our core service orchestrates the multi-step process, incorporating a manual rollback strategy. Each critical step is guarded, and if it fails, previous successful operations are systematically undone. This simulates a transaction across disparate operations, ensuring an "all or nothing" outcome.typescriptimport { SupabaseClient } from '@supabase/supabase-js'; // Simplified importclass InstitutionRegistrationService { constructor(private readonly supabase: SupabaseClient) {} async register(payload: RegisterInstitutionPayload) { let institutionId: string | null = null; let userId: string | null = null; try { // Step 1: Validate uniqueness (subdomain, email) await this.checkUniqueness(payload.subdomain, payload.directorEmail); // Step 2: Create Institution const { data: institutionData, error: instError } = await this.supabase .from('institutions') .insert({ name: payload.name, subdomain: payload.subdomain, status: 'TRIAL' }) .select('id') .single(); if (instError || !institutionData) throw new Error('Failed to create institution'); institutionId = institutionData.id; // Step 3: Create Auth User const { data: userData, error: userError } = await this.supabase.auth.admin.createUser({ email: payload.directorEmail, password: payload.directorPassword, }); if (userError || !userData?.user) throw new Error('Failed to create user'); userId = userData.user.id; // Step 4: Create Director Profile const { error: profileError } = await this.supabase .from('user_profiles') .insert({ user_id: userId, institution_id: institutionId, role: 'director' }); if (profileError) throw new Error('Failed to create director profile'); return { success: true, institutionId, userId }; } catch (error) { // Manual Rollback console.error('Registration failed, initiating rollback:', error); if (institutionId) { await this.supabase.from('institutions').delete().eq('id', institutionId); } if (userId) { await this.supabase.auth.admin.deleteUser(userId); } throw error; // Re-throw to signal failure to the caller } } private async checkUniqueness(subdomain: string, email: string) { // Implement actual uniqueness checks here // Example: Query 'institutions' table for subdomain, 'auth.users' for email // For email, we noted current tech debt uses `listUsers()` which is inefficient. // This will be optimized later when a dedicated auth module is available. console.log(`Checking uniqueness for subdomain: ${subdomain}, email: ${email}`); // Simulate checks if (subdomain === 'existing-sub') throw new Error('Subdomain already exists'); if (email === '[email protected]') throw new Error('Email already exists'); }}This service is then exposed through an Express API route:typescriptimport express, { Request, Response } from 'express';import { InstitutionRegistrationService } from './InstitutionRegistrationService';import { supabaseAdmin } from './supabaseClient'; // Our pre-configured Supabase clientconst router = express.Router();const registrationService = new InstitutionRegistrationService(supabaseAdmin);router.post('/api/v1/institutions/register', async (req: Request, res: Response) => { try { const validatedBody = registerInstitutionSchema.parse(req.body); // Zod validation const result = await registrationService.register(validatedBody); res.status(201).json(result); } catch (error: any) { // Handle validation errors or service errors res.status(400).json({ message: error.message }); }});export default router;Crucially, this entire workflow is thoroughly unit tested using Vitest. Tests cover the happy path, validation failures, and various rollback scenarios (e.g., institution creation succeeds, but user creation fails, leading to institution deletion). We used manual mocking for the supabaseAdmin client to ensure service logic could be tested in isolation.## The Takeaway: Designing for FailureWhen building APIs that involve multiple dependent operations, especially across different data stores or external services, explicitly designing for failure and rollback is paramount. While database transactions handle atomicity within a single database, multi-service operations require a higher-level orchestration. Whether it's a manual try-catch-rollback pattern as shown, or more sophisticated distributed transaction managers, ensuring data consistency when things go wrong is key to a robust system. It saves countless hours of debugging inconsistent states and builds trust in your application.
Generated with Gitvlg.com