返回 Skill 列表
extension
分类: 开发与工程无需 API Key

dto-sync-patterns

前端和后端之间同步DTO的模式。涵盖共享类型、代码生成以及验证同步。使用场景:当用户询问“DTO同步”、“共享类型”、“前端后端类型”、“类型一致性”、“API模型”时 不要用于:类型生成工具 - 请使用`type-generation`技能,验证规则 - 请使用验证相关的技能

person作者: jakexiaohubgithub

DTO Sync Patterns - Quick Reference

When NOT to Use This Skill

  • Type generation setup - Use type-generation skill
  • Validation implementation - Use language-specific validation skills
  • API contract validation - Use openapi-contract skill

Sync Strategy Overview

┌─────────────────────────────────────────────────────────────────────┐
│                        DTO SYNC STRATEGIES                          │
├─────────────────────────────────────────────────────────────────────┤
│                                                                      │
│  1. Schema-First (Recommended)                                       │
│     ┌──────────────┐                                                │
│     │ OpenAPI Spec │───→ Generate Backend DTOs                      │
│     │ (Source)     │───→ Generate Frontend Types                    │
│     └──────────────┘                                                │
│                                                                      │
│  2. Backend-First                                                    │
│     ┌──────────────┐      ┌──────────────┐                         │
│     │ Backend DTOs │───→  │ OpenAPI Spec │───→ Frontend Types      │
│     │ (Source)     │      │ (Generated)  │                         │
│     └──────────────┘      └──────────────┘                         │
│                                                                      │
│  3. Shared Package (Monorepo)                                        │
│     ┌──────────────┐                                                │
│     │ @shared/types│───→ Backend imports                            │
│     │ (TypeScript) │───→ Frontend imports                           │
│     └──────────────┘                                                │
│                                                                      │
└─────────────────────────────────────────────────────────────────────┘

Pattern 1: Schema-First

OpenAPI as Source of Truth

# openapi.yaml - Single source of truth
components:
  schemas:
    CreateUserRequest:
      type: object
      required:
        - email
        - name
      properties:
        email:
          type: string
          format: email
          maxLength: 255
        name:
          type: string
          minLength: 2
          maxLength: 100
        age:
          type: integer
          minimum: 0
          maximum: 150

    User:
      type: object
      properties:
        id:
          type: string
          format: uuid
        email:
          type: string
        name:
          type: string
        age:
          type: integer
        createdAt:
          type: string
          format: date-time

Generate for Backend (Java)

# Generate Java DTOs from OpenAPI
npx @openapitools/openapi-generator-cli generate \
  -i openapi.yaml \
  -g spring \
  -o generated/java \
  --additional-properties=useJakartaEe=true
// Generated: CreateUserRequest.java
@Generated
public class CreateUserRequest {
    @NotNull
    @Email
    @Size(max = 255)
    private String email;

    @NotNull
    @Size(min = 2, max = 100)
    private String name;

    @Min(0)
    @Max(150)
    private Integer age;

    // getters, setters...
}

Generate for Frontend (TypeScript)

# Generate TypeScript types from OpenAPI
npx openapi-typescript openapi.yaml -o src/api/types.ts
// Generated: types.ts
export interface components {
  schemas: {
    CreateUserRequest: {
      email: string;
      name: string;
      age?: number;
    };
    User: {
      id?: string;
      email?: string;
      name?: string;
      age?: number;
      createdAt?: string;
    };
  };
}

Pattern 2: Backend-First

Backend Generates OpenAPI

// Spring Boot with springdoc-openapi
@Schema(description = "Request to create a new user")
public record CreateUserRequest(
    @Schema(description = "User email", example = "john@example.com")
    @NotNull
    @Email
    @Size(max = 255)
    String email,

    @Schema(description = "User name", example = "John Doe")
    @NotNull
    @Size(min = 2, max = 100)
    String name,

    @Schema(description = "User age", minimum = "0", maximum = "150")
    @Min(0)
    @Max(150)
    Integer age
) {}
# Export OpenAPI spec from running backend
curl http://localhost:8080/v3/api-docs > openapi.json

# Generate frontend types
npx openapi-typescript openapi.json -o src/api/types.ts

NestJS with Swagger

// NestJS DTO with decorators
@Schema({ description: 'Request to create a new user' })
export class CreateUserDto {
  @ApiProperty({ example: 'john@example.com' })
  @IsEmail()
  @MaxLength(255)
  email: string;

  @ApiProperty({ example: 'John Doe' })
  @IsString()
  @Length(2, 100)
  name: string;

  @ApiPropertyOptional({ minimum: 0, maximum: 150 })
  @IsOptional()
  @IsInt()
  @Min(0)
  @Max(150)
  age?: number;
}
# Export from NestJS
# (requires @nestjs/swagger setup)
curl http://localhost:3000/api-json > openapi.json

Pattern 3: Shared Package (Monorepo)

Project Structure

monorepo/
├── packages/
│   ├── shared/
│   │   ├── package.json
│   │   └── src/
│   │       ├── types/
│   │       │   ├── user.ts
│   │       │   └── index.ts
│   │       └── validation/
│   │           ├── user.ts
│   │           └── index.ts
│   ├── frontend/
│   │   └── package.json  # depends on @shared
│   └── backend/
│       └── package.json  # depends on @shared
└── package.json

Shared Types

// packages/shared/src/types/user.ts
export interface User {
  id: string;
  email: string;
  name: string;
  age?: number;
  createdAt: Date;
}

export interface CreateUserRequest {
  email: string;
  name: string;
  age?: number;
}

export interface UpdateUserRequest {
  name?: string;
  age?: number;
}

// Type guards
export function isUser(obj: unknown): obj is User {
  return (
    typeof obj === 'object' &&
    obj !== null &&
    'id' in obj &&
    'email' in obj
  );
}

Shared Validation (Zod)

// packages/shared/src/validation/user.ts
import { z } from 'zod';

export const CreateUserSchema = z.object({
  email: z.string().email().max(255),
  name: z.string().min(2).max(100),
  age: z.number().int().min(0).max(150).optional(),
});

export const UpdateUserSchema = z.object({
  name: z.string().min(2).max(100).optional(),
  age: z.number().int().min(0).max(150).optional(),
});

// Infer types from schemas
export type CreateUserRequest = z.infer<typeof CreateUserSchema>;
export type UpdateUserRequest = z.infer<typeof UpdateUserSchema>;

Frontend Usage

// packages/frontend/src/api/users.ts
import type { User, CreateUserRequest } from '@shared/types';
import { CreateUserSchema } from '@shared/validation';

async function createUser(data: CreateUserRequest): Promise<User> {
  // Validate before sending
  const validated = CreateUserSchema.parse(data);

  const response = await fetch('/api/users', {
    method: 'POST',
    body: JSON.stringify(validated),
  });

  return response.json();
}

Backend Usage (Node.js)

// packages/backend/src/routes/users.ts
import type { CreateUserRequest } from '@shared/types';
import { CreateUserSchema } from '@shared/validation';

app.post('/api/users', async (req, res) => {
  // Same validation as frontend
  const result = CreateUserSchema.safeParse(req.body);

  if (!result.success) {
    return res.status(400).json({
      code: 'VALIDATION_ERROR',
      details: result.error.issues,
    });
  }

  const user = await userService.create(result.data);
  res.json(user);
});

Validation Sync

Zod (TypeScript Both Ends)

// Shared schema
const UserSchema = z.object({
  email: z.string().email(),
  name: z.string().min(2).max(100),
});

// Frontend: Form validation
const form = useForm({
  resolver: zodResolver(UserSchema),
});

// Backend: Request validation
app.post('/users', (req, res) => {
  const result = UserSchema.safeParse(req.body);
});

class-validator (NestJS) ↔ Zod (Frontend)

// Backend: class-validator
class CreateUserDto {
  @IsEmail()
  @MaxLength(255)
  email: string;

  @IsString()
  @Length(2, 100)
  name: string;
}

// Frontend: Equivalent Zod schema
const CreateUserSchema = z.object({
  email: z.string().email().max(255),
  name: z.string().min(2).max(100),
});

Java Bean Validation ↔ Zod (Frontend)

// Backend: Jakarta validation
public record CreateUserRequest(
    @NotNull @Email @Size(max = 255) String email,
    @NotNull @Size(min = 2, max = 100) String name
) {}
// Frontend: Equivalent Zod
const CreateUserSchema = z.object({
  email: z.string().email().max(255),
  name: z.string().min(2).max(100),
});

Transformation Patterns

Request Transformation

// Frontend form data → API request
interface FormData {
  firstName: string;
  lastName: string;
  birthDate: Date;
}

interface CreateUserRequest {
  name: string;  // Concatenated
  age: number;   // Calculated
}

function toCreateUserRequest(form: FormData): CreateUserRequest {
  const age = calculateAge(form.birthDate);
  return {
    name: `${form.firstName} ${form.lastName}`,
    age,
  };
}

Response Transformation

// API response → Frontend model
interface UserResponse {
  id: string;
  created_at: string;  // snake_case
  full_name: string;
}

interface User {
  id: string;
  createdAt: Date;     // camelCase
  fullName: string;
}

function toUser(response: UserResponse): User {
  return {
    id: response.id,
    createdAt: new Date(response.created_at),
    fullName: response.full_name,
  };
}

Automatic Case Conversion

import camelcaseKeys from 'camelcase-keys';
import snakecaseKeys from 'snakecase-keys';

// Axios interceptor
axios.interceptors.request.use((config) => {
  if (config.data) {
    config.data = snakecaseKeys(config.data, { deep: true });
  }
  return config;
});

axios.interceptors.response.use((response) => {
  if (response.data) {
    response.data = camelcaseKeys(response.data, { deep: true });
  }
  return response;
});

Validation Sync Report

## DTO Sync Validation Report

### CreateUserRequest
| Field | Backend | Frontend | Status |
|-------|---------|----------|--------|
| email | @Email @Size(max=255) | z.string().email().max(255) | OK |
| name | @Size(min=2, max=100) | z.string().min(2).max(100) | OK |
| age | @Min(0) @Max(150) | z.number().min(0).max(150) | OK |

### User Response
| Field | Backend | Frontend | Status |
|-------|---------|----------|--------|
| id | UUID | string | OK |
| createdAt | Instant | Date | OK (transformed) |
| name | String | string | OK |

### Recommendations
1. All validations are in sync
2. Date transformation handled in response interceptor

Anti-Patterns

| Anti-Pattern | Why It's Bad | Correct Approach | |--------------|--------------|------------------| | Manual type copying | Drift over time | Generate from schema | | Different validation rules | Inconsistent errors | Share validation logic | | No transformation layer | Tight coupling | Add DTOs for each layer | | Ignoring optionality | Runtime errors | Match required/optional exactly | | snake_case/camelCase mismatch | Confusion | Auto-transform consistently |

Quick Troubleshooting

| Issue | Likely Cause | Solution | |-------|--------------|----------| | Type mismatch | Manual sync drift | Regenerate from schema | | Validation passes frontend, fails backend | Different rules | Align validation schemas | | Missing required field | Optionality mismatch | Check OpenAPI required array | | Date parsing error | String vs Date | Add transformation | | Case mismatch | snake_case vs camelCase | Add case conversion |

Related Skills