Skip to main content
If you directly modify code auto-generated by Sonamu, it will be overwritten on the next regeneration. This document explains how to safely customize generated code.

Basic Principles

Generated Files are Read-Only

Never modify *.generated.* filesChanges lost on auto-regeneration

Customize Through Extension

Wrap or extend in separate filesSafe and maintainable

Control at Source

Adjust in Entity, Model, TypesPreserved across regenerations

Leverage Templates

Control generation with custom templatesReflect project requirements

Modifiable vs Non-Modifiable Files

❌ Non-Modifiable Files

Never modify these files. They are overwritten on regeneration.
File PatternRegeneration TriggerExample
*.generated.tsEntity/Model changessonamu.generated.ts
*.generated.sso.tsEntity changessonamu.generated.sso.ts
*.generated.tsxModel changesentry-server.generated.tsx
*.generated.httpModel changessonamu.generated.http
services.generated.tsModel changesservices.generated.ts
queries.generated.tsModel changesqueries.generated.ts
sd.generated.tsEntity/i18n changessd.generated.ts

✅ Modifiable Files

These files can be modified after initial generation.
File PatternRegenerationExample
{entity}.types.tsNot after first creationuser.types.ts
{entity}.model.tsNot after scaffolduser.model.ts
{Entity}List.tsxNot after scaffoldUserList.tsx
{Entity}Form.tsxNot after scaffoldUserForm.tsx
Custom filesNever regenerateduser.custom.ts

Customizing API Clients

Don’t modify services.generated.ts directly—wrap it instead.

❌ Wrong Approach

services.generated.ts
// ❌ Don't modify directly!
export async function findUserById(id: number): Promise<User> {
  // Adding custom logic
  console.log("Finding user:", id);
  
  const { data } = await axios.get("/user/findById", { params: { id } });
  return data;
}
// Will be overwritten on next Model change 💥

✅ Correct Approach 1: Wrapper Functions

import { findUserById as _findUserById } from "../services.generated";

// Extend with wrapper function
export async function findUserById(id: number) {
  console.log("Finding user:", id);
  
  // Call original
  const user = await _findUserById(id);
  
  // Additional processing
  if (!user.email_verified) {
    throw new Error("Email not verified");
  }
  
  return user;
}

// Re-export other functions as-is
export {
  findManyUsers,
  saveUser,
  deleteUser,
} from "../services.generated";
Benefits:
  • No modification to generated files
  • Reuses original functions
  • Maintains type safety

✅ Correct Approach 2: Axios Interceptor

services/axios-config.ts (create new)
import axios from "axios";

// Request Interceptor
axios.interceptors.request.use((config) => {
  // Common logging
  console.log(`API Request: ${config.method} ${config.url}`);
  
  // Add auth token
  const token = localStorage.getItem("token");
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  
  return config;
});

// Response Interceptor
axios.interceptors.response.use(
  (response) => response,
  (error) => {
    // Common error handling
    if (error.response?.status === 401) {
      // Logout
      localStorage.removeItem("token");
      window.location.href = "/login";
    }
    return Promise.reject(error);
  }
);
Benefits:
  • Automatically applies to all APIs
  • Eliminates duplicate code
  • Centralized configuration

✅ Correct Approach 3: TanStack Query Customization

hooks/useUserQuery.ts (create new)
import { useQuery } from "@tanstack/react-query";
import { findUserById } from "@/services/services.generated";

// Custom Hook
export function useUserById(id: number, options?: UseQueryOptions) {
  return useQuery({
    queryKey: ["user", id],
    queryFn: () => findUserById(id),
    // Additional options
    staleTime: 5 * 60 * 1000,  // 5 minutes
    cacheTime: 10 * 60 * 1000, // 10 minutes
    retry: 3,
    // Merge custom options
    ...options,
  });
}

// Usage
function UserProfile({ userId }) {
  const { data, isLoading } = useUserById(userId, {
    // Per-component options
    enabled: userId > 0,
  });
}
Benefits:
  • Centralized management of common options
  • Per-component customization possible
  • Leverages TanStack Query features

Customizing Types

{entity}.types.ts is not regenerated, so you can modify it freely.

Adding Custom Types

api/src/application/user/user.types.ts
import { z } from "zod";

// Auto-generated types (modifiable)
export type User = {
  id: number;
  email: string;
  username: string;
};

export const User = z.object({
  id: z.number(),
  email: z.string(),
  username: z.string(),
});

// ✅ Add custom types (safe)
export const UserLoginParams = z.object({
  email: z.string().email(),
  password: z.string().min(8),
  rememberMe: z.boolean().optional(),
});
export type UserLoginParams = z.infer<typeof UserLoginParams>;

export const UserProfileUpdateParams = User.pick({
  username: true,
}).extend({
  bio: z.string().optional(),
  avatar_url: z.string().url().optional(),
});
export type UserProfileUpdateParams = z.infer<typeof UserProfileUpdateParams>;

// Domain-specific types
export type UserWithStats = User & {
  post_count: number;
  follower_count: number;
};

// Enum extension
export const ExtendedUserRole = z.enum([
  ...User.shape.role.options,  // Existing roles
  "moderator",                 // Addition
]);
Note: {entity}.types.ts is copied to targets, so changes are automatically synchronized.

Enhanced Validation

user.types.ts
// Complex Validation
export const UserSaveParams = z.object({
  email: z.string()
    .email("Please enter a valid email")
    .refine(
      (email) => !email.endsWith("@temp.com"),
      "Temporary emails are not allowed"
    ),
    
  username: z.string()
    .min(2, "Minimum 2 characters")
    .max(20, "Maximum 20 characters")
    .regex(/^[a-zA-Z0-9_]+$/, "Only letters, numbers, and underscores allowed"),
    
  password: z.string()
    .min(8)
    .regex(/[A-Z]/, "Must include uppercase letter")
    .regex(/[0-9]/, "Must include number")
    .regex(/[!@#$%^&*]/, "Must include special character"),
    
  age: z.number()
    .int()
    .min(18, "Must be 18 or older to register")
    .max(100),
}).refine(
  (data) => data.password !== data.username,
  {
    message: "Password must be different from username",
    path: ["password"],
  }
);

Customizing Models

Model files can be freely modified after scaffolding.

Adding Methods

api/src/application/user/user.model.ts
class UserModelClass extends BaseModelClass {
  // Scaffold-generated method (modifiable)
  @api({ httpMethod: "GET" })
  async findById(id: number): Promise<User> {
    // ...
  }

  // ✅ Add custom methods (safe)
  @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
  async login(params: UserLoginParams): Promise<{ user: User; token: string }> {
    const user = await this.puri()
      .where("email", params.email)
      .first();
      
    if (!user) {
      throw new UnauthorizedException("Email or password does not match");
    }
    
    const isValid = await bcrypt.compare(params.password, user.password);
    if (!isValid) {
      throw new UnauthorizedException("Email or password does not match");
    }
    
    const token = jwt.sign({ userId: user.id }, SECRET_KEY);
    return { user, token };
  }

  @api({ httpMethod: "GET", guards: ["user"] })
  async me(): Promise<User | null> {
    const context = Sonamu.getContext();
    if (!context.user) return null;
    
    return this.findById("A", context.user.id);
  }
  
  // Internal helper method (no @api)
  async findByEmail(email: string): Promise<User | null> {
    return this.puri()
      .where("email", email)
      .first();
  }
}
Added APIs are auto-generated:
  • login(), me() functions added to services.generated.ts
  • Test cases added to sonamu.generated.http

Separating Helper Methods

api/src/application/user/user.helpers.ts (create new)
import bcrypt from "bcrypt";
import jwt from "jsonwebtoken";

// Password-related
export async function hashPassword(password: string): Promise<string> {
  return bcrypt.hash(password, 10);
}

export async function verifyPassword(
  password: string,
  hash: string
): Promise<boolean> {
  return bcrypt.compare(password, hash);
}

// Token-related
export function generateToken(userId: number): string {
  return jwt.sign({ userId }, process.env.JWT_SECRET!, {
    expiresIn: "7d",
  });
}

export function verifyToken(token: string): { userId: number } {
  return jwt.verify(token, process.env.JWT_SECRET!) as { userId: number };
}
Usage in user.model.ts
import { hashPassword, verifyPassword, generateToken } from "./user.helpers";

class UserModelClass extends BaseModelClass {
  @api({ httpMethod: "POST" })
  async register(params: UserRegisterParams) {
    // Use helper
    const hashedPassword = await hashPassword(params.password);
    
    const [userId] = await this.save([{
      ...params,
      password: hashedPassword,
    }]);
    
    const token = generateToken(userId);
    return { userId, token };
  }
}

Customizing React Components

Components generated via scaffold can be freely modified.

Customizing Form Components

web/src/pages/user/UserForm.tsx
// Customize after scaffold generation
import { useSaveUser } from "@/services/services.generated";
import { UserSaveParams } from "@/services/user/user.types";

export function UserForm({ userId }: { userId?: number }) {
  const { data: user } = useUserById(userId);
  const { mutate: save, isPending } = useSaveUser();
  
  // ✅ Custom validation
  const [errors, setErrors] = useState<Record<string, string>>({});
  
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    
    // ✅ Client-side validation
    const email = formData.get("email") as string;
    if (!email.includes("@")) {
      setErrors({ email: "Please enter a valid email" });
      return;
    }
    
    // Parse with Zod
    const result = UserSaveParams.safeParse({
      email,
      username: formData.get("username"),
    });
    
    if (!result.success) {
      // Display Zod errors
      setErrors(result.error.flatten().fieldErrors);
      return;
    }
    
    save([result.data], {
      onSuccess: () => {
        alert("Saved successfully");
        setErrors({});
      },
      onError: (error) => {
        alert(error.message);
      },
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <input
          name="email"
          type="email"
          defaultValue={user?.email}
          required
        />
        {errors.email && <span className="error">{errors.email}</span>}
      </div>
      
      <div>
        <input
          name="username"
          defaultValue={user?.username}
          required
        />
        {errors.username && <span className="error">{errors.username}</span>}
      </div>
      
      <button type="submit" disabled={isPending}>
        {isPending ? "Saving..." : "Save"}
      </button>
    </form>
  );
}

Customizing List Components

web/src/pages/user/UserList.tsx
import { useUsers } from "@/services/services.generated";
import { UserSearchInput } from "./UserSearchInput";

export function UserList() {
  const [params, setParams] = useState({
    num: 20,
    page: 1,
    search: "email" as const,
    keyword: "",
  });
  
  const { data, isLoading } = useUsers(params);

  // ✅ Add custom features
  const handleExport = () => {
    if (!data?.rows) return;
    
    const csv = [
      ["ID", "Email", "Username"],
      ...data.rows.map(u => [u.id, u.email, u.username]),
    ].map(row => row.join(",")).join("\n");
    
    const blob = new Blob([csv], { type: "text/csv" });
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = "users.csv";
    a.click();
  };

  return (
    <div>
      <div className="toolbar">
        <UserSearchInput
          value={params.search}
          onChange={(search) => setParams({ ...params, search })}
        />
        <input
          type="text"
          value={params.keyword}
          onChange={(e) => setParams({ ...params, keyword: e.target.value })}
        />
        <button onClick={handleExport}>Export</button>
      </div>
      
      <table>
        {/* ... */}
      </table>
    </div>
  );
}

Customizing Enums

To add or modify enums, adjust them in the Entity.

Modifying Enums in Entity

{
  "enums": {
    "UserRole": {
      "admin": "Administrator",
      "moderator": "Moderator",      // ✅ Added
      "normal": "Normal User",
      "guest": "Guest"               // ✅ Added
    }
  }
}

Extending Enums (Separate File)

user.types.ts
import { UserRole as _UserRole } from "../sonamu.generated";

// ✅ Extend enum
export const ExtendedUserRole = z.enum([
  ..._UserRole.options,
  "suspended",  // Suspended
  "deleted",    // Deleted
]);
export type ExtendedUserRole = z.infer<typeof ExtendedUserRole>;

// Custom labels
export function extendedUserRoleLabel(role: ExtendedUserRole): string {
  if (role === "suspended") return "Suspended";
  if (role === "deleted") return "Deleted";
  
  // Use existing labels
  return userRoleLabel(role as UserRole);
}

Customizing Subsets

To modify subsets, adjust them in the Entity.

Adding/Removing Subset Fields

{
  "subsets": {
    "A": [
      "id",
      "email",
      "username",
      "role",
      "created_at"    // ✅ Added
    ],
    "P": [
      "id",
      "email",
      "employee.id",
      "employee.department.name"
    ],
    "SS": [
      "id",
      "email"
    ],
    "Public": [      // ✅ New Subset added
      "id",
      "username"
    ]
  }
}

Next Steps