Loading...
Loading...
Loading...
- All forms **MUST** have a shared Zod schema for client and server validation.
---
trigger: always_on
---
# Forms & React Hook Form Rules
## 1. Shared Zod Schema (Single Source of Truth)
### Schema Location
- All forms **MUST** have a shared Zod schema for client and server validation.
- Schema should be placed in `domain/<feature>/schemas/<form-name>.schema.ts`.
- Schema defines **only data structure**: types, required, min/max length, pattern, format.
- **DO NOT place in schema**:
- Business logic
- Authorization checks
- Database queries
- Uniqueness validation
- Validation of dependencies between entities
### Schema Structure
```typescript
// domain/happiness/schemas/createCampaign.schema.ts
import { z } from 'zod'
export const createCampaignSchema = z.object({
projectId: z.string().uuid('Invalid project ID'),
title: z.string()
.min(3, 'Title must be at least 3 characters')
.max(200, 'Title must not exceed 200 characters'),
description: z.string()
.max(2000, 'Description too long')
.optional(),
endsAt: z.date()
.min(new Date(), 'End date must be in the future')
.optional(),
questions: z.array(z.object({
type: z.enum(['SCALE', 'OPEN_TEXT']),
label: z.string().min(3).max(500),
order: z.number().int().positive()
})).min(1, 'At least one question is required')
})
// Export type for TypeScript
export type CreateCampaignInput = z.infer<typeof createCampaignSchema>
```
### Schema Best Practices
```typescript
// ✅ CORRECT - Structural validation only
const schema = z.object({
email: z.string().email(),
age: z.number().min(0).max(120)
})
// ❌ WRONG - Business logic in schema
const schema = z.object({
email: z.string().email().refine(async (email) => {
const exists = await db.user.findUnique({ where: { email } })
return !exists
}, 'Email already exists') // This is business validation!
})
// ❌ WRONG - Authorization in schema
const schema = z.object({
projectId: z.string().refine(async (id) => {
const member = await checkMembership(id)
return member !== null
}, 'Not authorized') // This is authorization!
})
```
## 2. React Hook Form Integration (Client-Side)
### Form Component Structure
```typescript
'use client'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { createCampaignSchema, type CreateCampaignInput } from '@/domain/happiness/schemas/createCampaign.schema'
import { createCampaign } from '@/app/actions/happiness/createCampaign'
import { useTransition } from 'react'
import { toast } from 'sonner'
interface CreateCampaignFormProps {
projectId: string
onSuccess?: () => void
}
export function CreateCampaignForm({ projectId, onSuccess }: CreateCampaignFormProps) {
const [isPending, startTransition] = useTransition()
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
setError,
reset
} = useForm<CreateCampaignInput>({
resolver: zodResolver(createCampaignSchema),
defaultValues: {
projectId,
questions: []
}
})
async function onSubmit(data: CreateCampaignInput) {
startTransition(async () => {
const result = await createCampaign(data)
if (!result.success) {
// Handle different error types
if (result.type === 'validation') {
// Structural validation errors (should rarely happen with zodResolver)
Object.entries(result.errors).forEach(([field, messages]) => {
setError(field as any, {
type: 'server',
message: messages.join(', ')
})
})
} else if (result.type === 'business') {
// Business logic errors - show under specific field or as toast
if (result.field) {
setError(result.field as any, {
type: 'server',
message: result.message
})
} else {
toast.error(result.message)
}
} else {
// Unknown errors
toast.error(result.message || 'An error occurred')
}
return
}
// Success
toast.success('Campaign created successfully')
reset()
onSuccess?.()
})
}
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
<div>
<label htmlFor="title" className="block text-sm font-medium">
Campaign Title
</label>
<input
id="title"
type="text"
{...register('title')}
className="mt-1 block w-full rounded border"
disabled={isPending}
/>
{errors.title && (
<p className="mt-1 text-sm text-red-600">{errors.title.message}</p>
)}
</div>
<button
type="submit"
disabled={isPending || isSubmitting}
className="rounded bg-primary px-4 py-2 text-white"
>
{isPending ? 'Creating...' : 'Create Campaign'}
</button>
</form>
)
}
```
### Key RHF Patterns
#### Use `zodResolver` for Validation
```typescript
// ✅ CORRECT
const form = useForm<InputType>({
resolver: zodResolver(sharedSchema)
})
// ❌ WRONG - Manual validation
const form = useForm({
validate: (data) => {
// Don't do this - use Zod
}
})
```
#### Use `setError` for Server-Side Errors
```typescript
// ✅ CORRECT - Map server errors to form fields
if (result.type === 'business' && result.field) {
setError(result.field as keyof FormData, {
type: 'server',
message: result.message
})
}
// ❌ WRONG - Generic alert
if (!result.success) {
alert(result.error) // Poor UX
}
```
#### Use `useTransition` for Server Actions
```typescript
// ✅ CORRECT - Non-blocking UI updates
const [isPending, startTransition] = useTransition()
async function onSubmit(data: FormData) {
startTransition(async () => {
const result = await serverAction(data)
// Handle result
})
}
// Disable form during submission
<button disabled={isPending || isSubmitting}>Submit</button>
```
## 3. Server Actions (Orchestration Layer)
### Server Action Structure
```typescript
'use server'
import { z } from 'zod'
import { getAuthSession } from '@/lib/auth'
import { createCampaignSchema, type CreateCampaignInput } from '@/domain/happiness/schemas/createCampaign.schema'
import { createCampaignService } from '@/domain/happiness/services/campaign.service'
import { campaignToDTO } from '@/domain/happiness/mappers/campaign.mapper'
import type { ActionResult } from '@/types/common'
import type { HappinessCampaignDTO } from '@/types/happiness'
export async function createCampaign(
input: CreateCampaignInput
): Promise<ActionResult<HappinessCampaignDTO>> {
try {
// 1. Validate input (structural validation)
const validated = createCampaignSchema.safeParse(input)
if (!validated.success) {
return {
success: false,
type: 'validation',
errors: validated.error.flatten().fieldErrors
}
}
// 2. Authentication check
const session = await getAuthSession()
if (!session?.user?.id) {
return {
success: false,
type: 'business',
message: 'Unauthorized'
}
}
// 3. Execute business logic via domain layer
const campaign = await createCampaignService({
...validated.data,
userId: session.user.id
})
// 4. Return DTO
return {
success: true,
data: campaignToDTO(campaign)
}
} catch (error) {
// Handle domain errors
if (error instanceof ProjectNotFoundError) {
return {
success: false,
type: 'business',
field: 'projectId',
message: 'Project not found'
}
}
if (error instanceof ForbiddenError) {
return {
success: false,
type: 'business',
message: 'You do not have permission to create campaigns'
}
}
if (error instanceof CampaignLimitExceededError) {
return {
success: false,
type: 'business',
message: 'Maximum number of active campaigns reached'
}
}
// Unknown errors
console.error('[createCampaign] Error:', error)
return {
success: false,
type: 'unknown',
message: 'Failed to create campaign'
}
}
}
```
### Action Result Types
```typescript
// types/common.ts
export type ActionResult<T = void> =
| { success: true; data: T }
| { success: false; type: 'validation'; errors: Record<string, string[]> }
| { success: false; type: 'business'; field?: string; message: string }
| { success: false; type: 'unknown'; message: string }
```
### Server Action Best Practices
```typescript
// ✅ CORRECT - Orchestrate only
export async function createRisk(input: CreateRiskInput) {
const validated = schema.safeParse(input)
if (!validated.success) {
return { success: false, type: 'validation', errors: validated.error.flatten().fieldErrors }
}
const session = await getAuthSession()
if (!session) {
return { success: false, type: 'business', message: 'Unauthorized' }
}
// Domain layer handles business logic
const risk = await createRiskService(validated.data, session.user.id)
return { success: true, data: riskToDTO(risk) }
}
// ❌ WRONG - Business logic in Server Action
export async function createRisk(input: CreateRiskInput) {
const validated = schema.safeParse(input)
// Business logic should be in domain layer!
const member = await db.projectMember.findUnique({
where: { projectId_userId: { projectId: input.projectId, userId: session.user.id } }
})
if (!member || member.role !== 'PM') {
return { success: false, type: 'business', message: 'Forbidden' }
}
const existingRisks = await db.risk.count({ where: { projectId: input.projectId, status: 'OPEN' } })
if (existingRisks >= 50) {
return { success: false, type: 'business', message: 'Too many open risks' }
}
// This is all business logic - belongs in domain layer!
const risk = await db.risk.create({ data: input })
return { success: true, data: riskToDTO(risk) }
}
```
## 4. Domain Layer (Business Logic)
### Domain Service Structure
```typescript
// domain/happiness/services/campaign.service.ts
import { db } from '@/lib/db'
import { canManageCampaigns } from '@/domain/auth/permissions'
import { ProjectNotFoundError, ForbiddenError, CampaignLimitExceededError } from '@/domain/happiness/errors'
interface CreateCampaignData {
projectId: string
title: string
description?: string
endsAt?: Date
questions: Array<{
type: 'SCALE' | 'OPEN_TEXT'
label: string
order: number
}>
userId: string
}
export async function createCampaignService(data: CreateCampaignData) {
// 1. Verify project exists
const project = await db.project.findUnique({
where: { id: data.projectId }
})
if (!project) {
throw new ProjectNotFoundError(data.projectId)
}
// 2. Check authorization
const canManage = await canManageCampaigns(data.userId, data.projectId)
if (!canManage) {
throw new ForbiddenError('You must be a PM to create campaigns')
}
// 3. Business rule: Max 5 active campaigns per project
const activeCampaignsCount = await db.happinessCampaign.count({
where: {
projectId: data.projectId,
status: 'ACTIVE'
}
})
if (activeCampaignsCount >= 5) {
throw new CampaignLimitExceededError()
}
// 4. Business rule: Questions must have unique orders
const orders = data.questions.map(q => q.order)
const uniqueOrders = new Set(orders)
if (orders.length !== uniqueOrders.size) {
throw new Error('Question orders must be unique')
}
// 5. Create campaign with questions in transaction
return await db.$transaction(async (tx) => {
const campaign = await tx.happinessCampaign.create({
data: {
projectId: data.projectId,
title: data.title,
description: data.description,
endsAt: data.endsAt,
status: 'DRAFT'
}
})
await tx.happinessQuestion.createMany({
data: data.questions.map(q => ({
campaignId: campaign.id,
type: q.type,
label: q.label,
order: q.order
}))
})
return campaign
})
}
```
### Domain Errors
```typescript
// domain/happiness/errors.ts
export class ProjectNotFoundError extends Error {
constructor(projectId: string) {
super(`Project ${projectId} not found`)
this.name = 'ProjectNotFoundError'
}
}
export class ForbiddenError extends Error {
constructor(message: string) {
super(message)
this.name = 'ForbiddenError'
}
}
export class CampaignLimitExceededError extends Error {
constructor() {
super('Maximum number of active campaigns reached')
this.name = 'CampaignLimitExceededError'
}
}
```
### Domain Layer Rules
- **Pure business logic** - No framework dependencies (Next.js, React, RHF)
- **Authorization checks** - Verify user permissions
- **Business rules** - Enforce project-specific constraints
- **Data consistency** - Use transactions for multi-step operations
- **Throw typed errors** - Use custom error classes
- **No DB types exposure** - Work with domain types, not Prisma types directly
## 5. Dynamic & Complex Forms
### Using `useFieldArray` for Dynamic Fields
```typescript
'use client'
import { useForm, useFieldArray } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
export function CampaignQuestionsForm() {
const { control, register, handleSubmit, formState: { errors } } = useForm<CreateCampaignInput>({
resolver: zodResolver(createCampaignSchema),
defaultValues: {
questions: [{ type: 'SCALE', label: '', order: 1 }]
}
})
const { fields, append, remove } = useFieldArray({
control,
name: 'questions'
})
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
{fields.map((field, index) => (
<div key={field.id} className="space-y-2">
<select {...register(`questions.${index}.type`)}>
<option value="SCALE">Scale (1-5)</option>
<option value="OPEN_TEXT">Open Text</option>
</select>
<input
{...register(`questions.${index}.label`)}
placeholder="Question text"
/>
{errors.questions?.[index]?.label && (
<p className="text-sm text-red-600">
{errors.questions[index].label?.message}
</p>
)}
<button type="button" onClick={() => remove(index)}>
Remove
</button>
</div>
))}
<button
type="button"
onClick={() => append({ type: 'SCALE', label: '', order: fields.length + 1 })}
>
Add Question
</button>
<button type="submit">Create Campaign</button>
</form>
)
}
```
### Conditional Fields
```typescript
const watchCampaignType = watch('type')
return (
<form noValidate>
<select {...register('type')}>
<option value="QUICK">Quick Survey</option>
<option value="DETAILED">Detailed Survey</option>
</select>
{watchCampaignType === 'DETAILED' && (
<div>
<label>Detailed Description</label>
<textarea {...register('detailedDescription')} />
</div>
)}
</form>
)
```
## 6. Form Validation Strategy
### CRITICAL: Disable HTML5 Validation
**ALL forms MUST use the `noValidate` attribute** to disable browser's default HTML5 validation. This ensures consistent validation behavior across all browsers and allows Zod to be the single source of validation truth.
```typescript
// ✅ CORRECT - noValidate attribute disables HTML5 validation
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<input
type="email"
{...register('email')}
/>
<button type="submit">Submit</button>
</form>
// ❌ WRONG - Missing noValidate allows browser validation
<form onSubmit={handleSubmit(onSubmit)}>
<input
type="email"
required // Browser will validate this
{...register('email')}
/>
</form>
```
### Why Disable HTML5 Validation?
1. **Consistency** - Browser validation messages vary across browsers and languages
2. **Control** - Zod provides consistent, customizable error messages
3. **UX** - React Hook Form handles validation timing (onBlur, onChange, onSubmit)
4. **Accessibility** - Better control over error message presentation
5. **Testing** - Easier to test when validation is handled by one system
### Validation Layers
With `noValidate`, validation happens in this order:
1. **RHF + Zod (Client)** - Immediate feedback with custom messages
2. **Server Action + Zod** - Security validation (never trust client)
3. **Domain Layer** - Business rules and authorization
```typescript
// Complete validation flow
export function CreateCampaignForm() {
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(createCampaignSchema) // Zod validates
})
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate> {/* Disable HTML5 */}
<input
type="text"
{...register('title')}
aria-invalid={!!errors.title}
/>
{errors.title && (
<p className="text-red-600">{errors.title.message}</p>
)}
<button type="submit">Submit</button>
</form>
)
}
```
### Input Types for UX (Not Validation)
You can still use HTML5 input types for better UX (mobile keyboards, date pickers), but they won't validate:
```typescript
// ✅ CORRECT - Use input types for UX, Zod for validation
<form noValidate>
<input
type="email" // Shows email keyboard on mobile
{...register('email')}
/>
<input
type="url" // Shows URL keyboard on mobile
{...register('website')}
/>
<input
type="date" // Shows date picker
{...register('startDate')}
/>
</form>
// Zod schema handles all validation
const schema = z.object({
email: z.string().email('Invalid email address'),
website: z.string().url('Invalid URL'),
startDate: z.date()
})
```
## 7. Naming Conventions
### File Structure
```
app/
├─ actions/
│ └─ happiness/
│ ├─ createCampaign.ts # Server Action
│ ├─ updateCampaign.ts
│ └─ submitAnswer.ts
components/
├─ happiness/
│ ├─ CreateCampaignForm.tsx # Form component
│ ├─ UpdateCampaignForm.tsx
│ └─ AnswerForm.tsx
domain/
├─ happiness/
│ ├─ schemas/
│ │ ├─ createCampaign.schema.ts # Zod schema
│ │ ├─ updateCampaign.schema.ts
│ │ └─ submitAnswer.schema.ts
│ ├─ services/
│ │ ├─ campaign.service.ts # Business logic
│ │ └─ answer.service.ts
│ ├─ errors.ts # Domain errors
│ └─ mappers/
│ └─ campaign.mapper.ts # DB → DTO mapping
```
### Naming Pattern
- **Form Components**: `<Action><Feature>Form.tsx` (e.g., `CreateCampaignForm.tsx`)
- **Server Actions**: `<action><Feature>.ts` (e.g., `createCampaign.ts`)
- **Zod Schemas**: `<action><Feature>.schema.ts` (e.g., `createCampaign.schema.ts`)
- **Domain Services**: `<feature>.service.ts` (e.g., `campaign.service.ts`)
- **Domain Errors**: `<Feature><ErrorType>Error` (e.g., `CampaignLimitExceededError`)
## 8. Testing Strategy
### Test Schema Validation
```typescript
// tests/domain/happiness/schemas/createCampaign.test.ts
import { createCampaignSchema } from '@/domain/happiness/schemas/createCampaign.schema'
describe('createCampaignSchema', () => {
it('validates correct data', () => {
const result = createCampaignSchema.safeParse({
projectId: '123e4567-e89b-12d3-a456-426614174000',
title: 'Q1 Survey',
questions: [
{ type: 'SCALE', label: 'How happy are you?', order: 1 }
]
})
expect(result.success).toBe(true)
})
it('rejects title too short', () => {
const result = createCampaignSchema.safeParse({
projectId: '123e4567-e89b-12d3-a456-426614174000',
title: 'Q1',
questions: []
})
expect(result.success).toBe(false)
expect(result.error?.issues[0].message).toContain('at least 3 characters')
})
it('requires at least one question', () => {
const result = createCampaignSchema.safeParse({
projectId: '123e4567-e89b-12d3-a456-426614174000',
title: 'Q1 Survey',
questions: []
})
expect(result.success).toBe(false)
})
})
```
## 9. Security Considerations
### Never Trust Client Validation
```typescript
// ❌ WRONG - Only client validation
export function CreateRiskForm() {
const form = useForm({ resolver: zodResolver(schema) })
// If user bypasses client, no validation!
}
// ✅ CORRECT - Always validate on server
export async function createRisk(input: unknown) {
const validated = schema.safeParse(input)
if (!validated.success) {
return { success: false, type: 'validation', errors: validated.error.flatten().fieldErrors }
}
// Proceed with validated data
}
```
### Validate Authorization in Domain Layer
```typescript
// ✅ CORRECT - Check permissions in domain service
export async function createCampaignService(data: CreateCampaignData) {
const canManage = await canManageCampaigns(data.userId, data.projectId)
if (!canManage) {
throw new ForbiddenError()
}
// Proceed
}
// ❌ WRONG - Only check in form
export function CreateCampaignForm() {
if (!isPM) {
return <p>You must be a PM</p>
}
// User can bypass this!
}
```
### Sanitize User Input
```typescript
// Use Zod to restrict input format
const schema = z.object({
title: z.string()
.max(200)
.regex(/^[a-zA-Z0-9\s\-_]+$/, 'Invalid characters'),
url: z.string().url(),
email: z.string().email()
})
// React automatically escapes JSX
<p>{user.input}</p> // Safe from XSS
```
## 10. Accessibility Best Practices
### Form Accessibility
```typescript
export function AccessibleForm() {
const { register, formState: { errors } } = useForm()
return (
<form noValidate>
{/* Always use labels */}
<label htmlFor="campaign-title">
Campaign Title
<span aria-label="required">*</span>
</label>
<input
id="campaign-title"
{...register('title')}
aria-invalid={errors.title ? 'true' : 'false'}
aria-describedby={errors.title ? 'title-error' : undefined}
/>
{/* Associate errors with inputs */}
{errors.title && (
<p id="title-error" role="alert" className="text-red-600">
{errors.title.message}
</p>
)}
{/* Disabled state */}
<button
type="submit"
disabled={isSubmitting}
aria-disabled={isSubmitting}
>
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>
</form>
)
}
```
## 11. Performance Optimization
### Avoid Unnecessary Re-renders
```typescript
// ✅ CORRECT - Watch specific fields
const watchedFields = watch(['title', 'status'])
// ❌ WRONG - Watch everything
const allValues = watch() // Re-renders on any field change
```
### Debounce Expensive Operations
```typescript
import { useDebouncedCallback } from 'use-debounce'
export function FormWithPreview() {
const { watch } = useForm()
const [preview, setPreview] = useState('')
const updatePreview = useDebouncedCallback((value: string) => {
// Expensive operation
setPreview(generatePreview(value))
}, 500)
useEffect(() => {
const subscription = watch((value) => {
updatePreview(value.description)
})
return () => subscription.unsubscribe()
}, [watch, updatePreview])
}
```
## 12. Common Patterns
### Multi-Step Forms
```typescript
export function MultiStepCampaignForm() {
const [step, setStep] = useState(1)
const form = useForm<CreateCampaignInput>({
resolver: zodResolver(createCampaignSchema),
mode: 'onBlur'
})
async function onSubmit(data: CreateCampaignInput) {
if (step < 3) {
// Validate current step
const isValid = await form.trigger(getFieldsForStep(step))
if (isValid) {
setStep(step + 1)
}
return
}
// Final submit
startTransition(async () => {
const result = await createCampaign(data)
// Handle result
})
}
return (
<form onSubmit={form.handleSubmit(onSubmit)} noValidate>
{step === 1 && <BasicInfoStep form={form} />}
{step === 2 && <QuestionsStep form={form} />}
{step === 3 && <ReviewStep form={form} />}
<button type="submit">
{step < 3 ? 'Next' : 'Create Campaign'}
</button>
</form>
)
}
```
### Form with File Upload
```typescript
export function FormWithFileUpload() {
const { register, handleSubmit } = useForm()
async function onSubmit(data: FormData) {
const formData = new FormData()
formData.append('title', data.title)
if (data.file[0]) {
formData.append('file', data.file[0])
}
const result = await uploadAction(formData)
// Handle result
}
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<input {...register('title')} />
<input type="file" {...register('file')} accept=".pdf,.doc" />
<button type="submit">Upload</button>
</form>
)
}
```
### Optimistic Updates
```typescript
export function UpdateRiskForm({ risk }: { risk: RiskDTO }) {
const router = useRouter()
const [optimisticRisk, setOptimisticRisk] = useOptimistic(risk)
async function onSubmit(data: UpdateRiskInput) {
// Optimistically update UI
setOptimisticRisk({ ...risk, ...data })
const result = await updateRisk(risk.id, data)
if (!result.success) {
// Revert on error
setOptimisticRisk(risk)
toast.error(result.message)
return
}
toast.success('Risk updated')
router.refresh()
}
return (
<div>
<h2>{optimisticRisk.title}</h2>
<form onSubmit={handleSubmit(onSubmit)} noValidate>
{/* Form fields */}
</form>
</div>
)
}
```
## Summary
### Key Principles
1. **Shared Zod Schema** - Single source of truth for client and server
2. **RHF + zodResolver** - Type-safe forms with excellent UX
3. **Server Actions** - Orchestration layer only
4. **Domain Layer** - All business logic and authorization
5. **Structured Results** - Consistent error handling pattern
6. **setError** - Map server errors to form fields
7. **Progressive Enhancement** - HTML5 + Zod + Server validation
8. **Security First** - Never trust client-side validation
9. **Testing** - Schema, domain, integration, E2E
10. **Accessibility** - Labels, ARIA, keyboard navigation
### Data Flow
```
User Input
↓
HTML5 Validation (instant)
↓
RHF + Zod Validation (rich UX)
↓
Server Action (orchestration)
↓
Zod Schema Parse (security)
↓
Domain Service (business logic + authorization)
↓
Database
↓
DTO Mapping
↓
Response to Client
↓
setError() or Success Toast
```
### Quick Checklist
- [ ] Zod schema in `domain/<feature>/schemas/`
- [ ] Server Action validates with same schema
- [ ] Domain service contains business logic
- [ ] Form uses `zodResolver(schema)`
- [ ] **Form has `noValidate` attribute** (disable HTML5 validation)
- [ ] `setError()` used for server-side errors
- [ ] `useTransition` for non-blocking submits
- [ ] Structured `ActionResult` type
- [ ] No business logic in Server Action
- [ ] Authorization checked in domain layer
- [ ] Tests for schema, domain, and integration
- [ ] Accessible labels and error messages
- [ ] No Prisma types exposed to UI
_Status: Work in progress_
1. [Overview](#overview)
You will need to decide where your entity should be located and how it will be structured. This is largely driven by tax considerations, but may also be driven by governance preferences.
This document aims to help you get started with profiling test suites and answers the following questions: which profiles to run first? How do we interpret the results to choose the next steps? Etc.