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) โ
โโโโโโโโโโโโโโโโโโโโโโโ
๐ 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>;
๐ญ 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>
);
}
โก 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.' };
}
};
๐ง 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();
๐๏ธ 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();
๐ง 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;
};
}
โ 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');
});
});
});
๐ 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:
- Copy the contract patterns it sees everywhere
- Follow the layer structure that's clearly defined
- 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)