Organizing Backend Code
It’s much easier to move fast as the codebase grows if you’re already using patterns that scale. That’s why we use a layered architecture even if its overkill at the start: Routes → Services → Repositories → Database.
Repositories
Repositories handle database queries. They provide a clean abstraction over a potentially complex schema. For example, fetching messages requires joining across multiple tables (messages, parts, text parts, image parts, tool invocations):
async function getMessagesByChatIdRaw(chatId: string) { return db.query.messages.findMany({ where: eq(messages.chatId, chatId), orderBy: [asc(messages.createdAt)], with: { parts: { orderBy: [asc(messageParts.order)], with: { textPart: true, imagePart: { with: { file: true } }, toolInvocationPart: true, }, }, }, });}The caller doesn’t need to know about the underlying table structure - they just call MessageRepository.getMessagesByChatIdRaw(chatId).
Services
Services handle authorization and transform data for the caller. Here’s the service that uses the repository above:
async function getMessagesForChat(chatId: string, userId: string) { // Verify chat ownership before fetching const hasAccess = await ChatRepository.verifyChatOwnership(chatId, userId); if (!hasAccess) { throw new Error("Unauthorized: Chat not found or access denied"); }
const rawMessages = await MessageRepository.getMessagesByChatIdRaw(chatId); return transformToNormalizedMessages(rawMessages);}The service checks authorization, calls the repository, and transforms the raw database format into something the UI can use.
Server Functions
Server functions are the entry point from the client - they handle auth middleware and call services:
export const getMessages = createServerFn() .middleware([useSessionTokenClientMiddleware, ensureUserMiddleware]) .inputValidator(getMessagesSchema) .handler(async ({ data, context }) => { const messages = await MessageService.getMessagesForChat( data.chatId, context.userId, ); return MessageService.toUIMessages(messages); });