Technical proposal · for Joy dev team · 2026-05-27

Shared Validation Schema — single source of truth FE↔BE

Tách validation logic ra một package Zod schema thuần, dùng chung cho frontend và REST v2 backend — giữ nguyên message i18n và không đụng tới component frontend.

Status: Proposal (chưa code) Tech: Zod v4 Rollout: strangler fig, 3 phase

1Tóm tắt

Validation hiện chỉ sống ở frontend (packages/assets/src/config/validations/), viết theo rules-object của @avada/react-form-hook và dính chặt i18n. Backend REST v2 phải tự viết lại → drift, dễ sót, đã sinh ra bug bảo mật/data-integrity.

Đề xuất: đưa logic validation về một package shared-schemas dạng Zod schema thuần (không React, không i18n instance), để cả FE adapter lẫn BE handler import cùng một nguồn. Một adapter mỏng (~50 dòng) giữ cho frontend hoạt động y nguyên.

2Vấn đề

3Giải pháp & data flow

Schema là nguồn duy nhất; mỗi phía có một presenter riêng (FE adapter → form hook rules; BE → HTTP errors).

Zod schema thuần
shared-schemas/ · 0% React · 0% i18n
FE adapter
zodToFormHookRules() → rules object cũ
BE handler
schema.safeParse(req.body)

3 ràng buộc đã chốt

Ràng buộcCách đạt được
Logic dùng chung FE+BESchema thuần ở shared-schemas, cả 2 phía import cùng nguồn.
Message i18n giữ nguyênSchema chỉ đánh dấu key qua params.i18n; translate ở presenter; giữ format Validations.<tab>.<key> — file dịch không đụng.
Frontend zero-touchFile *Rules.js giữ export signature, chỉ rewrite internal. Component + useFormHook không đổi 1 dòng.

Quyết định thiết kế

Quyết địnhPhương án chọnVì sao
Validation libraryZod v4Built-in vi locale + 40 langs; không add lib thứ 3; bundle FE +~14kB.
Migration styleStrangler fig + anti-corruption layerAdapter giữ react-form-hook → migrate module-by-module, không big-bang, rollback per-module.
BE error responseStructured {code, path, params}Client tự dịch; không leak Zod internals; partner API dễ consume. (cần team confirm — xem mục 7)
i18n ownershipPresenter layer, không phải schemaSchema thuần reuse được BE; message vẫn theo locale của shop.

4Code sketch

Schema thuần (shared package)

// packages/shared-schemas/src/schemas/membershipPlan.js
export const planSchema = z.object({
  title: z.string().min(1, { params: { i18n: { key: 'titleRequired' } } }),
  tiers: z.array(tierSchema).superRefine((tiers, ctx) => {
    // tier overlap → ctx.addIssue({ path: [i, 'fromOrder'], params: {...} })
  })
})

Adapter Zod → react-form-hook rules (~50 dòng, shared)

export const zodToFormHookRules = (schema, data, i18n, opts) => {
  const result = schema.safeParse(data)
  if (result.success) return {}
  // map issues → { 'plan/0/title': { value, validation: 'custom', message } }
}

File rules cũ — internal rewrite, signature GIỮ NGUYÊN

// packages/assets/src/config/validations/membershipPlanRules.js
import { plansListSchema } from '@avada/shared-schemas/schemas/membershipPlan'
export const membershipPlanRules = (plans, i18n) =>
  zodToFormHookRules(plansListSchema, plans, i18n, {
    tabKey: 'membershipPlanRules', keyPrefix: 'plan/'
  })
// → component import path cũ, gọi cùng cách, nhận shape errors cũ.

Backend REST v2 — dùng schema trực tiếp

const result = plansListSchema.safeParse(req.body.plans)
if (!result.success) return res.status(422).json({
  errors: result.error.issues  // { code, path, params } — client tự dịch
})

5Cross-field & ràng buộc phức tạp

Mọi pattern đều emit issue có path[] → adapter convert đồng nhất, không cần thay đổi.

Pattern JoyCông cụ Zod
Tier overlap / array invariant.superRefine() với path: [i, field]
Variant theo event type (program.js)z.discriminatedUnion('event', [...]) + exhaustiveness check
Composite value (val + unit)discriminated union / superRefine
Conditional depends().optional() + superRefine, hoặc discriminated union
External context (shop plan, feature flag)Schema factory makeXSchema(ctx) — build per-request
Async (uniqueness DB).refine(async) + parseAsync — BE-only layer

6Rollout (strangler fig, module-by-module)

PhaseScopeEffort
0 — POCTạo workspace + migrate appleWalletRules.js (30 dòng); snapshot test error message giống hệt; verify BE chạy cùng schema.~1 ngày
1 — Membership PlanSchema phức tạp nhất (tier overlap, prepaid/postpaid); wire BE REST v2 cùng schema; test parity payload sai.2-3 ngày
2 — Mở rộng (parallel)notificationRules, emailReportRules, referralProgram, tierProgram; program.js (2165 dòng, 30+ variant) chia subschema.1-2 ngày/module · program.js ~5-7 ngày

Mỗi phase ship + rollback độc lập per-module. Không big-bang rewrite.

7Open questions — cần team chốt