Architecture

Understand how Quackback's layers fit together.

DU
Demo User
Written By Demo UserLast updated 2 months ago

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    │  │  pg_cron     │   │
│  └──────────────┘  └──────────────┘  └──────────────┘   │
└─────────────────────────────────────────────────────────┘

Tech Stack

Layer

Technology

Purpose

Framework

TanStack Start

Full-stack React framework

Routing

TanStack Router

File-based routing, type-safe

Database

PostgreSQL 18+

Primary data store

ORM

Drizzle

Type-safe queries

Auth

Better Auth

Authentication library

Styling

Tailwind CSS v4

Utility-first CSS

Components

shadcn/ui

UI component library

Validation

Zod

Runtime type validation

State

TanStack Query

Server state management

Runtime

Bun

JavaScript 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

Note:
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. Never enqueues BullMQ directly.
  await tx.insert(events).values({ /* ... */ })
}

A leader-elected relay (see durable event outbox below) drains unpublished rows into BullMQ, which fans 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

Two separate auth flows:

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

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 constructs BullMQ workers 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. A leader-elected relay process (worker-role, elected via a Postgres advisory lock so multiple worker replicas stay safe) polls the outbox and fans each row out into BullMQ jobs for webhooks, notifications, and workflow triggers, then marks the row published. This is the sole delivery path for events; nothing enqueues BullMQ directly.

Next Steps

Was this helpful?

Your feedback shapes what we write next.