Skip to main content

Design Decisions

This document explains the reasoning behind the major technical decisions made when building AllMyChats. Understanding these decisions will help you navigate the codebase and make informed choices when extending the application.

Table of Contents​


Why TanStack Start​

What is TanStack Start?​

TanStack Start is a modern full-stack React framework that combines the best of React Server Components, file-based routing, and powerful data fetching through TanStack Query.

Key Benefits​

1. Type Safety Across the Stack​

// Define a server function with full type safety
export const getMessageFn = createServerFn({ method: "GET" })
.inputValidator(z.object({ id: z.string() }))
.handler(async ({ data }) => {
return await getMessage(data.id);
});

// Call it from the client with autocomplete and type checking
const message = await getMessageFn({ id: "123" });
// TypeScript knows message is of type Message

Why this matters: End-to-end type safety prevents bugs at compile time rather than runtime. When you change a server function's signature, TypeScript will immediately show all client locations that need updating.

2. Unified Data Fetching​

TanStack Start integrates TanStack Query deeply, providing:

  • Automatic caching and invalidation
  • Optimistic updates
  • Background refetching
  • Loading and error states
// Data fetching with automatic caching
const { data: messages, isLoading } = useQuery({
queryKey: ["messages"],
queryFn: getMessagesFn,
});

Why this matters: Reduces boilerplate code and provides a consistent pattern for data fetching across the entire application.

3. File-Based Routing​

src/routes/
├── index.tsx → /
├── dashboard.tsx → /dashboard
├── messages/
│ ├── $id.tsx → /messages/:id
│ └── index.tsx → /messages
└── api/
└── trpc/
└── $.ts → /api/trpc/*

Why this matters: Clear organization, collocated routing logic, and automatic route generation make the codebase easier to navigate and maintain.

4. Server and Client Components​

TanStack Start supports both Server Components (for performance) and Client Components (for interactivity) in the same application:

// Server component - runs on the server, reduces client bundle
export const MessageList = async () => {
const messages = await getMessagesFn();
return <div>{/* render messages */}</div>;
};

// Client component - runs on the client for interactivity
export const MessageFilter = () => {
const [filter, setFilter] = useState("");
// ... interactive logic
};

Why this matters: Optimize performance by rendering data-heavy components on the server while keeping interactive components on the client.

Alternatives Considered​

FrameworkProsConsWhy Not Chosen
Next.jsMature, large communityComplex app dir, less flexible routingTanStack Start offers better TypeScript integration and simpler mental model
RemixWeb standards focusedLess built-in caching, verboseTanStack Start provides better data fetching out of the box
Vite + React RouterSimple, fastManual server setup neededTanStack Start provides full-stack solution in one package

Why tRPC​

What is tRPC?​

tRPC (TypeScript Remote Procedure Call) is a library that allows you to build fully typesafe APIs without schemas or code generation. It uses TypeScript's type inference to share types between client and server automatically.

Key Benefits​

1. End-to-End Type Safety Without Schema Duplication​

// Define router on server
const messageRouter = router({
list: publicProcedure
.input(z.object({ limit: z.number().optional() }))
.query(async ({ input }) => {
return await getMessages(input.limit);
}),
});

// Use on client with full type inference
const { data } = trpc.messages.list.useQuery({ limit: 10 });
// TypeScript knows the exact shape of data

Why this matters: When you change the server procedure, TypeScript immediately flags all client call sites that need updating. No separate API contracts or Swagger schemas to maintain.

2. Collocated Server and Client Code​

tRPC procedures are organized alongside your business logic:

src/server/routers/
├── messages.ts // Message-related procedures
├── conversations.ts // Conversation procedures
└── categories.ts // Category procedures

Why this matters: Keep related code together, making it easier to understand and modify features.

3. Automatic Type Inference​

export const appRouter = router({
messages: messageRouter,
conversations: conversationRouter,
categories: categoryRouter,
});

export type AppRouter = typeof appRouter;

// Client has full knowledge of all procedures
const trpc = createTRPCClient<AppRouter>({
/* ... */
});

Why this matters: No manual type exports or imports. The client automatically knows about all available procedures.

4. Batteries Included​

tRPC includes:

  • Built-in error handling
  • Request batching
  • Loading and error states via React Query integration
  • Middleware for authentication and validation
const protectedProcedure = t.procedure
.use(authMiddleware) // Reusable auth middleware
.input(
z.object({
/* validation schema */
})
);

export const messageRouter = router({
create: protectedProcedure.mutation(async ({ input, ctx }) => {
// ctx.userId is available from authMiddleware
return await createMessage(input, ctx.userId);
}),
});

Why this matters: Less boilerplate to write and maintain.

Alternatives Considered​

ApproachProsConsWhy Not Chosen
REST + OpenAPIStandard, language-agnosticType duplication, manual synctRPC eliminates type drift between client and server
GraphQL + ApolloFlexible queries, single endpointComplex setup, over-fetching controltRPC provides same type safety with less complexity
Server Functions OnlySimple, no RPC layerLess structure, manual type sharingtRPC provides better organization and automatic type inference

Why PostgreSQL + Drizzle ORM​

What is PostgreSQL?​

PostgreSQL is a powerful, open-source object-relational database system with over 35 years of active development.

What is Drizzle ORM?​

Drizzle ORM is a TypeScript ORM that provides type-safe database queries with a minimal API and excellent performance.

Key Benefits​

1. Type-Safe Queries​

import { message, category } from "~/db/schema";
import { eq, and } from "drizzle-orm";

// Fully typed query
const messages = await database
.select()
.from(message)
.leftJoin(category, eq(message.categoryId, category.id))
.where(eq(message.userId, userId));

// TypeScript knows messages is of type MessageWithCategory[]

Why this matters: Catch query errors at compile time, not runtime. The query result type is automatically inferred.

2. Schema as Single Source of Truth​

// Define schema once
export const message = pgTable("message", {
id: text("id").primaryKey(),
content: text("content").notNull(),
sender: text("sender").notNull(),
timestamp: timestamp("timestamp").notNull(),
categoryId: text("category_id").references(() => category.id),
// ...
});

// Use the schema to generate migrations
npm run db:generate

// And TypeScript types
export type Message = typeof message.$inferSelect;

Why this matters: Your database schema is defined in code, making it version-controlled and type-safe. Migrations are generated automatically from schema changes.

3. Performance​

Drizzle ORM generates optimized SQL queries without heavy runtime overhead:

// Drizzle generates this SQL:
// SELECT id, content, sender, timestamp FROM message
// WHERE user_id = $1 ORDER BY timestamp DESC LIMIT 50

const recentMessages = await database
.select()
.from(message)
.where(eq(message.userId, userId))
.orderBy(desc(message.timestamp))
.limit(50);

Why this matters: Fast queries without sacrificing type safety. Drizzle doesn't use a query builder that adds runtime overhead.

4. PostgreSQL Features​

PostgreSQL provides powerful features out of the box:

  • Full-text search: Built-in search capabilities for messages
  • JSONB: Store flexible data (message metadata, LLM responses)
  • Array types: Store tags, categories
  • Indexing: Optimize query performance
  • Transactions: ACID guarantees for data consistency
// Full-text search example
const results = await database
.select()
.from(message)
.where(sql`${message.content} ILIKE ${`%${searchTerm}%`}`);

Why this matters: PostgreSQL's advanced features reduce the need for external services (e.g., Elasticsearch for search).

5. Migration Management​

# Generate migration from schema changes
npm run db:generate

# Apply migrations
npm run db:migrate

# Open Drizzle Studio to inspect data
npm run db:studio

Why this matters: Simple, version-controlled migrations. No separate migration files to maintain manually.

Alternatives Considered​

Database/ORMProsConsWhy Not Chosen
PrismaExcellent DX, matureHeavy runtime, less flexible queriesDrizzle provides better performance and more control
TypeORMDecorator-based, popularLess type-safe, heavierDrizzle has better TypeScript integration
MongoDB + MongooseFlexible schemaNo ACID, less powerful queriesPostgreSQL provides better data integrity and querying
SQLiteSimple, embeddedLimited concurrencyPostgreSQL is more production-ready for multi-user app

Why Better Auth​

What is Better Auth?​

Better Auth is a modern authentication library for TypeScript that provides secure, type-safe authentication with minimal setup.

Key Benefits​

1. Type Safety​

// Auth instance is fully typed
export const auth = betterAuth({
database: drizzleAdapter(database, { provider: "pg" }),
emailAndPassword: { enabled: true },
});

// Session is typed
const session = await auth.api.getSession({ headers: request.headers });
if (session) {
// TypeScript knows session.user has id, email, name, etc.
console.log(session.user.email);
}

Why this matters: Auth-related code is fully type-safe, reducing bugs and improving developer experience.

2. Simple Setup​

// src/utils/auth.ts
export const auth = betterAuth({
database: drizzleAdapter(database, { provider: "pg" }),
emailAndPassword: { enabled: true },
});

// src/routes/api/auth/$.ts
export const Route = createServerFileRoute("/api/auth/$").methods({
GET: ({ request }) => auth.handler(request),
POST: ({ request }) => auth.handler(request),
});

Why this matters: Authentication is set up in minutes, not hours. No complex configuration needed.

3. Security by Default​

Better Auth handles:

  • Password hashing with bcrypt
  • Session management with secure cookies
  • CSRF protection
  • SQL injection protection (via Drizzle)
  • Rate limiting (configurable)

Why this matters: Authentication security is handled by experts. Don't roll your own auth security.

4. Flexible Auth Middleware​

// Reusable auth middleware for tRPC
const enforceUserIsAuthenticated = t.middleware(async ({ ctx, next }) => {
if (!ctx.isAuthenticated || !ctx.userId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
ctx: {
...ctx,
userId: ctx.userId,
isAuthenticated: true as const,
},
});
});

export const protectedProcedure = t.procedure.use(enforceUserIsAuthenticated);

Why this matters: Consistent authentication patterns across all API routes.

5. Database Integration​

Better Auth integrates seamlessly with Drizzle ORM:

export const user = pgTable("user", {
id: text("id").primaryKey(),
email: text("email").notNull().unique(),
// ... custom fields
});

export const session = pgTable("session", {
id: text("id").primaryKey(),
userId: text("user_id").references(() => user.id),
expiresAt: timestamp("expires_at").notNull(),
// ...
});

Why this matters: Auth tables are part of your database schema, fully typed and manageable through migrations.

Alternatives Considered​

Auth LibraryProsConsWhy Not Chosen
NextAuth.jsPopular, many providersNext.js-specific, complex setupBetter Auth is framework-agnostic and simpler
Auth.jsModular, flexibleSteeper learning curveBetter Auth provides better DX for our use case
LuciaLightweight, flexibleMore manual setupBetter Auth provides more out-of-the-box features
Custom AuthFull controlSecurity risks, time-consumingDon't roll your own auth security

LLM-Agnostic Architecture​

What is LLM-Agnostic Design?​

LLM-agnostic design means building AI features that work with any LLM provider (Claude, OpenAI, GLM, etc.) without being tied to a specific service.

Key Benefits​

1. Provider Flexibility​

// Define generic types
export interface LLMRequestOptions {
provider?: string; // "claude", "openai", "glm", etc.
model?: string;
temperature?: number;
maxTokens?: number;
}

// Functions accept any provider
export async function categorizeMessage(
message: string,
options: LLMRequestOptions = {}
): Promise<CategorizationResult> {
const client = options.llm || defaultClient;
// Works with any provider
return await client.query(prompt, options);
}

Why this matters: Switch providers without rewriting code. Take advantage of new models or pricing without architectural changes.

2. Cost Optimization​

// Choose provider based on use case
const quickCategorization = await categorizeMessage(msg, {
provider: "openai", // Faster, cheaper
model: "gpt-4o-mini",
});

const deepAnalysis = await analyzeMessage(msg, {
provider: "claude", // Better reasoning
model: "claude-3-5-sonnet-20241022",
});

Why this matters: Use the right tool for the job. Save costs with cheaper models for simple tasks, premium models for complex analysis.

3. Unified Client Interface​

// All providers implement the same interface
export interface LLMClient {
query(prompt: string, options?: LLMRequestOptions): Promise<LLMResponse>;
stream(prompt: string, options?: LLMRequestOptions): AsyncIterable<LLMStreamChunk>;
}

// Add new providers without changing application code
class GLMProvider implements BaseLLMProvider {
/* ... */
}
class GeminiProvider implements BaseLLMProvider {
/* ... */
}

Why this matters: Add new AI providers by implementing a single interface. No changes to application code.

4. Prompt Reusability​

// Prompts work with any provider
export function createCategorizationPrompt(message: string, categories: Category[]): string {
return `
You are an expert message categorization assistant.

Task: Categorize this message into ONE of the following categories:
${categories.map((c) => `- ${c.name}: ${c.description}`).join("\n")}

Response Format (JSON only):
{
"category": "category name",
"confidence": 0.0 to 1.0,
"reasoning": "brief explanation"
}

Message: "${message}"
`;
}

Why this matters: Write prompts once, use with any provider. Provider-specific features are minimal and isolated.

5. Error Handling Abstraction​

// Standardized error types
export class LLMError extends Error {
constructor(
message: string,
public provider?: string,
public model?: string,
public cause?: Error
) {
super(message);
this.name = "LLMError";
}
}

// Provider-agnostic error handling
try {
return await llm.query(prompt, options);
} catch (error) {
if (isRateLimitError(error)) {
// Handle rate limit consistently across providers
return await retryWithFallback(prompt, options);
}
throw new LLMError("LLM request failed", options.provider, options.model, error);
}

Why this matters: Consistent error handling regardless of provider. Easier debugging and monitoring.

Implementation in AllMyChats​

// src/lib/llm/index.ts
export { LLMClient, createLLMClient } from "./client";
export { ClaudeProvider, OpenAIProvider, GLMProvider } from "./providers";

// Usage in features
import { llm } from "~/lib/llm";

const result = await llm.query(categorizationPrompt, {
provider: "claude", // Configurable via environment
model: "claude-3-5-sonnet-20241022",
temperature: 0.3,
});

Alternatives Considered​

ApproachProsConsWhy Not Chosen
Provider-specificUses provider best featuresLocked in, hard to switchLimits flexibility and negotiating power
LangChainRich ecosystemHeavy, complex, opinionatedCustom abstraction is lighter and more flexible
Vercel AI SDKGood DX, streamingVercel-focusedGeneric approach works better for our needs

Documentation​

See LLM-Agnostic Development Guide for complete implementation guidelines and best practices.


File Storage Strategy (R2/S3)​

What is Cloudflare R2?​

Cloudflare R2 is an S3-compatible object storage service with zero egress fees. It's designed for storing and serving large files like images, videos, and audio.

Architecture: Presigned URLs​

AllMyChats uses a presigned URL approach for file uploads:

┌─────────┐                 ┌─────────┐                 ┌─────────┐
│ Client │ ──(1) Request──▶ │ Server │ ──(2) Generate──▶ R2 │
│ │ Upload URL │ │ Presigned URL │
│ │ ◀──(3) Return─── │ │ ◀──(4) Return────────── │
│ │ Upload URL │ │ Presigned URL │
└─────────┘ └─────────┘ └─────────┘
│ │
│ ────(5) Upload File Directly to R2 ─────────────────▶
│ (using presigned URL)

Key Benefits​

1. Reduced Server Load​

Files are uploaded directly from client to R2, bypassing the server:

// Server: Generate presigned URL (lightweight)
const uploadUrl = await r2.getPresignedUploadUrl(key, contentType);

// Client: Upload directly to R2
await fetch(uploadUrl, {
method: "PUT",
body: file,
headers: { "Content-Type": contentType },
});

Why this matters: Server doesn't handle large file payloads. Reduces memory usage and improves scalability.

2. Client-Side Progress Tracking​

Direct uploads enable real-time progress tracking:

export async function uploadFileWithProgress(
file: File,
url: string,
onProgress: (progress: number) => void
): Promise<void> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();

xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable) {
onProgress((e.loaded / e.total) * 100);
}
});

xhr.addEventListener("load", () => resolve());
xhr.addEventListener("error", () => reject(new Error("Upload failed")));

xhr.open("PUT", url);
xhr.send(file);
});
}

Why this matters: Better UX with real-time upload progress bars.

3. Controlled Access​

Server controls who can upload and what they can upload:

// Protected upload endpoint
export const getUploadUrlFn = createServerFn({ method: "POST" })
.middleware([authenticatedMiddleware]) // Require auth
.inputValidator(
z.object({
contentType: z.string(),
fileSize: z.number(),
})
)
.handler(async ({ input, context }) => {
// Validate file type and size
if (!ALLOWED_TYPES.includes(input.contentType)) {
throw new Error("Invalid file type");
}

if (input.fileSize > MAX_SIZE) {
throw new Error("File too large");
}

// Generate user-specific upload path
const key = `uploads/${context.userId}/${nanoid()}`;

return {
uploadUrl: await r2.getPresignedUploadUrl(key, input.contentType),
key,
};
});

Why this matters: Centralized validation and authorization. Users can only upload to their designated locations.

4. Scalability​

R2/S3 handles unlimited file storage and bandwidth:

// Storage structure
bucket/
├── music/
│ └── {userId}/
│ └── {songId}/
│ ├── audio.{ext}
│ └── cover.{ext}
├── profile-images/
│ └── {userId}/
│ └── {timestamp}.{ext}
└── uploads/
└── {userId}/
└── {timestamp}.{ext}

Why this matters: No need to provision storage or manage disk space. Scales automatically with user base.

5. Cost Efficiency​

Cloudflare R2 has zero egress fees, making it cost-effective for serving files:

OperationS3 (us-east-1)Cloudflare R2
Storage$0.023/GB$0.015/GB
Egress$0.09/GB$0.00/GB ✅
Class A Operations$0.005/1K requests$4.50/M requests

Why this matters: Serving media files (audio, images) doesn't incur bandwidth costs, significantly reducing monthly bills.

Alternatives Considered​

Storage MethodProsConsWhy Not Chosen
Server disk storageSimple, fastLimited space, backups neededDoesn't scale, single point of failure
AWS S3Mature, feature-richHigh egress feesR2 provides same features at lower cost
Cloudflare ImagesOptimized imagesImages onlyNeed to store multiple file types
Google Cloud StorageGood integrationEgress feesR2 is more cost-effective

Documentation​

See the project's file-uploads documentation for complete implementation details.


Layered Architecture Pattern​

What is Layered Architecture?​

Layered architecture organizes code into distinct layers with clear responsibilities. Each layer only depends on the layer below it.

Architecture Layers​

┌─────────────────────────────────────────────────────────────┐
│ 1. Routes Layer (src/routes/) │
│ - URL routing, route loaders, page composition │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 2. Components Layer (src/components/) │
│ - UI rendering, user interactions, local state │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 3. Hooks Layer (src/hooks/) │
│ - State management, data fetching, reusable logic │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 4. Queries Layer (src/queries/) │
│ - TanStack Query configs, caching, invalidation │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 5. Server Functions Layer (src/server/routers/) │
│ - tRPC procedures, validation, auth, business logic │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 6. Data Access Layer (src/data-access/) │
│ - Database operations, transactions, CRUD │
└─────────────────────────────────────────────────────────────┘

Key Benefits​

1. Separation of Concerns​

Each layer has a single, clear responsibility:

// Route: Only handles routing
export const Route = createFileRoute("/messages/$id")({
component: MessagePage,
});

// Component: Only handles UI
function MessagePage() {
const { data: message } = useMessage(); // Delegate to hook
return <MessageDisplay message={message} />;
}

// Hook: Manages state and data fetching
function useMessage(id: string) {
return trpc.messages.get.useQuery({ id }); // Delegate to tRPC
}

// tRPC: Handles API endpoint
const messagesRouter = router({
get: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
return await getMessageById(input.id); // Delegate to data access
}),
});

// Data Access: Database operations
export async function getMessageById(id: string) {
const [message] = await database
.select()
.from(messageTable)
.where(eq(messageTable.id, id));
return message;
}

Why this matters: Each piece of code has a clear job. Easier to understand, test, and modify.

2. Testability​

Each layer can be tested independently:

// Test data access without server
describe("getMessageById", () => {
it("returns message by ID", async () => {
const message = await getMessageById("123");
expect(message).toBeDefined();
expect(message.id).toBe("123");
});
});

// Test tRPC procedure with mocked data access
describe("messages.get", () => {
it("returns message", async () => {
vi.mock("~/data-access/messages", () => ({
getMessageById: vi.fn().mockResolvedValue(mockMessage),
}));

const result = await messagesRouter.get.get({ input: { id: "123" } });
expect(result).toEqual(mockMessage);
});
});

// Test component with mocked hook
describe("MessagePage", () => {
it("renders message", () => {
vi.mock("~/hooks/useMessage", () => ({
useMessage: vi.fn().mockReturnValue({ data: mockMessage }),
}));

render(<MessagePage />);
expect(screen.getByText(mockMessage.content)).toBeInTheDocument();
});
});

Why this matters: Easier to write tests. Mock lower layers to test upper layers in isolation.

3. Reusability​

Code in lower layers can be reused across multiple routes:

// Data access function used by multiple tRPC procedures
export async function findMessagesByUser(userId: string) {
return await database.select().from(messageTable).where(eq(messageTable.userId, userId));
}

// Used in multiple routers
const messagesRouter = router({
list: publicProcedure.query(async ({ ctx }) => {
return findMessagesByUser(ctx.userId);
}),
});

const searchRouter = router({
messages: publicProcedure.query(async ({ ctx }) => {
return findMessagesByUser(ctx.userId);
}),
});

Why this matters: Less code duplication. Changes to business logic only need to be made in one place.

4. Dependency Direction​

Dependencies flow in one direction (downwards):

Routes → Components → Hooks → Queries → Server → Data Access
↓ ↓ ↓ ↓ ↓ ↓
No No No No No No
circular circular circular circular circular dependencies

Why this matters: Prevents circular dependencies and coupling. Easier to refactor and maintain.

5. Easy to Navigate​

Clear folder structure makes finding code easy:

src/
├── routes/ # "Where is the /messages page?" → routes/messages.tsx
├── components/ # "Where is the MessageCard component?" → components/MessageCard.tsx
├── hooks/ # "Where is the useMessages hook?" → hooks/useMessages.ts
├── server/routers/ # "Where is the messages API?" → server/routers/messages.ts
└── data-access/ # "Where is the getMessages DB query?" → data-access/messages.ts

Why this matters: New developers can quickly understand the codebase structure.

Communication Rules​

  1. Never skip layers: Components shouldn't call data access directly
  2. No circular dependencies: Lower layers never import from upper layers
  3. Single responsibility: Each layer has one clear purpose
  4. Error handling: Each layer handles errors appropriate to its level

Documentation​

See Architecture Guide for complete details on the layered architecture pattern.


Summary of Technical Decisions​

DecisionPrimary BenefitTrade-off
TanStack StartEnd-to-end type safety, unified DXNewer framework, smaller community
tRPCType-safe API without schema duplicationLess standard than REST
PostgreSQL + DrizzleType-safe queries, powerful featuresPostgreSQL required (not SQLite)
Better AuthSimple, type-safe authNewer library than NextAuth
LLM-AgnosticProvider flexibility, cost optimizationMore abstraction layer
R2 StorageZero egress fees, scalableCloudflare-specific (can switch to S3)
Layered ArchitectureClear separation, testabilityMore files, deeper folder structure

These decisions prioritize developer experience, type safety, and scalability while keeping the codebase maintainable and flexible for future growth.



Questions or Suggestions?​

If you have questions about these design decisions or suggestions for improvements, please open an issue on GitHub.