DEV Community

meta-closure
meta-closure

Posted on

Building a Bulletproof Next.js User Management System with Contract Programming

Part 3 of 5: From theory to production-ready code that AI tools can safely contribute to

Welcome back! In Part 2, we learned the fundamentals of Decorator Contract Programming. Today, we're building a complete, production-ready user management system that's impossible for AI to break.

๐ŸŽฏ What We're Building

A Next.js user management system with:

  • โœ… Multi-layer security (Presentation โ†’ Action โ†’ Business โ†’ Data)
  • โœ… AI-proof contracts at every layer
  • โœ… Automatic error handling and appropriate responses
  • โœ… Complete audit trail of all operations
  • โœ… Type safety with runtime validation

๐Ÿ—๏ธ Architecture Overview

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ app/routes/*.tsx    โ”‚ โ† Presentation Layer
โ”‚ (Basic auth checks) โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ lib/actions/*.ts    โ”‚ โ† Action Layer  
โ”‚ (Permissions & I/O) โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ lib/services/*.ts   โ”‚ โ† Business Layer
โ”‚ (Ownership & Rules) โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ lib/data/*.ts       โ”‚ โ† Data Layer
โ”‚ (Final defense)     โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
Enter fullscreen mode Exit fullscreen mode

๐Ÿ“‹ Step 1: Schema Definitions

First, let's define our data contracts with Zod:

// lib/schemas/user.ts
import { z } from 'zod';

// Input schemas
export const userUpdateSchema = z.object({
  userId: z.string().uuid('Invalid user ID format'),
  email: z.string().email('Invalid email format').optional(),
  name: z.string()
    .min(1, 'Name is required')
    .max(100, 'Name must be less than 100 characters')
    .optional(),
  role: z.enum(['user', 'admin', 'moderator']).optional()
});

export const userCreateSchema = z.object({
  email: z.string().email('Invalid email format'),
  name: z.string()
    .min(1, 'Name is required')
    .max(100, 'Name must be less than 100 characters'),
  role: z.enum(['user', 'admin', 'moderator']).default('user')
});

// Output schema
export const userOutputSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  name: z.string(),
  role: z.string(),
  createdAt: z.date(),
  updatedAt: z.date()
});

// Context types
export interface AuthContext {
  user: {
    id: string;
    email: string;
    roles: string[];
  };
  session: {
    id: string;
    expiresAt: Date;
  };
}

// Type exports
export type UserUpdateInput = z.infer<typeof userUpdateSchema>;
export type UserCreateInput = z.infer<typeof userCreateSchema>;
export type User = z.infer<typeof userOutputSchema>;
Enter fullscreen mode Exit fullscreen mode

๐ŸŽญ Step 2: Presentation Layer

The presentation layer handles basic authentication and UI:

// app/(dashboard)/profile/page.tsx
import { redirect } from 'next/navigation';
import { updateProfile } from '@/lib/actions/users';
import { getServerSession } from 'next-auth/next';
import { authOptions } from '@/lib/auth/config';

export default async function ProfilePage() {
  const session = await getServerSession(authOptions);

  // Presentation layer: Basic auth check
  if (!session?.user) {
    redirect('/login');
  }

  if (new Date(session.expires) < new Date()) {
    redirect('/login?error=session_expired');
  }

  return (
    <div className="max-w-2xl mx-auto p-6">
      <h1 className="text-2xl font-bold mb-6">Profile Settings</h1>

      <form action={updateProfile} className="space-y-4">
        <div>
          <label htmlFor="email" className="block text-sm font-medium mb-1">
            Email
          </label>
          <input
            id="email"
            name="email"
            type="email"
            defaultValue={session.user.email}
            className="w-full px-3 py-2 border rounded-md"
            required
          />
        </div>

        <div>
          <label htmlFor="name" className="block text-sm font-medium mb-1">
            Name
          </label>
          <input
            id="name"
            name="name"
            type="text"
            defaultValue={session.user.name}
            className="w-full px-3 py-2 border rounded-md"
            required
          />
        </div>

        <input name="userId" type="hidden" value={session.user.id} />

        <button
          type="submit"
          className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
        >
          Update Profile
        </button>
      </form>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

โšก Step 3: Action Layer

This is where the magic happens. The action layer handles permissions, validation, and rate limiting:

// lib/actions/users.ts
"use server";

import { getServerSession } from 'next-auth/next';
import { authOptions } from '@/lib/auth/config';
import { userService } from '@/lib/services/users';
import { redirect } from 'next/navigation';
import {
  userUpdateSchema,
  userCreateSchema,
  userOutputSchema,
  type UserUpdateInput,
  type UserCreateInput,
  type AuthContext,
  type User
} from '@/lib/schemas/user';
import { contract, auth, validates, owns, rateLimit, auditLog, returns } from '@/lib/contracts';

class UserActions {
  @contract({
    requires: [
      auth('user'),                     // Must be authenticated
      validates(userUpdateSchema),      // Input validation
      owns('userId'),                   // Must own the profile
      rateLimit('updateProfile', 5)     // Max 5 updates per minute
    ],
    ensures: [
      returns(userOutputSchema),        // Output validation
      auditLog('profile_update')        // Audit trail
    ],
    layer: 'action'
  })
  async updateProfile(
    input: UserUpdateInput,
    context: AuthContext
  ): Promise<User> {
    return userService.updateUser(input, context);
  }

  @contract({
    requires: [
      auth('admin'),                    // Admin only
      validates(userCreateSchema),      // Input validation
      rateLimit('createUser', 10)       // Max 10 creations per minute
    ],
    ensures: [
      returns(userOutputSchema),        // Output validation
      auditLog('user_creation')         // Audit trail
    ],
    layer: 'action'
  })
  async createUser(
    input: UserCreateInput,
    context: AuthContext
  ): Promise<User> {
    return userService.createUser(input, context);
  }

  @contract({
    requires: [
      auth('user'),                     // Must be authenticated
      owns('userId'),                   // Can only delete own account
      rateLimit('deleteProfile', 1)     // Max 1 deletion per minute
    ],
    ensures: [
      auditLog('profile_deletion')      // Audit trail
    ],
    layer: 'action'
  })
  async deleteProfile(
    input: { userId: string },
    context: AuthContext
  ): Promise<{ success: boolean }> {
    await userService.deleteUser(input.userId, context);
    return { success: true };
  }
}

const userActions = new UserActions();

// Server Action entry points
export const updateProfile = async (formData: FormData) => {
  try {
    const session = await getServerSession(authOptions);
    const context: AuthContext = {
      user: session?.user as any,
      session: session as any
    };

    const input: UserUpdateInput = {
      userId: formData.get('userId') as string,
      email: formData.get('email') as string,
      name: formData.get('name') as string
    };

    const result = await userActions.updateProfile(input, context);

    return { success: true, data: result };

  } catch (error) {
    if (error instanceof ContractViolationError) {
      const response = error.getAppropriateResponse();

      if (response.redirect) {
        redirect(response.redirect);
      }

      return response;
    }

    console.error('Unexpected error in updateProfile:', error);
    return { success: false, error: 'An unexpected error occurred.' };
  }
};

export const createUser = async (formData: FormData) => {
  try {
    const session = await getServerSession(authOptions);
    const context: AuthContext = {
      user: session?.user as any,
      session: session as any
    };

    const input: UserCreateInput = {
      email: formData.get('email') as string,
      name: formData.get('name') as string,
      role: (formData.get('role') as 'user' | 'admin' | 'moderator') || 'user'
    };

    const result = await userActions.createUser(input, context);
    return { success: true, data: result };

  } catch (error) {
    if (error instanceof ContractViolationError) {
      return error.getAppropriateResponse();
    }

    return { success: false, error: 'An unexpected error occurred.' };
  }
};
Enter fullscreen mode Exit fullscreen mode

๐Ÿง  Step 4: Business Layer

The business layer enforces ownership and business rules:

// lib/services/users.ts
import { userRepository } from '@/lib/data/users';
import { contract, owns, businessRule } from '@/lib/contracts';
import {
  type UserUpdateInput,
  type UserCreateInput,
  type User,
  type AuthContext
} from '@/lib/schemas/user';

class UserService {
  @contract({
    requires: [
      owns('userId'),                   // Must own the resource
      businessRule('Cannot change own role', (input, context) => {
        // Users can't change their own role
        return !input.role || 
               input.userId !== context.user.id || 
               input.role === context.user.roles[0];
      }),
      businessRule('Email change frequency limit', async (input, context) => {
        // Can only change email once per 24 hours
        if (!input.email) return true;

        const user = await userRepository.findById(input.userId);
        const lastChange = user?.emailChangedAt;
        if (!lastChange) return true;

        const hoursSince = (Date.now() - lastChange.getTime()) / (1000 * 60 * 60);
        return hoursSince >= 24;
      })
    ],
    invariants: [
      // Output user ID must match input
      (input, output) => output.id === input.userId,
      // Update time must be reasonable
      (input, output) => output.updatedAt <= new Date()
    ],
    layer: 'business'
  })
  async updateUser(
    input: UserUpdateInput,
    context: AuthContext
  ): Promise<User> {
    const existingUser = await userRepository.findById(input.userId);
    if (!existingUser) {
      throw new Error('User not found');
    }

    // Business logic
    const updatedUser = {
      ...existingUser,
      ...(input.email && { email: input.email }),
      ...(input.name && { name: input.name }),
      ...(input.role && { role: input.role }),
      updatedAt: new Date(),
      ...(input.email && input.email !== existingUser.email && { 
        emailChangedAt: new Date() 
      })
    };

    // Check for email conflicts
    if (input.email && input.email !== existingUser.email) {
      const duplicate = await userRepository.findByEmail(input.email);
      if (duplicate && duplicate.id !== input.userId) {
        throw new Error('Email already in use');
      }
    }

    return userRepository.save(updatedUser, context);
  }

  @contract({
    requires: [
      businessRule('Email must be unique', async (input) => {
        const existing = await userRepository.findByEmail(input.email);
        return !existing;
      })
    ],
    invariants: [
      (input, output) => output.email === input.email,
      (input, output) => output.name === input.name,
      (input, output) => output.role === input.role
    ],
    layer: 'business'
  })
  async createUser(
    input: UserCreateInput,
    context: AuthContext
  ): Promise<User> {
    const newUser = {
      id: crypto.randomUUID(),
      email: input.email,
      name: input.name,
      role: input.role,
      userId: crypto.randomUUID(),
      createdAt: new Date(),
      updatedAt: new Date()
    };

    return userRepository.create(newUser, context);
  }

  @contract({
    requires: [
      owns('userId'),
      businessRule('Cannot delete admin users', async (userId, context) => {
        const user = await userRepository.findById(userId);
        return user?.role !== 'admin' || 
               context.user.roles.includes('super_admin');
      })
    ],
    layer: 'business'
  })
  async deleteUser(userId: string, context: AuthContext): Promise<void> {
    await userRepository.delete(userId, context);
  }
}

export const userService = new UserService();
Enter fullscreen mode Exit fullscreen mode

๐Ÿ—„๏ธ Step 5: Data Layer

The data layer provides the final security boundary:

// lib/data/users.ts
import { PrismaClient } from '@prisma/client';
import { contract, auditLog } from '@/lib/contracts';
import { type User, type AuthContext } from '@/lib/schemas/user';

const prisma = new PrismaClient();

class UserRepository {
  @contract({
    requires: [
      // Final data access permission check
      (input, context) => {
        return context.user.roles.includes('admin') || 
               input.id === context.user.id;
      }
    ],
    ensures: [
      // Data integrity final check
      (output, input) => output.id === input.id,
      auditLog('user_data_update')
    ],
    invariants: [
      // Saved data integrity
      (input, output) => output.updatedAt >= input.updatedAt
    ],
    layer: 'data'
  })
  async save(user: User, context: AuthContext): Promise<User> {
    const result = await prisma.user.update({
      where: { 
        id: user.id,
        // Query-level safety net
        ...(context.user.roles.includes('admin') ? {} : { id: context.user.id })
      },
      data: {
        email: user.email,
        name: user.name,
        role: user.role,
        updatedAt: user.updatedAt,
        emailChangedAt: user.emailChangedAt
      }
    });

    return result as User;
  }

  @contract({
    ensures: [
      auditLog('user_data_creation')
    ],
    invariants: [
      (input, output) => output.email === input.email,
      (input, output) => output.name === input.name
    ],
    layer: 'data'
  })
  async create(user: User, context: AuthContext): Promise<User> {
    const result = await prisma.user.create({
      data: {
        id: user.id,
        email: user.email,
        name: user.name,
        role: user.role,
        userId: user.userId,
        createdAt: user.createdAt,
        updatedAt: user.updatedAt
      }
    });

    return result as User;
  }

  @contract({
    requires: [
      // Final deletion permission check
      (input, context) => {
        return context.user.roles.includes('admin') || 
               input === context.user.id;
      }
    ],
    ensures: [
      auditLog('user_data_deletion')
    ],
    layer: 'data'
  })
  async delete(userId: string, context: AuthContext): Promise<void> {
    await prisma.user.delete({
      where: { 
        id: userId,
        // Query-level safety net
        ...(context.user.roles.includes('admin') ? {} : { id: context.user.id })
      }
    });
  }

  // Helper methods (no contracts - internal use only)
  async findById(userId: string): Promise<User | null> {
    const user = await prisma.user.findUnique({
      where: { id: userId }
    });
    return user as User | null;
  }

  async findByEmail(email: string): Promise<User | null> {
    const user = await prisma.user.findUnique({
      where: { email }
    });
    return user as User | null;
  }
}

export const userRepository = new UserRepository();
Enter fullscreen mode Exit fullscreen mode

๐Ÿ”ง Step 6: Contract Implementation Details

Here are the missing pieces from our contract system:

// lib/contracts/index.ts
import { z } from 'zod';

// Rate limiting helper (you'd implement with Redis or similar)
const rateLimitStore = new Map<string, { count: number; resetTime: number }>();

async function getRateLimitCount(key: string): Promise<number> {
  const now = Date.now();
  const stored = rateLimitStore.get(key);

  if (!stored || now > stored.resetTime) {
    rateLimitStore.set(key, { count: 0, resetTime: now + 60000 }); // 1 minute
    return 0;
  }

  return stored.count;
}

async function incrementRateLimitCount(key: string): Promise<void> {
  const now = Date.now();
  const stored = rateLimitStore.get(key);

  if (!stored || now > stored.resetTime) {
    rateLimitStore.set(key, { count: 1, resetTime: now + 60000 });
  } else {
    stored.count++;
  }
}

// Resource ownership helper
async function getResourceById(id: string): Promise<{ id: string; userId: string } | null> {
  // This would typically query your database
  // For user resources, the user IS the resource
  try {
    const user = await prisma.user.findUnique({ where: { id } });
    return user ? { id: user.id, userId: user.id } : null;
  } catch {
    return null;
  }
}

// Audit logging helper
interface AuditEvent {
  action: string;
  userId: string;
  resourceId?: string;
  timestamp: Date;
  input: any;
  output?: any;
  success: boolean;
}

async function logAuditEvent(event: AuditEvent): Promise<void> {
  // In production, this would go to your audit log system
  console.log('AUDIT:', JSON.stringify({
    ...event,
    input: sanitizeForAudit(event.input),
    output: sanitizeForAudit(event.output)
  }));
}

function sanitizeForAudit(data: any): any {
  if (typeof data !== 'object' || data === null) {
    return data;
  }

  const sanitized = { ...data };
  // Remove sensitive fields
  delete sanitized.password;
  delete sanitized.token;
  delete sanitized.secret;

  return sanitized;
}

// Rate limit condition
export function rateLimit(operation: string, maxPerMinute: number) {
  return async (input: any, context: AuthContext): Promise<boolean> => {
    const key = `rateLimit:${context.user.id}:${operation}`;
    const current = await getRateLimitCount(key);

    if (current >= maxPerMinute) {
      throw new ContractError('RATE_LIMIT_EXCEEDED', 
        `Rate limit exceeded for ${operation}: ${current}/${maxPerMinute} per minute`);
    }

    await incrementRateLimitCount(key);
    return true;
  };
}

// Audit log condition
export function auditLog(action: string) {
  return async (output: any, input: any, context: AuthContext): Promise<boolean> => {
    await logAuditEvent({
      action,
      userId: context.user.id,
      resourceId: input.id || input.userId,
      timestamp: new Date(),
      input,
      output,
      success: true
    });
    return true;
  };
}

// Output validation condition
export function returns(schema: z.ZodSchema) {
  return (output: any, input: any, context: any): boolean => {
    try {
      schema.parse(output);
      return true;
    } catch (error) {
      throw new ContractError('OUTPUT_VALIDATION_FAILED', 
        `Output does not match expected schema: ${error.message}`);
    }
  };
}

// Business rule condition
export function businessRule(
  description: string, 
  rule: (input: any, context: AuthContext) => boolean | Promise<boolean>
) {
  return async (input: any, context: AuthContext): Promise<boolean> => {
    const passed = await rule(input, context);
    if (!passed) {
      throw new ContractError('BUSINESS_RULE_VIOLATION', description);
    }
    return true;
  };
}
Enter fullscreen mode Exit fullscreen mode

โœ… Step 7: Testing Your Secure System

// __tests__/user-actions.test.ts
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
import { UserActions } from '@/lib/actions/users';
import { ContractViolationError } from '@/lib/contracts';

describe('UserActions', () => {
  let userActions: UserActions;
  let mockContext: AuthContext;

  beforeEach(() => {
    userActions = new UserActions();
    mockContext = {
      user: { id: 'user-123', email: 'user@test.com', roles: ['user'] },
      session: { id: 'session-123', expiresAt: new Date(Date.now() + 3600000) }
    };
  });

  describe('updateProfile', () => {
    it('should successfully update own profile', async () => {
      const input = {
        userId: 'user-123',
        email: 'newemail@test.com',
        name: 'New Name'
      };

      const result = await userActions.updateProfile(input, mockContext);

      expect(result.email).toBe('newemail@test.com');
      expect(result.name).toBe('New Name');
    });

    it('should fail when trying to update other user profile', async () => {
      const input = {
        userId: 'user-456', // Different user
        email: 'newemail@test.com',
        name: 'New Name'
      };

      await expect(userActions.updateProfile(input, mockContext))
        .rejects
        .toThrow(ContractViolationError);
    });

    it('should fail with invalid email format', async () => {
      const input = {
        userId: 'user-123',
        email: 'invalid-email',
        name: 'New Name'
      };

      await expect(userActions.updateProfile(input, mockContext))
        .rejects
        .toThrow('Input validation failed');
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

๐ŸŽ‰ What We've Accomplished

Our system now has:

โœ… 4-layer security - Each layer catches what others miss

โœ… Automatic validation - Zod schemas catch malformed input

โœ… Ownership checks - Users can only modify their own data

โœ… Rate limiting - Prevents abuse and DoS attacks

โœ… Complete audit trail - Every action is logged

โœ… Graceful error handling - Appropriate responses per layer

๐Ÿค– The AI Advantage

Here's the beautiful part: AI tools can now safely contribute to this codebase. When you ask Claude to "add a feature to delete user posts", it will:

  1. Copy the contract patterns it sees everywhere
  2. Follow the layer structure that's clearly defined
  3. Generate secure code because insecure code won't deploy

The contracts guide AI toward secure implementations automatically.

๐Ÿ”ฎ Coming Next

In Part 4, we'll explore advanced patterns:

  • Contract composition and inheritance
  • Conditional contracts for complex business rules
  • Testing strategies for contract-based systems
  • Performance optimization techniques

Questions about the implementation? Drop them below! I'd love to help you adapt this to your specific use case.


Next up: Advanced contract patterns that handle the most complex business scenarios.

Top comments (0)