😸
信頼出来ないAIコーディング対策としての契約プログラミング
1. モチベーション
AIコーディングツールは驚異的なスピードでコードを生成しますが、以下のような問題があります:
- 品質のバラつき: コードは動くが、セキュリティ(認可漏れ)やエラーハンドリングを無視
- 規約違反: フレームワークの責務分離を無視(Business LayerでDBアクセス等)
- 認可の混乱: どのレイヤーで何をチェックすべきかAIが理解していない
- スケーラビリティ: 品質管理が困難で、技術的負債が蓄積
従来の解決策はAIコーディングでは機能しません:
そこで、デコレーター契約プログラミングを用いて、AIが生成するコードに自動的にセーフティネットを適用するゼロトラストアーキテクチャを構築します。
2. 設計思想
核心となる3つの原則
- PRレビューに期待しない: デコレーターによる契約で自動検証
- レイヤーごとの責務を強制: 各レイヤーが適切な契約のみを持つ
- 壊れても露出しない: 契約違反は適切なレイヤーでフォールバック
レイヤーごとの契約責務
- Presentation Layer: 基本認証(ログイン状態、セッション有効性)
- Action Layer: アクション権限(ロール、レート制限、入力検証)
- Business Layer: リソース所有権(自分のデータか、ビジネスルール)
- Data Layer: データアクセス権限(クエリレベルの最終防御、監査)
AIに対する期待
- 何かしらの契約を書かなければ壊れるし、そこの部分のチェックが容易
- コピペみたくなると思うので、AIでも問題なくかける
- レイヤーごとの責務と契約さえ保てば、AI生成物へのクオリティコントロールとフェイルセーフがある程度できるだろうとの期待
アーキテクチャ図
3. デコレーター契約システム
基本デコレーターの実装
// 契約条件の型定義
type ContractCondition = (input: any, context: any) => boolean | Promise<boolean>;
type ContractValidator = (input: unknown) => any;
type ContractInvariant = (input: any, output: any) => boolean;
interface ContractOptions {
requires?: Array<ContractCondition | ContractValidator>;
ensures?: Array<ContractCondition | ((output: any, input: any, context: any) => boolean)>;
invariants?: Array<ContractInvariant>;
layer?: 'presentation' | 'action' | 'business' | 'data';
}
// メインの契約デコレーター
function contract(options: ContractOptions) {
return function(target: any, propertyName: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = async function(...args: any[]) {
const [input, context] = args;
const contractName = `${target.constructor.name}.${propertyName}`;
try {
// 事前条件チェック(requires)
let validatedInput = input;
for (const condition of options.requires || []) {
if (typeof condition === 'function' && condition.length === 1) {
// バリデーター(入力変換)
validatedInput = condition(validatedInput);
} else {
// 条件チェック
await condition(validatedInput, context);
}
}
// 元の処理実行
const result = await originalMethod.call(this, validatedInput, context);
// 事後条件チェック(ensures)
for (const condition of options.ensures || []) {
await condition(result, validatedInput, context);
}
// 不変条件チェック(invariants)
for (const invariant of options.invariants || []) {
if (!invariant(validatedInput, result)) {
throw new ContractError('INVARIANT_VIOLATION',
`Invariant condition failed in ${contractName}`
);
}
}
return result;
} catch (error) {
throw new ContractViolationError(
contractName,
options.layer || 'unknown',
error
);
}
};
return descriptor;
};
}
// 契約エラークラス
class ContractError extends Error {
constructor(public type: string, message: string) {
super(message);
this.name = 'ContractError';
}
}
class ContractViolationError extends Error {
constructor(
public contractName: string,
public layer: string,
public originalError: any
) {
super(`Contract violation in ${layer}.${contractName}: ${originalError.message}`);
this.name = 'ContractViolationError';
}
getAppropriateResponse() {
switch (this.layer) {
case 'presentation':
return { redirect: '/login', error: 'Authentication required' };
case 'action':
return { success: false, error: this.originalError.message };
case 'business':
return { success: false, error: 'Permission denied' };
case 'data':
return { success: false, error: 'Operation failed' };
default:
return { success: false, error: 'An error occurred' };
}
}
}
契約条件の実装
// 認証条件
function auth(requiredRole?: string) {
return async (input: any, context: AuthContext): Promise<boolean> => {
if (!context.user) {
throw new ContractError('AUTHENTICATION_REQUIRED', 'User must be logged in');
}
if (!context.session || new Date(context.session.expiresAt) < new Date()) {
throw new ContractError('SESSION_EXPIRED', 'Session has expired');
}
if (requiredRole && !context.user.roles.includes(requiredRole)) {
throw new ContractError('INSUFFICIENT_ROLE',
`Required role: ${requiredRole}, User roles: ${context.user.roles.join(', ')}`
);
}
return true;
};
}
// 所有権条件
function owns(resourceIdField: string) {
return async (input: any, context: AuthContext): Promise<boolean> => {
const resourceId = input[resourceIdField];
if (!resourceId) {
throw new ContractError('MISSING_RESOURCE_ID', `Field ${resourceIdField} is required`);
}
// 管理者は全リソースにアクセス可能
if (context.user.roles.includes('admin')) {
return true;
}
// リソース所有権チェック
const resource = await getResourceById(resourceId);
if (!resource || resource.userId !== context.user.id) {
throw new ContractError('OWNERSHIP_DENIED',
`User ${context.user.id} does not own resource ${resourceId}`
);
}
return true;
};
}
// 入力検証条件
function validates(schema: z.ZodSchema) {
return (input: unknown): any => {
try {
return schema.parse(input);
} catch (error) {
if (error instanceof z.ZodError) {
const messages = error.issues.map(issue =>
`${issue.path.join('.')}: ${issue.message}`
).join(', ');
throw new ContractError('VALIDATION_FAILED',
`Input validation failed: ${messages}`
);
}
throw error;
}
};
}
// レート制限条件
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;
};
}
// 出力検証条件
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}`
);
}
};
}
// 監査ログ条件
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: sanitizeForAudit(input),
output: sanitizeForAudit(output),
success: true
});
return true;
};
}
// ビジネスルール条件
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;
};
}
4. 実装例:ユーザー管理システム
スキーマ定義
import { z } from 'zod';
// 入力スキーマ
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()
});
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')
});
// 出力スキーマ
const userOutputSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string(),
role: z.string(),
createdAt: z.date(),
updatedAt: z.date()
});
// コンテキスト型
interface AuthContext {
user: {
id: string;
email: string;
roles: string[];
};
session: {
id: string;
expiresAt: Date;
};
}
Presentation Layer
// app/(routes)/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レイヤーでの基本認証チェック
if (!session?.user) {
redirect('/login');
}
if (new Date(session.expires) < new Date()) {
redirect('/login?error=session_expired');
}
return (
<div>
<h1>Profile Settings</h1>
<form action={updateProfile}>
<input name="email" type="email" defaultValue={session.user.email} required />
<input name="name" type="text" defaultValue={session.user.name} required />
<input name="userId" type="hidden" value={session.user.id} />
<button type="submit">Update Profile</button>
</form>
</div>
);
}
Action Layer
// 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';
class UserActions {
@contract({
requires: [
auth('user'),
validates(userUpdateSchema),
rateLimit('updateProfile', 5)
],
ensures: [
returns(userOutputSchema),
auditLog('profile_update')
],
layer: 'action'
})
async updateProfile(
input: z.infer<typeof userUpdateSchema>,
context: AuthContext
): Promise<z.infer<typeof userOutputSchema>> {
return userService.updateUser(input, context);
}
@contract({
requires: [
auth('admin'),
validates(userCreateSchema),
rateLimit('createUser', 10)
],
ensures: [
returns(userOutputSchema),
auditLog('user_creation')
],
layer: 'action'
})
async createUser(
input: z.infer<typeof userCreateSchema>,
context: AuthContext
): Promise<z.infer<typeof userOutputSchema>> {
return userService.createUser(input, context);
}
@contract({
requires: [
auth('user'),
owns('userId'),
rateLimit('deleteProfile', 1)
],
ensures: [
auditLog('profile_deletion')
],
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 エントリーポイント
export const updateProfile = async (formData: FormData) => {
try {
const session = await getServerSession(authOptions);
const context = {
user: session?.user,
session: session
} as AuthContext;
const input = {
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 = {
user: session?.user,
session: session
} as AuthContext;
const input = {
email: formData.get('email') as string,
name: formData.get('name') as string,
role: formData.get('role') as 'user' | 'admin' | 'moderator'
};
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.' };
}
};
Business Layer
// lib/services/users.ts
import { userRepository } from '@/lib/data/users';
class UserService {
@contract({
requires: [
owns('userId'),
businessRule('Cannot change own role', (input, context) => {
return !input.role || input.userId !== context.user.id || input.role === context.user.roles[0];
}),
businessRule('Email change frequency limit', async (input, context) => {
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: [
// 出力されるユーザーIDは入力と同じでなければならない
(input, output) => output.id === input.userId,
// 更新時刻は現在時刻以前でなければならない
(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');
}
// ビジネスロジック実行
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()
})
};
// メール重複チェック
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(), // for ownership tracking
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();
// 型定義
interface UserUpdateInput {
userId: string;
email?: string;
name?: string;
role?: string;
}
interface UserCreateInput {
email: string;
name: string;
role: string;
}
interface User {
id: string;
email: string;
name: string;
role: string;
userId: string;
createdAt: Date;
updatedAt: Date;
emailChangedAt?: Date;
}
Data Layer
// lib/data/users.ts
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
class UserRepository {
@contract({
requires: [
// データアクセス権限の最終チェック
(input, context) => {
return context.user.roles.includes('admin') ||
input.id === context.user.id;
}
],
ensures: [
// データ整合性の最終確認
(output, input) => output.id === input.id,
auditLog('user_data_update')
],
invariants: [
// 保存されたデータの整合性
(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,
// クエリレベルでの最終的な安全装置
...(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;
}
@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;
}
@contract({
requires: [
// 削除権限の最終チェック
(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,
// クエリレベルでの安全装置
...(context.user.roles.includes('admin') ? {} : { id: context.user.id })
}
});
}
// 契約なしのヘルパーメソッド(内部でのみ使用)
async findById(userId: string): Promise<User | null> {
return prisma.user.findUnique({
where: { id: userId }
});
}
async findByEmail(email: string): Promise<User | null> {
return prisma.user.findUnique({
where: { email }
});
}
}
export const userRepository = new UserRepository();
5. 高度な契約パターン
契約の合成と継承
// 共通契約の定義
const baseUserContract = {
requires: [auth('user')],
ensures: [auditLog('user_operation')]
};
const adminUserContract = {
requires: [auth('admin'), rateLimit('admin_operation', 20)],
ensures: [auditLog('admin_operation')]
};
// 契約の合成
function composeContracts(...contracts: Partial<ContractOptions>[]): ContractOptions {
return {
requires: contracts.flatMap(c => c.requires || []),
ensures: contracts.flatMap(c => c.ensures || []),
invariants: contracts.flatMap(c => c.invariants || [])
};
}
// 使用例
@contract(composeContracts(
baseUserContract,
{
requires: [validates(userUpdateSchema), owns('userId')],
invariants: [(input, output) => output.id === input.userId]
}
))
async updateUserProfile(input: UserUpdateInput, context: AuthContext) {
// 実装
}
条件付き契約
// 条件付き契約の実装
function conditionalContract(
condition: (input: any, context: any) => boolean,
whenTrue: ContractOptions,
whenFalse: ContractOptions = {}
) {
return function(target: any, propertyName: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = async function(...args: any[]) {
const [input, context] = args;
const contractToApply = condition(input, context) ? whenTrue : whenFalse;
// 選択された契約を適用
const contractDecorator = contract(contractToApply);
const tempDescriptor = { value: originalMethod };
contractDecorator(target, propertyName, tempDescriptor);
return tempDescriptor.value.apply(this, args);
};
};
}
// 使用例
@conditionalContract(
(input, context) => input.role && input.role !== context.user.roles[0],
// ロール変更時は追加チェック
{ requires: [auth('admin'), businessRule('Role change requires admin', () => true)] },
// 通常の更新
{ requires: [auth('user'), owns('userId')] }
)
async updateUser(input: UserUpdateInput, context: AuthContext) {
// 実装
}
バルクオペレーション契約
// バルクオペレーション用の契約
function bulkContract(itemContract: ContractOptions) {
return {
requires: [
(input: any[], context: AuthContext) => {
if (!Array.isArray(input)) {
throw new ContractError('INVALID_BULK_INPUT', 'Input must be an array');
}
return true;
}
],
ensures: [
async (output: any[], input: any[], context: AuthContext) => {
// 各アイテムに個別の契約を適用
for (let i = 0; i < input.length; i++) {
for (const condition of itemContract.ensures || []) {
await condition(output[i], input[i], context);
}
}
return true;
}
]
};
}
@contract(bulkContract({
requires: [owns('userId')],
ensures: [auditLog('bulk_user_update')]
}))
async bulkUpdateUsers(users: UserUpdateInput[], context: AuthContext): Promise<User[]> {
const results = [];
for (const user of users) {
results.push(await this.updateUser(user, context));
}
return results;
}
6. デバッグとモニタリング
開発環境での契約可視化
class ContractDebugger {
private static contractHistory: Array<{
contractName: string;
layer: string;
timestamp: Date;
input: any;
output?: any;
status: 'success' | 'failure';
error?: any;
}> = [];
static logContractExecution(
contractName: string,
layer: string,
input: any,
output?: any,
error?: any
) {
if (process.env.NODE_ENV === 'development') {
this.contractHistory.push({
contractName,
layer,
timestamp: new Date(),
input: this.sanitizeForLog(input),
output: this.sanitizeForLog(output),
status: error ? 'failure' : 'success',
error
});
// コンソールでリアルタイム表示
const icon = error ? '❌' : '✅';
console.log(`${icon} Contract [${layer}] ${contractName}`, {
input: this.sanitizeForLog(input),
...(output && { output: this.sanitizeForLog(output) }),
...(error && { error: error.message })
});
}
}
static getContractReport(): string {
const successCount = this.contractHistory.filter(h => h.status === 'success').length;
const failureCount = this.contractHistory.filter(h => h.status === 'failure').length;
const layerStats = this.contractHistory.reduce((acc, h) => {
acc[h.layer] = acc[h.layer] || { success: 0, failure: 0 };
acc[h.layer][h.status]++;
return acc;
}, {} as Record<string, { success: number; failure: number }>);
return JSON.stringify({
total: this.contractHistory.length,
success: successCount,
failure: failureCount,
successRate: `${((successCount / this.contractHistory.length) * 100).toFixed(1)}%`,
layerStats
}, null, 2);
}
private static sanitizeForLog(data: any): any {
if (typeof data !== 'object' || data === null) {
return data;
}
const sanitized = { ...data };
// 機密情報を除外
delete sanitized.password;
delete sanitized.token;
delete sanitized.secret;
return sanitized;
}
}
// 開発環境でのグローバル契約モニター
if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
(window as any).__contractDebugger = ContractDebugger;
}
パフォーマンス測定
class ContractPerformanceMonitor {
private static metrics = new Map<string, {
executions: number;
totalTime: number;
failures: number;
avgTime: number;
}>();
static measureContract<T>(
contractName: string,
fn: () => Promise<T> | T
): Promise<T> | T {
const start = performance.now();
const updateMetrics = (success: boolean) => {
const duration = performance.now() - start;
const current = this.metrics.get(contractName) || {
executions: 0,
totalTime: 0,
failures: 0,
avgTime: 0
};
current.executions++;
current.totalTime += duration;
if (!success) current.failures++;
current.avgTime = current.totalTime / current.executions;
this.metrics.set(contractName, current);
};
try {
const result = fn();
if (result instanceof Promise) {
return result
.then(res => {
updateMetrics(true);
return res;
})
.catch(err => {
updateMetrics(false);
throw err;
});
}
updateMetrics(true);
return result;
} catch (error) {
updateMetrics(false);
throw error;
}
}
static getPerformanceReport(): string {
const report = Array.from(this.metrics.entries())
.map(([name, stats]) => ({
contract: name,
executions: stats.executions,
avgTimeMs: parseFloat(stats.avgTime.toFixed(2)),
failureRate: `${((stats.failures / stats.executions) * 100).toFixed(1)}%`,
totalTimeMs: parseFloat(stats.totalTime.toFixed(2))
}))
.sort((a, b) => b.avgTimeMs - a.avgTimeMs);
return JSON.stringify(report, null, 2);
}
}
7. テスト戦略
契約のユニットテスト
describe('Contract System', () => {
describe('Authentication Contract', () => {
it('should pass with valid user and role', async () => {
const context = {
user: { id: 'user-123', roles: ['user'] },
session: { id: 'session-123', expiresAt: new Date(Date.now() + 3600000) }
};
const authCondition = auth('user');
await expect(authCondition({}, context)).resolves.toBe(true);
});
it('should fail with insufficient role', async () => {
const context = {
user: { id: 'user-123', roles: ['user'] },
session: { id: 'session-123', expiresAt: new Date(Date.now() + 3600000) }
};
const authCondition = auth('admin');
await expect(authCondition({}, context))
.rejects
.toThrow('Required role: admin');
});
it('should fail with expired session', async () => {
const context = {
user: { id: 'user-123', roles: ['user'] },
session: { id: 'session-123', expiresAt: new Date(Date.now() - 1000) }
};
const authCondition = auth('user');
await expect(authCondition({}, context))
.rejects
.toThrow('Session has expired');
});
});
describe('Ownership Contract', () => {
beforeEach(() => {
// リソース取得のモック
jest.spyOn(global, 'getResourceById').mockImplementation(async (id) => {
const resources = {
'resource-123': { id: 'resource-123', userId: 'user-123' },
'resource-456': { id: 'resource-456', userId: 'user-456' }
};
return resources[id] || null;
});
});
it('should pass for resource owner', async () => {
const context = {
user: { id: 'user-123', roles: ['user'] }
};
const ownershipCondition = owns('resourceId');
await expect(ownershipCondition({ resourceId: 'resource-123' }, context))
.resolves.toBe(true);
});
it('should pass for admin', async () => {
const context = {
user: { id: 'user-456', roles: ['admin'] }
};
const ownershipCondition = owns('resourceId');
await expect(ownershipCondition({ resourceId: 'resource-123' }, context))
.resolves.toBe(true);
});
it('should fail for non-owner', async () => {
const context = {
user: { id: 'user-456', roles: ['user'] }
};
const ownershipCondition = owns('resourceId');
await expect(ownershipCondition({ resourceId: 'resource-123' }, context))
.rejects
.toThrow('does not own resource');
});
});
});
インテグレーションテスト
describe('User Management Integration', () => {
let mockContext: AuthContext;
let userActions: UserActions;
beforeEach(() => {
mockContext = {
user: { id: 'user-123', email: 'user@example.com', roles: ['user'] },
session: { id: 'session-123', expiresAt: new Date(Date.now() + 3600000) }
};
userActions = new UserActions();
});
describe('updateProfile', () => {
it('should successfully update own profile', async () => {
const input = {
userId: 'user-123',
email: 'newemail@example.com',
name: 'New Name'
};
// モックのセットアップ
jest.spyOn(userService, 'updateUser').mockResolvedValue({
id: 'user-123',
email: 'newemail@example.com',
name: 'New Name',
role: 'user',
createdAt: new Date(),
updatedAt: new Date()
});
const result = await userActions.updateProfile(input, mockContext);
expect(result.email).toBe('newemail@example.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@example.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');
});
});
describe('createUser', () => {
beforeEach(() => {
// 管理者コンテキスト
mockContext.user.roles = ['admin'];
});
it('should successfully create user as admin', async () => {
const input = {
email: 'newuser@example.com',
name: 'New User',
role: 'user' as const
};
jest.spyOn(userService, 'createUser').mockResolvedValue({
id: 'new-user-id',
email: 'newuser@example.com',
name: 'New User',
role: 'user',
createdAt: new Date(),
updatedAt: new Date()
});
const result = await userActions.createUser(input, mockContext);
expect(result.email).toBe('newuser@example.com');
expect(result.role).toBe('user');
});
it('should fail when non-admin tries to create user', async () => {
mockContext.user.roles = ['user'];
const input = {
email: 'newuser@example.com',
name: 'New User',
role: 'user' as const
};
await expect(userActions.createUser(input, mockContext))
.rejects
.toThrow('Required role: admin');
});
});
});
契約モックシステム
class ContractMocker {
private mocks = new Map<string, any>();
mockCondition(conditionName: string, result: any) {
this.mocks.set(conditionName, result);
}
mockAuth(result: boolean | Error) {
this.mockCondition('auth', result);
}
mockOwnership(result: boolean | Error) {
this.mockCondition('owns', result);
}
mockValidation(result: any | Error) {
this.mockCondition('validates', result);
}
applyMocks() {
// 実際の契約条件をモックで置き換え
const originalAuth = global.auth;
global.auth = () => async () => {
const mock = this.mocks.get('auth');
if (mock instanceof Error) throw mock;
return mock ?? true;
};
// テスト後にリストア
return () => {
global.auth = originalAuth;
};
}
}
// テストでの使用例
describe('Contract Mocking', () => {
let contractMocker: ContractMocker;
let restoreMocks: () => void;
beforeEach(() => {
contractMocker = new ContractMocker();
restoreMocks = contractMocker.applyMocks();
});
afterEach(() => {
restoreMocks();
});
it('should handle auth failure gracefully', async () => {
contractMocker.mockAuth(new ContractError('AUTH_FAILED', 'Mock auth failure'));
await expect(userActions.updateProfile(validInput, mockContext))
.rejects
.toThrow('Mock auth failure');
});
});
8. AI-Friendly設計パターン
契約テンプレートシステム
// AIが使いやすい事前定義契約
export const ContractTemplates = {
// 基本的なCRUD操作
userCRUD: (requiredRole: string = 'user') => ({
requires: [
auth(requiredRole),
validates(userUpdateSchema),
owns('userId'),
rateLimit('userCRUD', 10)
],
ensures: [
returns(userOutputSchema),
auditLog('user_crud')
]
}),
// 管理者専用操作
adminOnly: (operation: string) => ({
requires: [
auth('admin'),
rateLimit(`admin_${operation}`, 20)
],
ensures: [
auditLog(`admin_${operation}`)
]
}),
// 公開API(認証不要)
publicAPI: (operation: string) => ({
requires: [
validates(z.any()), // 基本的な入力検証のみ
rateLimit(`public_${operation}`, 100)
],
ensures: [
auditLog(`public_${operation}`)
]
}),
// バッチ処理用
batchOperation: (itemContract: ContractOptions) => ({
requires: [
auth('admin'),
(input: any[]) => {
if (!Array.isArray(input)) {
throw new ContractError('INVALID_BATCH_INPUT', 'Input must be an array');
}
if (input.length > 1000) {
throw new ContractError('BATCH_TOO_LARGE', 'Batch size must be ≤ 1000 items');
}
return true;
}
],
ensures: [
auditLog('batch_operation')
]
})
};
// AIが生成するコード例
class AIGeneratedUserController {
// AI: "ユーザープロフィール更新機能を作って"
@contract(ContractTemplates.userCRUD())
async updateUserProfile(input: UserUpdateInput, context: AuthContext) {
return userService.updateUser(input, context);
}
// AI: "管理者によるユーザー削除機能を作って"
@contract(ContractTemplates.adminOnly('deleteUser'))
async deleteUser(input: { userId: string }, context: AuthContext) {
return userService.deleteUser(input.userId, context);
}
// AI: "公開ユーザー検索APIを作って"
@contract(ContractTemplates.publicAPI('searchUsers'))
async searchUsers(input: { query: string }, context: AuthContext) {
return userService.searchUsers(input.query);
}
}
スマート契約生成
// AIが契約を自動推論するヘルパー
function smartContract(options: {
operation: 'create' | 'read' | 'update' | 'delete';
resource: string;
visibility: 'public' | 'private' | 'admin';
rateLimit?: number;
}) {
const contracts: ContractOptions = {
requires: [],
ensures: [],
invariants: []
};
// 可視性に基づく認証要件
switch (options.visibility) {
case 'public':
// 認証不要
break;
case 'private':
contracts.requires!.push(auth('user'));
if (options.operation !== 'create') {
contracts.requires!.push(owns(`${options.resource}Id`));
}
break;
case 'admin':
contracts.requires!.push(auth('admin'));
break;
}
// 操作に基づく検証要件
if (['create', 'update'].includes(options.operation)) {
// 作成・更新時は入力検証必須
contracts.requires!.push(
validates(getSchemaForResource(options.resource, options.operation))
);
}
// レート制限
if (options.rateLimit) {
contracts.requires!.push(
rateLimit(`${options.operation}_${options.resource}`, options.rateLimit)
);
}
// 監査ログ
contracts.ensures!.push(
auditLog(`${options.operation}_${options.resource}`)
);
// 出力検証
if (['create', 'read', 'update'].includes(options.operation)) {
contracts.ensures!.push(
returns(getSchemaForResource(options.resource, 'output'))
);
}
return contracts;
}
// AIが使用する例
class SmartUserController {
// AI: "ユーザー作成機能"と指定するだけで適切な契約が自動適用
@contract(smartContract({
operation: 'create',
resource: 'user',
visibility: 'admin',
rateLimit: 5
}))
async createUser(input: UserCreateInput, context: AuthContext) {
return userService.createUser(input, context);
}
// AI: "ユーザー更新機能"
@contract(smartContract({
operation: 'update',
resource: 'user',
visibility: 'private',
rateLimit: 10
}))
async updateUser(input: UserUpdateInput, context: AuthContext) {
return userService.updateUser(input, context);
}
}
function getSchemaForResource(resource: string, operation: string): z.ZodSchema {
const schemas = {
user: {
create: userCreateSchema,
update: userUpdateSchema,
output: userOutputSchema
}
// 他のリソースのスキーマも定義
};
return schemas[resource]?.[operation] || z.any();
}
9. 本番環境での運用
エラーハンドリングとフォールバック
// 本番環境用のエラーハンドリング
class ProductionErrorHandler {
static handleContractViolation(error: ContractViolationError): any {
// セキュリティログ記録
this.logSecurityEvent(error);
// レイヤーに応じた適切な応答
switch (error.layer) {
case 'presentation':
return { redirect: '/login' };
case 'action':
return {
success: false,
error: this.getSafeErrorMessage(error.originalError)
};
case 'business':
return {
success: false,
error: 'Permission denied'
};
case 'data':
return {
success: false,
error: 'Operation failed'
};
default:
return {
success: false,
error: 'An error occurred'
};
}
}
private static getSafeErrorMessage(error: any): string {
// 本番環境では詳細なエラー情報を隠す
if (process.env.NODE_ENV === 'production') {
const safeErrors = [
'VALIDATION_FAILED',
'RATE_LIMIT_EXCEEDED',
'AUTHENTICATION_REQUIRED'
];
if (error instanceof ContractError && safeErrors.includes(error.type)) {
return error.message;
}
return 'Invalid request';
}
return error?.message || 'An error occurred';
}
private static logSecurityEvent(error: ContractViolationError) {
// セキュリティ監視システムへの通知
console.error('Security Event:', {
type: 'contract_violation',
layer: error.layer,
contract: error.contractName,
error: error.originalError?.type,
timestamp: new Date(),
// 本番環境では個人情報を除外
...(process.env.NODE_ENV !== 'production' && {
details: error.originalError
})
});
}
}
// グローバルエラーハンドラーへの統合
process.on('unhandledRejection', (reason, promise) => {
if (reason instanceof ContractViolationError) {
ProductionErrorHandler.handleContractViolation(reason);
}
});
パフォーマンス最適化
// 契約の最適化とキャッシング
class OptimizedContractSystem {
private static contractCache = new Map<string, Function>();
private static conditionCache = new Map<string, any>();
// 契約のコンパイルとキャッシング
static compileContract(contractName: string, options: ContractOptions): Function {
if (this.contractCache.has(contractName)) {
return this.contractCache.get(contractName)!;
}
const compiledContract = this.optimizeContract(options);
this.contractCache.set(contractName, compiledContract);
return compiledContract;
}
private static optimizeContract(options: ContractOptions): Function {
// 静的チェックと動的チェックを分離
const staticChecks = this.extractStaticChecks(options.requires || []);
const dynamicChecks = this.extractDynamicChecks(options.requires || []);
return async (input: any, context: any) => {
// 静的チェックは事前実行(コンパイル時)
for (const check of staticChecks) {
check(input, context);
}
// 動的チェックのみ実行時に実行
for (const check of dynamicChecks) {
await check(input, context);
}
};
}
// バリデーションスキーマのキャッシング
static getCachedValidation(schemaKey: string, schema: z.ZodSchema) {
if (!this.conditionCache.has(schemaKey)) {
const compiledSchema = schema.parse.bind(schema);
this.conditionCache.set(schemaKey, compiledSchema);
}
return this.conditionCache.get(schemaKey);
}
private static extractStaticChecks(conditions: any[]): Function[] {
return conditions.filter(c =>
// 静的に評価可能な条件
typeof c === 'function' && c.toString().includes('input.') && !c.toString().includes('await')
);
}
private static extractDynamicChecks(conditions: any[]): Function[] {
return conditions.filter(c =>
// 動的評価が必要な条件
typeof c === 'function' && (c.toString().includes('await') || c.toString().includes('async'))
);
}
}
10. まとめ
このデコレーター契約プログラミングアプローチは、AIコーディング時代の課題を以下のように解決します:
解決される問題
-
認可の混乱:
@contract({ requires: [auth('user'), owns('userId')] })で明確に宣言 - 品質のバラつき: 契約違反は自動的にエラーとして検出
- デバッグ困難: 契約名とレイヤーを含む詳細なエラー情報
- 規約違反: レイヤーごとの契約により責務分離を強制
実現される価値
- 宣言的セキュリティ: 契約がコードの一部として可視化
- AIフレンドリー: テンプレートとスマート生成による学習支援
- 段階的導入: 必要な機能から契約を適用可能
- 本番運用対応: パフォーマンス最適化とエラーハンドリング
適用効果
// Before: AIが生成する危険なコード
export async function updateUser(data: any) {
return db.user.update({ where: { id: data.id }, data });
}
// After: 契約により自動的に安全なコード
@contract({
requires: [auth('user'), validates(userUpdateSchema), owns('userId')],
ensures: [returns(userOutputSchema), auditLog('user_update')]
})
export async function updateUser(data: UserUpdateInput, context: AuthContext) {
return db.user.update({ where: { id: data.userId }, data });
}
AIが生成するコードに宣言的な契約を自動適用することで、開発速度と品質の両立を実現する実用的なアプローチです。デコレーターによる簡潔な記述により、複雑な契約システムを隠蔽しつつ、強力な型安全性とランタイム検証を提供します。
Discussion