Design Principles
Core principles guiding the architecture and development practices
Overview
This project is built on a foundation of proven design principles that ensure code quality, maintainability, and scalability. These principles guide every architectural decision and should be followed when extending the application.
1. Type Safety
Everything should be type-safe from database to UI.
Why It Matters
- Catch errors at compile-time, not runtime
- Reduce bugs in production
- Improve IDE support and developer experience
- Enable confident refactoring
- Self-document code through types
Implementation
Database Types
// Tables are defined with Drizzle ORM
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
// Types are automatically inferred
type User = typeof users.$inferSelect;
type NewUser = typeof users.$inferInsert;API Types
// tRPC provides end-to-end type safety
export const userRouter = createTRPCRouter({
getById: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
return await db.query.users.findFirst({
where: eq(users.id, input.id),
});
}),
});
// Client knows exact return type without code generation
const { data } = api.users.getById.useQuery({ id: "123" });
// ^? User | undefinedForm Types
// Zod schemas for validation
const userSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
age: z.number().min(18),
});
// Infer TypeScript type from schema
type UserFormData = z.infer<typeof userSchema>;
// Use with React Hook Form
const form = useForm<UserFormData>({
resolver: zodResolver(userSchema),
});Best Practices
- ✅ Use
typeinstead ofinterfacefor consistency - ✅ Infer types from runtime values when possible
- ✅ Use Zod for runtime validation + type inference
- ✅ Enable TypeScript strict mode
- ❌ Never use
any- useunknownand type guards - ❌ Don't duplicate types - infer from single source
2. Separation of Concerns
Keep UI, business logic, and data access separate.
Why It Matters
- Easier to test individual pieces
- Changes don't cascade across layers
- Multiple UIs can use the same logic
- Logic can be reused across features
- Clearer code organization
Implementation
Feature Structure (5-File Pattern)
src/features/users/
├── schema.ts # Validation and type definitions
├── functions.ts # Business logic and database operations
├── hooks.ts # Client-side React hooks
├── fields.tsx # Form field components
└── prompts.tsx # Dialog/modal wrappersLayer Separation
Data Layer (functions.ts):
// Pure business logic - no React, no UI
export async function createUser(data: CreateUserInput) {
// Validation
const validated = createUserSchema.parse(data);
// Business rules
if (await userOperations.existsByEmail(validated.email)) {
throw new ValidationError("Email already exists");
}
// Database operation
return await userOperations.create(validated);
}API Layer (tRPC router):
// Connects UI to business logic
export const userRouter = createTRPCRouter({
create: protectedProcedure
.input(createUserSchema)
.mutation(async ({ input }) => {
return await createUser(input);
}),
});UI Layer (React component):
// Only handles presentation and user interaction
export function CreateUserForm() {
const mutation = api.users.create.useMutation();
const onSubmit = (data: CreateUserInput) => {
mutation.mutate(data);
};
return <form onSubmit={handleSubmit(onSubmit)}>...</form>;
}Best Practices
- ✅ Keep business logic in
functions.ts - ✅ Keep React hooks in
hooks.ts - ✅ Keep UI components presentation-only
- ✅ Test each layer independently
- ❌ Don't put business logic in components
- ❌ Don't access database directly from components
3. Reusability
Build small, composable pieces that can be combined.
Why It Matters
- Reduce code duplication
- Faster feature development
- Consistent UI and behavior
- Easier to maintain
- Smaller bundle sizes
Implementation
Reusable Form Fields
// fields.tsx - Reusable field components
export function UserNameField() {
const { control } = useFormContext<UserFormData>();
return (
<FormField
control={control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} placeholder="John Doe" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
}
// Use in multiple forms
export function CreateUserForm() {
return (
<Form {...form}>
<UserNameField />
{/* Other fields */}
</Form>
);
}
export function EditUserForm() {
return (
<Form {...form}>
<UserNameField />
{/* Same field, different form */}
</Form>
);
}Reusable Database Operations
// Use createDrizzleOperations for CRUD
import { createDrizzleOperations } from "@/db/drizzle-operations";
export const userOperations = createDrizzleOperations({
table: users,
primaryKey: "id",
});
// Now available everywhere:
userOperations.getById(id);
userOperations.list({ limit: 10 });
userOperations.create(data);
userOperations.update(id, data);
userOperations.delete(id);Reusable UI Components
// Shared components in src/components/ui/
import { Button } from "@/components/ui/button";
import { Dialog } from "@/components/ui/dialog";
// Use consistently across features
<Button variant="default" size="lg">
Submit
</Button>
<Button variant="destructive" size="sm">
Delete
</Button>Best Practices
- ✅ Create reusable field components
- ✅ Use
createDrizzleOperationsfor CRUD - ✅ Build generic UI components
- ✅ Extract common patterns
- ❌ Don't duplicate form fields
- ❌ Don't write raw CRUD operations repeatedly
4. Performance
Optimize for speed using server-side rendering and caching.
Why It Matters
- Better user experience
- Improved SEO rankings
- Lower hosting costs
- Higher conversion rates
- Reduced client bundle size
Implementation
Server Components
// Render on server - no JavaScript sent to client
export default async function UsersPage() {
const users = await api.users.list.query({ limit: 10 });
return (
<div>
{users.map((user) => (
<UserCard key={user.id} user={user} />
))}
</div>
);
}Data Caching
// Cache expensive database queries with unstable_cache
import { unstable_cache } from "next/cache";
import { TableTags } from "@/db/tags";
export const getPopularPosts = unstable_cache(
async () => {
const posts = await db.query.posts.findMany({
where: gt(posts.views, 1000),
orderBy: desc(posts.views),
limit: 10,
});
return posts;
},
["popular-posts"],
{
tags: [TableTags.posts],
revalidate: 3600, // Cache for 1 hour
}
);
#### Prefetching
```typescript
// Prefetch data in Server Components
export default async function PostPage({ params }: Props) {
// Start prefetching immediately
void api.posts.getById.prefetch({ id: params.id });
void api.comments.list.prefetch({ postId: params.id });
return (
<div>
<PostContent id={params.id} />
<CommentsList postId={params.id} />
</div>
);
}Lazy Loading
// Load components only when needed
const AdminPanel = dynamic(() => import("@/features/admin/components/admin-panel"), {
loading: () => <Skeleton />,
});Best Practices
- ✅ Use Server Components by default
- ✅ Cache expensive operations with
unstable_cache - ✅ Prefetch data in Server Components
- ✅ Lazy load heavy components
- ✅ Optimize images with Next.js Image
- ❌ Don't fetch data in Client Components
- ❌ Don't send large bundles to the client
5. Modularity
Organize code by feature, not by technical layer.
Why It Matters
- Easier to find related code
- Teams can own specific features
- Reduce merge conflicts
- Enable independent deployment
- Scale development team
Implementation
Feature-Based Structure
src/features/
├── auth/ # Authentication feature
│ ├── schema.ts
│ ├── functions.ts
│ ├── hooks.ts
│ └── components/
├── organizations/ # Organizations feature
│ ├── schema.ts
│ ├── functions.ts
│ ├── hooks.ts
│ └── components/
└── subscriptions/ # Subscriptions feature
├── schema.ts
├── functions.ts
├── hooks.ts
└── components/NOT organized by technical layer:
❌ src/
├── components/ # All components mixed together
├── services/ # All services mixed together
└── types/ # All types mixed togetherFeature Independence
// Each feature is self-contained
import { createUser } from "@/features/users/functions";
import { sendWelcomeEmail } from "@/features/emails/functions";
import { createOrganization } from "@/features/organizations/functions";
// Features compose together
async function onboardUser(data: OnboardingData) {
const user = await createUser(data.user);
const org = await createOrganization(data.organization, user.id);
await sendWelcomeEmail(user.email, user.name);
return { user, org };
}Best Practices
- ✅ Group by feature, not by type
- ✅ Keep feature code together
- ✅ Make features independent
- ✅ Share code through imports, not globals
- ❌ Don't scatter feature code across folders
- ❌ Don't create deep folder nesting
6. DRY Principle
Don't Repeat Yourself - Use abstractions to avoid duplication.
Why It Matters
- Single source of truth
- Easier to update behavior
- Reduce bugs from inconsistency
- Smaller codebase
- Faster development
Implementation
Database Operations
// ❌ Don't repeat CRUD operations
async function getUser(id: string) {
return await db.query.users.findFirst({ where: eq(users.id, id) });
}
async function getPost(id: string) {
return await db.query.posts.findFirst({ where: eq(posts.id, id) });
}
// ✅ Use abstraction
import { createDrizzleOperations } from "@/db/drizzle-operations";
export const userOperations = createDrizzleOperations({
table: users,
primaryKey: "id"
});
export const postOperations = createDrizzleOperations({
table: posts,
primaryKey: "id"
});
// Both have: getById, list, create, update, deleteForm Patterns
// ❌ Don't repeat form setup
const form1 = useForm({ resolver: zodResolver(schema1) });
const form2 = useForm({ resolver: zodResolver(schema2) });
// ✅ Use helper
function useZodForm<T extends z.ZodType>(schema: T) {
return useForm<z.infer<T>>({
resolver: zodResolver(schema),
});
}
const form1 = useZodForm(schema1);
const form2 = useZodForm(schema2);Component Patterns
// ❌ Don't repeat dialog structure
<Dialog>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete User</DialogTitle>
</DialogHeader>
{/* Content */}
</DialogContent>
</Dialog>
// ✅ Use prompt wrapper (prompts.tsx)
export function DeleteUserPrompt({ userId }: Props) {
return (
<PromptDialog
title="Delete User"
description="Are you sure?"
trigger={<Button variant="destructive">Delete</Button>}
>
<DeleteUserForm userId={userId} />
</PromptDialog>
);
}Best Practices
- ✅ Extract repeated patterns
- ✅ Use helper functions
- ✅ Create wrapper components
- ✅ Share configurations
- ❌ Don't copy-paste code
- ❌ Don't create slightly different versions
Applying These Principles
Code Review Checklist
When reviewing code, ask:
- Type Safety: Are all types explicit? No
any? - Separation: Is business logic separate from UI?
- Reusability: Could this be used elsewhere?
- Performance: Is data fetched on the server?
- Modularity: Is code in the right feature folder?
- DRY: Is there duplicated code?
Refactoring Guide
When refactoring, follow this order:
- Add types - Make it type-safe first
- Extract logic - Move business logic out of components
- Create abstractions - DRY up repeated patterns
- Optimize - Add caching and server rendering
- Modularize - Move to appropriate feature folder
Testing Strategy
Each principle enables better testing:
- Type Safety → Compiler catches errors
- Separation → Unit test each layer
- Reusability → Test once, use everywhere
- Performance → Measure with profiler
- Modularity → Test features in isolation
- DRY → Change behavior in one place
Related Documentation
- Features Guide - Creating feature modules
- Database Guide - Using Drizzle operations
- tRPC Guide - Type-safe API patterns
- Architecture Overview - System design
Summary
These six principles work together to create:
- ✅ Reliable - Types catch errors early
- ✅ Maintainable - Clear separation of concerns
- ✅ Efficient - Reusable components and functions
- ✅ Fast - Server-first with caching
- ✅ Scalable - Feature-based organization
- ✅ Clean - No duplication
Follow these principles consistently and your code will be easier to understand, test, and evolve.