Skip to content

Architecture

Know where everything lives. This overview shows how Quackback's layers fit together: routes, server functions, services, and database. Navigate confidently and make changes in the right places.

High-Level Architecture

┌─────────────────────────────────────────────────────────┐
│                     Browser/Client                       │
│                                                          │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐   │
│  │  Public      │  │  Admin       │  │  Auth        │   │
│  │  Portal      │  │  Dashboard   │  │  Pages       │   │
│  └──────────────┘  └──────────────┘  └──────────────┘   │
└────────────────────────────┬────────────────────────────┘
                             │
                             ▼
┌─────────────────────────────────────────────────────────┐
│                  TanStack Start Server                   │
│                                                          │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐   │
│  │  Routes      │  │  Server      │  │  API         │   │
│  │  (SSR)       │  │  Functions   │  │  Routes      │   │
│  └──────────────┘  └──────────────┘  └──────────────┘   │
│                             │                            │
│  ┌──────────────┐  ┌────────┴───────┐  ┌─────────────┐  │
│  │  Services    │  │  Auth          │  │  Events     │  │
│  │  Layer       │  │  (Better Auth) │  │  System     │  │
│  └──────────────┘  └────────────────┘  └─────────────┘  │
└────────────────────────────┬────────────────────────────┘
                             │
                             ▼
┌─────────────────────────────────────────────────────────┐
│                     PostgreSQL                           │
│                                                          │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐   │
│  │  Drizzle ORM │  │  pgvector    │  │  Job queue   │   │
│  └──────────────┘  └──────────────┘  └──────────────┘   │
└─────────────────────────────────────────────────────────┘

Tech Stack

LayerTechnologyPurpose
FrameworkTanStack StartFull-stack React framework
RoutingTanStack RouterFile-based routing, type-safe
DatabasePostgreSQL 14+Data, job queue, cache, and realtime (LISTEN/NOTIFY)
ORMDrizzleType-safe queries
AuthBetter AuthAuthentication library
StylingTailwind CSS v4Utility-first CSS
Componentsshadcn/uiUI component library
ValidationZodRuntime type validation
StateTanStack QueryServer state management
RuntimeBunJavaScript runtime

Directory Structure

apps/web/

The main application:

apps/web/src/
├── routes/                    # File-based routing
│   ├── __root.tsx             # Root layout
│   ├── _portal/               # Public portal routes
│   ├── admin/                 # Admin dashboard
│   ├── auth.*/                # Portal auth
│   ├── admin.login/           # Team auth
│   ├── onboarding/            # Setup wizard
│   └── api/                   # API routes
│
├── components/                # React components
│   ├── admin/                 # Admin-specific
│   ├── public/                # Portal components
│   ├── settings/              # Settings UI
│   └── ui/                    # shadcn/ui primitives
│
├── lib/                       # Business logic
│   ├── server/                # Server-side code
│   │   ├── functions/         # RPC endpoints (server functions)
│   │   ├── domains/           # Service layers by feature
│   │   │   ├── posts/         # Post service
│   │   │   ├── boards/        # Board service
│   │   │   ├── comments/      # Comment service
│   │   │   ├── statuses/      # Status service
│   │   │   ├── tags/          # Tag service
│   │   │   ├── members/       # Member service
│   │   │   ├── users/         # User service
│   │   │   ├── notifications/ # Notification service
│   │   │   ├── subscriptions/ # Subscription service
│   │   │   ├── ai/            # AI integrations
│   │   │   ├── sentiment/     # Sentiment analysis
│   │   │   ├── embeddings/    # Vector embeddings
│   │   │   ├── summary/       # AI post summaries
│   │   │   ├── merge-suggestions/ # AI duplicate detection
│   │   │   ├── changelog/     # Changelog entries
│   │   │   ├── roadmaps/      # Roadmap management
│   │   │   ├── api-keys/      # API key management
│   │   │   └── webhooks/      # Webhook management
│   │   ├── auth/              # Auth configuration
│   │   ├── events/            # Event dispatch & handlers
│   │   ├── integrations/      # Integration handlers
│   │   ├── storage/           # File storage
│   │   └── db.ts              # Database proxy (lazy singleton)
│   │
│   ├── client/                # Client-side code
│   │   ├── hooks/             # React hooks
│   │   ├── queries/           # TanStack Query factories
│   │   ├── mutations/         # TanStack mutation hooks
│   │   ├── stores/            # Zustand stores
│   │   └── query/             # Query client setup
│   │
│   └── shared/                # Utilities & errors
│
└── router.tsx                 # Router configuration

packages/

Shared code:

packages/
├── db/                        # Database layer
│   ├── src/
│   │   ├── schema/            # Drizzle table definitions
│   │   ├── client.ts          # Connection factory
│   │   ├── seed.ts            # Demo data seeding
│   │   └── migrate.ts         # Migration runner
│   └── drizzle/               # Migration SQL files
│
├── ids/                       # TypeID system
│   └── src/
│       ├── index.ts           # ID generation & exports
│       ├── core.ts            # Core ID functions
│       ├── prefixes.ts        # Entity type prefixes
│       ├── types.ts           # Branded type definitions
│       ├── zod.ts             # Validation helpers
│       └── drizzle.ts         # DB column types
│
├── email/                     # Email service
│   └── src/
│       ├── index.ts           # Send functions
│       └── templates/         # React Email templates
│
├── core/                      # Shared core utilities
│
└── integrations/              # Integration connectors

Key Patterns

Server Functions

Type-safe RPC between client and server:

// lib/server/functions/posts.ts
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
 
export const createPostFn = createServerFn({ method: 'POST' })
  .inputValidator(z.object({
    title: z.string().min(1),
    content: z.string(),
    boardId: boardIdSchema,
  }))
  .handler(async ({ data }) => {
    const auth = await requireAuth()
    return createPost(data, auth.member)
  })

Usage in components:

const mutation = useMutation({
  mutationFn: createPostFn,
})

Service Layer

Business logic separate from transport:

// lib/server/domains/posts/post.service.ts
export async function createPost(
  input: CreatePostInput,
  author: Author
): Promise<PostWithDetails> {
  // Validation
  if (!input.title?.trim()) {
    throw new ValidationError('VALIDATION_ERROR', 'Title required')
  }
 
  // Business logic
  const post = await db.insert(posts).values({
    id: createId('post'),
    ...input,
    authorId: author.id,
  }).returning()
 
  // Side effects
  await dispatchPostCreated(post, author)
 
  return post
}

Database Access

Always use the database proxy from @/lib/server/db for database access to ensure proper connection management.

// ✅ Correct - use the proxy
import { db, posts, eq } from '@/lib/server/db'
 
// ❌ Wrong - bypasses tenant context
import { db } from '@quackback/db'

The proxy handles singleton connection initialization with lazy loading.

TypeIDs

Branded UUIDs for type safety:

import { createId, type PostId, type BoardId } from '@quackback/ids'
 
// Create new ID
const postId: PostId = createId('post')
// => "post_01h455vb4pex5vsknk084sn02q"
 
// Type-safe function parameters
function getPost(id: PostId): Promise<Post> {
  // Can't accidentally pass a BoardId here
}

Event System

Domain events write to a durable outbox table, in the same database transaction as the mutation that raised them:

// lib/server/events/emit.ts
export async function emit<P>(tx: Transaction, def: EventDefinition<P>, input: EmitInput<P>) {
  // INSERTs one row into `events` on the caller's transaction, so the event
  // commits atomically with the mutation, plus a dispatch job on the same transaction.
  await tx.insert(events).values({ /* ... */ })
}

A dispatch job on the Postgres job queue (see durable event outbox below) fans each event out to webhooks, notifications, and workflow triggers. The request that raised the event never waits on hook delivery.

Hook Handlers

Extensible integration points:

// lib/server/events/handlers/webhook.ts (or similar handler file)
export const slackHandler: HookHandler = {
  async run(event, target, config) {
    const message = formatSlackMessage(event)
    await slack.chat.postMessage({
      channel: config.channelId,
      ...message,
    })
    return { success: true }
  },
}

Route Groups

Public Portal (_portal/)

User-facing feedback portal:

  • Board listing
  • Post detail with comments
  • Voting interface
  • Public roadmap

Admin Dashboard (admin/)

Team management interface:

  • Feedback inbox
  • Roadmap management
  • Settings configuration

Authentication

Sign-in routes:

  • auth.* - Portal users (password, email OTP, OAuth, OIDC)
  • admin.login - Team members (password, email OTP, OAuth, OIDC)

Sessions also record where the sign-in came from (dashboard, widget, or portal). Only a host/dashboard sign-in can use team permissions. Widget and portal-from-widget sign-ins always act as customers, even if that person is later invited as admin or member. See Session types.

Data Flow

Create Post Flow

User clicks "Submit" in portal
        │
        ▼
Component calls createPostFn()
        │
        ▼
Server function validates input with Zod
        │
        ▼
requireAuth() checks session
        │
        ▼
createPost() service function
        │
        ▼
Insert into database via Drizzle
        │
        ▼
dispatchPostCreated() event
        │
        ▼
processEvent() runs hooks (async)
        │
        ├──▶ Slack notification
        ├──▶ Email to subscribers
        └──▶ AI sentiment analysis
        │
        ▼
Return post to client
        │
        ▼
React Query updates cache

Deployment

Quackback runs as a single workspace per deployment:

  • DATABASE_URL environment variable
  • Singleton database connection
  • No tenant resolution needed

Process roles

Background processing doesn't have to share a process with HTTP serving. Every Quackback process reads QUACKBACK_ROLE (all, web, or worker) to decide whether it claims jobs from the Postgres job queue and runs the periodic sweepers, independent of whether it serves HTTP. The single-container default (all) does both in one process; splitting web and worker replicas lets you scale HTTP capacity without also scaling queue consumption, or vice versa. See Scale with multiple replicas.

Durable event outbox

Domain events (post.created, conversation.message.created, and so on) are written to an events outbox table in the same transaction as the mutation that raised them, so an event can never exist without the change it describes, and never gets lost to a crash between the two. The same transaction inserts an event-dispatch row into the job_queue table, and a trigger sends a NOTIFY on commit so a worker picks it up at once. Workers claim jobs with row locks, so several worker replicas never run the same job twice, and a periodic sweep recovers anything a lost notification missed. The dispatch job fans the event out to webhooks, notifications, and workflow triggers, then marks the row published. There is no Redis or external queue.

Next Steps