Skip to content

Latest commit

 

History

History
233 lines (175 loc) · 6.27 KB

ARCHITECTURE.md

File metadata and controls

233 lines (175 loc) · 6.27 KB

Architecture: Single Source of Truth with Zod

Overview

This project demonstrates a clean architecture where Zod schemas are the single source of truth for both runtime validation and API documentation. There is zero duplication between validation logic and OpenAPI specifications.

Core Principle

Shared Package (Zod Schemas with OpenAPI metadata)
            ↓
    ┌───────┴───────┐
    ↓               ↓
Backend          Frontend
(validation)     (types)
    ↓
OpenAPI Spec
(documentation)

How It Works

1. Schemas Define Everything

In the shared/ package, we define schemas once with both validation rules and OpenAPI metadata:

// shared/src/schemas/user.schema.ts
import { z } from 'zod';
import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';

extendZodWithOpenApi(z);

export const UserSchema = z.object({
  id: z.string().uuid().openapi({ 
    description: 'Unique user identifier', 
    example: '123e4567-e89b-12d3-a456-426614174000' 
  }),
  name: z.string().min(1).openapi({ 
    description: 'User full name', 
    example: 'John Doe' 
  }),
  email: z.string().email().openapi({ 
    description: 'User email address', 
    example: 'john.doe@example.com' 
  }),
  createdAt: z.string().datetime().openapi({ 
    description: 'ISO 8601 timestamp', 
    example: '2024-01-15T10:30:00Z' 
  })
}).openapi('User', { description: 'A user in the system' });

2. Backend Uses Schemas for Validation

// src/routes/api/users/+server.ts
import { CreateUserSchema } from '@zod-schema-example/shared';

export const POST: RequestHandler = async ({ request }) => {
  const body = await request.json();
  // Validates using the exact same schema
  const validatedData = CreateUserSchema.parse(body);
  // ...
};

3. Frontend Uses Inferred Types

// src/routes/+page.svelte
import type { User, CreateUser } from '@zod-schema-example/shared';

let users = $state<User[]>([]);
let formData = $state<CreateUser>({ name: '', email: '' });

4. OpenAPI Generation is Trivial

// scripts/generate-openapi.ts
import { UserSchema, CreateUserSchema, UpdateUserSchema } from '@zod-schema-example/shared';

// Just register them - no duplication!
registry.register('User', UserSchema);
registry.register('CreateUser', CreateUserSchema);
registry.register('UpdateUser', UpdateUserSchema);

Key Benefits

✅ True Single Source of Truth

  • Validation rules defined once in Zod schemas
  • OpenAPI metadata co-located with validation
  • Types automatically inferred from schemas

✅ No Duplication

  • No separate type definitions
  • No manual OpenAPI schema writing
  • No drift between validation and documentation

✅ Compile-Time Safety

  • TypeScript catches type errors
  • Changes to schemas propagate automatically
  • Refactoring is safe and easy

✅ Runtime Safety

  • All API inputs are validated
  • Schema validation catches bad data
  • Type-safe at both compile and runtime

Adding New Schemas

To add a new entity (e.g., Post):

Step 1: Define the schema in shared/src/schemas/post.schema.ts:

import { z } from 'zod';
import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';

extendZodWithOpenApi(z);

export const PostSchema = z.object({
  id: z.string().uuid().openapi({ description: 'Post ID' }),
  title: z.string().min(1).openapi({ description: 'Post title' }),
  content: z.string().openapi({ description: 'Post content' }),
  authorId: z.string().uuid().openapi({ description: 'Author ID' })
}).openapi('Post');

export type Post = z.infer<typeof PostSchema>;

Step 2: Export it:

// shared/src/schemas/index.ts
export * from './user.schema';
export * from './post.schema';

Step 3: Build the shared package:

npm run build:shared

Step 4: Use it everywhere:

// Backend
import { PostSchema } from '@zod-schema-example/shared';
const post = PostSchema.parse(data);

// Frontend
import type { Post } from '@zod-schema-example/shared';
const posts: Post[] = [];

// OpenAPI
registry.register('Post', PostSchema);

Comparison to Traditional Approaches

❌ Traditional Approach

1. Define types in TypeScript
2. Manually write validation logic
3. Separately define OpenAPI schemas
4. Keep all three in sync (hope for the best!)

✅ This Approach

1. Define Zod schema with OpenAPI metadata
2. Done! Everything else is derived automatically

Technical Details

Shared Package Structure

shared/
├── src/
│   ├── schemas/          # Zod schemas with OpenAPI metadata
│   │   ├── user.schema.ts
│   │   └── index.ts
│   └── index.ts
├── dist/                 # Compiled output
│   ├── *.js              # JavaScript for runtime
│   ├── *.d.ts            # TypeScript declarations
│   └── *.map             # Source maps
├── package.json
└── tsconfig.json

Dependencies

  • shared package: zod + @asteasolutions/zod-to-openapi
  • main app: imports from @zod-schema-example/shared

Build Process

  1. npm run build:shared - Compiles shared package (TypeScript → JavaScript + .d.ts)
  2. npm run generate:openapi - Generates OpenAPI spec from schemas
  3. npm run build - Builds the SvelteKit app

Why This Works

  1. Zod provides runtime validation - catches bad data at runtime
  2. TypeScript infers types from Zod - compile-time safety
  3. OpenAPI metadata is additive - doesn't change validation behavior
  4. Package compilation preserves metadata - OpenAPI info survives the build process
  5. Single import point - all consumers get the same schemas

Limitations & Considerations

  • The shared package depends on @asteasolutions/zod-to-openapi, making it slightly heavier
  • OpenAPI metadata is verbose but explicit
  • Schema changes require rebuilding the shared package

Summary

This architecture achieves the goal of a true single source of truth where:

  • Validation logic lives in one place (Zod schemas)
  • Documentation lives in the same place (OpenAPI metadata)
  • Types are derived automatically (TypeScript inference)
  • The validation used at runtime is exactly what's documented in OpenAPI

No duplication. No drift. One source of truth.