# Senior Fullstack

> Expert fullstack architecture skill covering advanced backend patterns, infrastructure, testing, and system design. Use this skill whenever the user is working on fullstack architecture decisions, API design, background job queues, caching strategies, database connection pooling, CI/CD pipelines, Docker containerization, end-to-end testing with Playwright, WebSocket real-time features, or deployment configurations. Trigger on mentions of Redis, BullMQ, Prisma, Drizzle, PostgreSQL, Docker, GitHub Actions, connection pooling, rate limiting, Playwright, or any backend/infra topic that goes beyond simple CRUD. Also trigger for performance bottlenecks, scalability concerns, or production readiness reviews.

- Skill: `roedyrustam/senior-fullstack` (Agent Skill)
- Install (CLI): `npx skillmds@latest add roedyrustam/senior-fullstack`
- Raw SKILL.md: https://api.skillmd.com/api/skills/roedyrustam/senior-fullstack/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: roedyrustam (https://skillmd.com/u/roedyrustam)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/roedyrustam/senior-fullstack

---


# Senior Fullstack Engineer Skill

Advanced architectural patterns, infrastructure, testing, and production-grade system design.

---

## Core Stack

| Layer | Technology |
|-------|-----------|
| Framework | Next.js 15 (App Router) |
| ORM | Drizzle ORM or Prisma |
| Database | PostgreSQL (Supabase / Neon / RDS) |
| Cache / Queue | Redis + BullMQ |
| Auth | Clerk or NextAuth v5 |
| Payments | Stripe |
| Testing | Playwright (E2E) + Vitest (unit) |
| CI/CD | GitHub Actions |
| Containers | Docker + Docker Compose |
| Monitoring | Sentry + OpenTelemetry |

---

## Database Layer

### Drizzle ORM (Preferred — Type-Safe, Lightweight)
```typescript
// lib/db/schema.ts
import { pgTable, text, timestamp, uuid, boolean, index } from 'drizzle-orm/pg-core'

export const users = pgTable('users', {
  id: uuid('id').primaryKey().defaultRandom(),
  clerkId: text('clerk_id').notNull().unique(),
  email: text('email').notNull().unique(),
  name: text('name').notNull(),
  createdAt: timestamp('created_at').notNull().defaultNow(),
  updatedAt: timestamp('updated_at').notNull().defaultNow(),
}, (table) => ({
  clerkIdIdx: index('users_clerk_id_idx').on(table.clerkId),
  emailIdx: index('users_email_idx').on(table.email),
}))

export const posts = pgTable('posts', {
  id: uuid('id').primaryKey().defaultRandom(),
  userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
  title: text('title').notNull(),
  slug: text('slug').notNull().unique(),
  content: text('content'),
  published: boolean('published').notNull().default(false),
  publishedAt: timestamp('published_at'),
  createdAt: timestamp('created_at').notNull().defaultNow(),
}, (table) => ({
  userIdIdx: index('posts_user_id_idx').on(table.userId),
  slugIdx: index('posts_slug_idx').on(table.slug),
}))
```

### Database Connection Pooling
```typescript
// lib/db/index.ts
import { drizzle } from 'drizzle-orm/node-postgres'
import { Pool } from 'pg'
import * as schema from './schema'

// Single pool instance — critical for connection pooling
const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 20,                    // Max connections
  idleTimeoutMillis: 30_000,  // Close idle connections after 30s
  connectionTimeoutMillis: 2_000,
})

export const db = drizzle(pool, { schema })

// For serverless (Neon, Supabase with pgBouncer)
// Use neon-serverless driver instead:
// import { neon } from '@neondatabase/serverless'
// const sql = neon(process.env.DATABASE_URL!)
// export const db = drizzle(sql, { schema })
```

### Repository Pattern
```typescript
// lib/db/repositories/post.repository.ts
import { db } from '@/lib/db'
import { posts, users } from '@/lib/db/schema'
import { eq, desc, and, sql } from 'drizzle-orm'

export class PostRepository {
  async findPublished(limit = 10, offset = 0) {
    return db
      .select({
        id: posts.id,
        title: posts.title,
        slug: posts.slug,
        publishedAt: posts.publishedAt,
        author: { name: users.name },
      })
      .from(posts)
      .innerJoin(users, eq(posts.userId, users.id))
      .where(eq(posts.published, true))
      .orderBy(desc(posts.publishedAt))
      .limit(limit)
      .offset(offset)
  }

  async findBySlug(slug: string) {
    const [post] = await db
      .select()
      .from(posts)
      .where(eq(posts.slug, slug))
      .limit(1)
    return post ?? null
  }

  async create(data: typeof posts.$inferInsert) {
    const [post] = await db.insert(posts).values(data).returning()
    return post
  }

  async incrementView(id: string) {
    await db
      .update(posts)
      .set({ viewCount: sql`${posts.viewCount} + 1` })
      .where(eq(posts.id, id))
  }
}

export const postRepository = new PostRepository()
```

---

## Redis Caching

### Setup & Client
```typescript
// lib/redis.ts
import { Redis } from 'ioredis'

let redis: Redis

if (process.env.NODE_ENV === 'production') {
  redis = new Redis(process.env.REDIS_URL!, {
    maxRetriesPerRequest: 3,
    enableReadyCheck: false,
    lazyConnect: true,
  })
} else {
  // Singleton in dev to prevent hot-reload creating new connections
  const globalForRedis = globalThis as { redis?: Redis }
  globalForRedis.redis ??= new Redis(process.env.REDIS_URL!)
  redis = globalForRedis.redis
}

export { redis }
```

### Cache-Aside Pattern
```typescript
// lib/cache.ts
import { redis } from './redis'

export async function cached<T>(
  key: string,
  fn: () => Promise<T>,
  ttlSeconds = 60
): Promise<T> {
  const cached = await redis.get(key)
  if (cached) return JSON.parse(cached) as T

  const result = await fn()
  await redis.setex(key, ttlSeconds, JSON.stringify(result))
  return result
}

export function cacheKey(...parts: string[]) {
  return parts.join(':')
}

// Usage
const posts = await cached(
  cacheKey('posts', 'published', page.toString()),
  () => postRepository.findPublished(10, offset),
  300 // 5 minutes
)
```

### Cache Invalidation Strategy
```typescript
// Granular tag-based invalidation
export async function invalidatePostCache(postId: string, slug: string) {
  const keys = await redis.keys(`posts:*`)
  if (keys.length > 0) await redis.del(...keys)
  await redis.del(`post:${slug}`, `post:${postId}`)
}
```

---

## Background Jobs with BullMQ

### Queue Setup
```typescript
// lib/queues/index.ts
import { Queue, Worker, QueueEvents } from 'bullmq'
import { redis } from '@/lib/redis'

const connection = { connection: redis }

// Define queues
export const emailQueue = new Queue('email', connection)
export const imageQueue = new Queue('image-processing', connection)
export const webhookQueue = new Queue('webhooks', connection)

// Job types
export type EmailJobData = {
  to: string
  subject: string
  template: 'welcome' | 'reset-password' | 'invoice'
  variables: Record<string, string>
}
```

### Worker
```typescript
// workers/email.worker.ts
import { Worker } from 'bullmq'
import { redis } from '@/lib/redis'
import { sendEmail } from '@/lib/email'
import type { EmailJobData } from '@/lib/queues'

export const emailWorker = new Worker<EmailJobData>(
  'email',
  async (job) => {
    const { to, subject, template, variables } = job.data

    await job.updateProgress(10)

    const html = await renderEmailTemplate(template, variables)

    await job.updateProgress(50)

    await sendEmail({ to, subject, html })

    await job.updateProgress(100)

    return { sent: true, to }
  },
  {
    connection: { connection: redis },
    concurrency: 5,
    limiter: { max: 100, duration: 60_000 }, // 100 emails/min
  }
)

emailWorker.on('completed', (job, result) => {
  console.log(`Email job ${job.id} completed`, result)
})

emailWorker.on('failed', (job, err) => {
  console.error(`Email job ${job?.id} failed:`, err)
})
```

### Enqueue from Server Actions
```typescript
// app/actions/auth.actions.ts
'use server'
import { emailQueue } from '@/lib/queues'

export async function sendWelcomeEmail(userId: string) {
  const user = await userRepository.findById(userId)
  if (!user) return

  await emailQueue.add(
    'welcome-email',
    {
      to: user.email,
      subject: 'Welcome!',
      template: 'welcome',
      variables: { name: user.name },
    },
    {
      attempts: 3,
      backoff: { type: 'exponential', delay: 5000 },
      removeOnComplete: 100,  // Keep last 100 completed
      removeOnFail: 500,
    }
  )
}
```

---

## API Design (Route Handlers)

### Typed Route Handler
```typescript
// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@clerk/nextjs/server'
import { z } from 'zod'
import { postRepository } from '@/lib/db/repositories/post.repository'

const createPostSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().optional(),
  published: z.boolean().default(false),
})

export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url)
  const page = Math.max(1, Number(searchParams.get('page') ?? 1))
  const limit = Math.min(50, Number(searchParams.get('limit') ?? 10))

  const posts = await postRepository.findPublished(limit, (page - 1) * limit)
  return NextResponse.json({ data: posts, page, limit })
}

export async function POST(request: NextRequest) {
  const { userId } = await auth()
  if (!userId) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  const body = await request.json()
  const parsed = createPostSchema.safeParse(body)
  if (!parsed.success) {
    return NextResponse.json(
      { error: 'Validation failed', details: parsed.error.flatten() },
      { status: 422 }
    )
  }

  const post = await postRepository.create({ ...parsed.data, userId })
  return NextResponse.json({ data: post }, { status: 201 })
}
```

### Rate Limiting Middleware
```typescript
// middleware.ts
import { NextResponse, type NextRequest } from 'next/server'
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(10, '10 s'),
  analytics: true,
})

export async function middleware(request: NextRequest) {
  if (request.nextUrl.pathname.startsWith('/api/')) {
    const ip = request.ip ?? '127.0.0.1'
    const { success, limit, reset, remaining } = await ratelimit.limit(ip)

    if (!success) {
      return NextResponse.json(
        { error: 'Too many requests' },
        {
          status: 429,
          headers: {
            'X-RateLimit-Limit': limit.toString(),
            'X-RateLimit-Remaining': remaining.toString(),
            'X-RateLimit-Reset': reset.toString(),
          },
        }
      )
    }
  }
}

export const config = {
  matcher: '/api/:path*',
}
```

---

## Testing

### Playwright E2E
```typescript
// tests/e2e/auth.spec.ts
import { test, expect } from '@playwright/test'

test.describe('Authentication flow', () => {
  test('user can sign up and access dashboard', async ({ page }) => {
    await page.goto('/sign-up')

    await page.fill('[name="email"]', 'test@example.com')
    await page.fill('[name="password"]', 'SecurePass123!')
    await page.click('[type="submit"]')

    await expect(page).toHaveURL('/dashboard')
    await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible()
  })

  test('protected routes redirect unauthenticated users', async ({ page }) => {
    await page.goto('/dashboard')
    await expect(page).toHaveURL(/\/sign-in/)
  })
})
```

```typescript
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'

export default defineConfig({
  testDir: './tests/e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: [['html'], ['github']],
  use: {
    baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'Mobile Safari', use: { ...devices['iPhone 14'] } },
  ],
  webServer: {
    command: 'pnpm dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
})
```

### Vitest Unit Tests
```typescript
// lib/__tests__/cache.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { cached } from '@/lib/cache'

vi.mock('@/lib/redis', () => ({
  redis: {
    get: vi.fn(),
    setex: vi.fn(),
  },
}))

describe('cached()', () => {
  beforeEach(() => vi.clearAllMocks())

  it('returns cached value when cache hit', async () => {
    const { redis } = await import('@/lib/redis')
    vi.mocked(redis.get).mockResolvedValue(JSON.stringify({ id: 1 }))

    const fn = vi.fn().mockResolvedValue({ id: 1 })
    const result = await cached('test-key', fn)

    expect(result).toEqual({ id: 1 })
    expect(fn).not.toHaveBeenCalled()
  })

  it('calls fn and caches result on miss', async () => {
    const { redis } = await import('@/lib/redis')
    vi.mocked(redis.get).mockResolvedValue(null)

    const fn = vi.fn().mockResolvedValue({ id: 2 })
    const result = await cached('test-key', fn, 60)

    expect(result).toEqual({ id: 2 })
    expect(redis.setex).toHaveBeenCalledWith('test-key', 60, JSON.stringify({ id: 2 }))
  })
})
```

---

## Docker & CI/CD

### Multi-Stage Dockerfile
```dockerfile
# Dockerfile
FROM node:20-alpine AS base
RUN apk add --no-cache libc6-compat
WORKDIR /app
RUN npm install -g pnpm

FROM base AS deps
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile

FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build

FROM base AS runner
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"

CMD ["node", "server.js"]
```

### GitHub Actions CI Pipeline
```yaml
# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: postgres
          POSTGRES_DB: testdb
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 5432:5432

    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v3
        with:
          version: 9
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Type check
        run: pnpm tsc --noEmit

      - name: Lint
        run: pnpm lint

      - name: Unit tests
        run: pnpm test
        env:
          DATABASE_URL: postgresql://postgres:postgres@localhost:5432/testdb

      - name: Run migrations
        run: pnpm db:migrate
        env:
          DATABASE_URL: postgresql://postgres:postgres@localhost:5432/testdb

      - name: Build
        run: pnpm build
        env:
          DATABASE_URL: postgresql://postgres:postgres@localhost:5432/testdb

  e2e:
    runs-on: ubuntu-latest
    needs: test
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v3
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'
      - run: pnpm install --frozen-lockfile
      - name: Install Playwright browsers
        run: pnpm playwright install --with-deps chromium
      - name: Run E2E tests
        run: pnpm playwright test
        env:
          BASE_URL: http://localhost:3000
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/
```

---

## Key Architectural Rules

1. **Repository pattern** for all DB access — never raw queries in route handlers or Server Actions
2. **Connection pool as singleton** — one `Pool` instance, not one per request
3. **BullMQ for anything async** — email, image processing, webhooks, notifications
4. **Redis cache-aside** — cache at the repository layer, not the component layer
5. **Zod validation** at every API boundary — route handlers AND Server Actions
6. **Rate limit all public APIs** — use Upstash Redis for serverless-safe limiting
7. **Multi-stage Docker builds** — never ship dev dependencies to production
8. **Test pyramid**: unit (Vitest) > integration > E2E (Playwright)
9. **CI gates on type check + lint + unit tests** before E2E
10. **Never commit secrets** — use environment variables, validate at startup

