AI & ML

Prompt Patterns That Make AI Code Better: The Vibe Coding Playbook

· 5 min read
SitePoint Premium
Stay Relevant and Grow Your Career in Tech
  • Premium Results
  • Publish articles on SitePoint
  • Daily curated jobs
  • Learning Paths
  • Discounts to dev tools
Start Free Trial

7 Day Free Trial. Cancel Anytime.

Developers working with AI coding tools regularly encounter the same failure modes: hallucinated imports, wrong architectural patterns, missing edge cases, and code that looks plausible but falls apart under real-world conditions. This playbook covers five core prompt patterns, each with a concrete before-and-after example showing the difference in generated code quality.

Table of Contents

Why Your AI Prompts Produce Bad Code (And How Patterns Fix It)

Developers working with AI coding tools regularly encounter the same failure modes: hallucinated imports, wrong architectural patterns, missing edge cases, and code that looks plausible but falls apart under real-world conditions. The root cause is not the model — it is the prompt. Vague, one-shot AI coding prompts produce vague, one-shot code, and most developers treat prompt engineering for coding as an informal, improvised process. The output reflects that.

Prompt patterns produce fewer hallucinated imports, closer convention alignment, and less manual cleanup.

Prompt patterns produce fewer hallucinated imports, closer convention alignment, and less manual cleanup. These are repeatable, named templates for structuring communication with language models, analogous to how design patterns standardize solutions to recurring software problems. They are broadly applicable across tools like Claude Code, Cursor, and ChatGPT, though results vary by model, context window, and tool-specific features.

This playbook covers five core prompt patterns, each with a concrete before-and-after example showing the difference in generated code quality.

Pattern 1: Context Priming

What It Is

Without upfront context, a model guesses at conventions, often defaulting to generic examples from training data rather than matching the specific codebase. Context Priming establishes the project's stack, conventions, constraints, and expectations before making any code request. Providing this framing at the start of a prompt gives the model relevant grounding before it begins generating, reducing reliance on training-data defaults.

Before and After

A vague prompt produces vague code:

Prompt: "Write a function to fetch user data"
// AI output — generic, untyped, no error handling
async function fetchUserData(userId) {
  const response = await fetch(`/api/users/${userId}`);
  const data = await response.json();
  return data;
}

A context-primed prompt produces targeted output:

Note: This example assumes @/lib/db exports a configured Drizzle ORM client and @/lib/schema exports a users table definition. These are project-specific path aliases that require paths configuration in your tsconfig.json and a working Drizzle ORM setup. See the Drizzle ORM documentation for configuration details.

Prompt: "We're in a Next.js 14 App Router project using TypeScript strict mode.
All data fetching uses server actions. Error handling follows our convention
of returning { data, error } discriminated unions — never throwing.
The User type is { id: string; email: string; role: 'admin' | 'member' }.

Write a server action to fetch a user by ID from our Postgres database using Drizzle ORM."
// AI output — typed, convention-aligned, proper error handling
"use server";
import "server-only";

import { db } from "@/lib/db";
import { users } from "@/lib/schema";
import { eq } from "drizzle-orm";
import { z } from "zod";

type User = { id: string; email: string; role: "admin" | "member" };
type Result<T> = { data: T; error: null } | { data: null; error: string };

const UserSchema = z.object({
  id: z.string(),
  email: z.string().email(),
  role: z.enum(["admin", "member"]),
});

export async function getUser(userId: string): Promise<Result<User>> {
  if (!userId?.trim()) {
    return { data: null, error: "Invalid userId" };
  }

  try {
    const rows = await db
      .select()
      .from(users)
      .where(eq(users.id, userId))
      .limit(1);

    if (!rows.length) return { data: null, error: "User not found" };

    const parsed = UserSchema.safeParse(rows[0]);
    if (!parsed.success) {
      console.error("[getUser] schema mismatch", {
        userId,
        issues: parsed.error.issues,
      });
      return { data: null, error: "User data invalid" };
    }

    return { data: parsed.data, error: null };
  } catch (e) {
    const message = e instanceof Error ? e.message : String(e);
    console.error("[getUser] db error", { userId, message });
    return { data: null, error: "Failed to fetch user" };
  }
}

The difference is structural. The primed version respects the project's ORM, error pattern, and type system. Runtime validation via Zod ensures that unexpected database values (such as an unrecognized role) are caught before reaching authorization logic, rather than silently passing through a type cast. The import "server-only" directive provides an additional safeguard against accidental client-side bundling. This output usually needs minimal follow-up corrections.

Pattern 2: Constraint Injection

What It Is

Constraint Injection explicitly states what the AI must not do: forbidden libraries, banned type constructs, performance budgets, and anti-patterns to avoid. Explicit negative constraints reduce unwanted behaviors such as hallucinated imports, though effectiveness varies across models.

Before and After

Unconstrained prompt: "Write a utility to debounce function calls in TypeScript"

This commonly produces output pulling in Lodash or inventing wrapper classes. Constraining the prompt changes the result:

Prompt: "Write a debounce utility in TypeScript.
Do not use any external libraries.
Do not use the `any` type.
Keep the implementation under 30 lines.
Handle the edge case where the debounced function is called after the component unmounts (return a cancel method)."
function debounce<Args extends unknown[]>(
  fn: (...args: Args) => void,
  delayMs: number
): { call: (...args: Args) => void; cancel: () => void } {
  if (delayMs < 0) throw new RangeError("delayMs must be non-negative");

  let timeoutId: ReturnType<typeof setTimeout> | null = null;

  // Note: The returned object's `call` and `cancel` methods share a single
  // timer via the closure. If you destructure and pass `call` to multiple
  // independent consumers, they will interfere with each other's timing.
  // Create separate debounce instances for independent use cases.
  return Object.freeze({
    call(...args: Args) {
      if (timeoutId) clearTimeout(timeoutId);
      timeoutId = setTimeout(() => {
        fn(...args);
        timeoutId = null;
      }, delayMs);
    },
    cancel() {
      if (timeoutId) {
        clearTimeout(timeoutId);
        timeoutId = null;
      }
    },
  });
}

Zero dependencies. No any. Cancel method included. The output honors every constraint because the prompt states each one explicitly.

Pattern 3: Incremental Scaffolding

What It Is

Large, multi-component requests crammed into a single prompt often produce muddled results — types conflict with the implementation, or error handling contradicts the interface contract. Incremental Scaffolding breaks these requests into sequenced micro-prompts where each step's output feeds the next. Sequencing forces the model to commit to decisions (types, interfaces, structure) before building on them.

Sequencing forces the model to commit to decisions (types, interfaces, structure) before building on them.

The Prompt Sequence

Step 1 Prompt: "Define TypeScript interfaces for a task management system:
Task (id, title, status, assigneeId, createdAt), TaskFilter, and
a TaskRepository interface with findAll, findById, create, and update methods."

After receiving the interfaces, feed them into the next prompt:

Step 2 Prompt: "Using the interfaces above, implement an InMemoryTaskRepository
class that satisfies the TaskRepository interface. Use a Map<string, Task>
for storage."

Note: In-memory Map storage is suitable for testing and prototyping only. Production use requires a persistent store with proper memory bounds and concurrency handling.

Then build on the working implementation:

Step 3 Prompt: "Add error handling to InMemoryTaskRepository: throw a typed
NotFoundError on missing tasks in findById and update. Add input validation
that rejects empty titles and invalid status transitions (e.g., 'done' → 'todo')."

Here is the Step 1 output, followed by a complete Step 2 and Step 3 implementation so the full scaffold is runnable end-to-end:

// Step 1: Types and interfaces

interface Task {
  id: string;
  title: string;
  status: "todo" | "in-progress" | "done";
  assigneeId: string | null;
  createdAt: Date;
}

interface TaskFilter {
  status?: Task["status"];
  assigneeId?: string;
}

interface TaskRepository {
  findAll(filter?: TaskFilter): Task[];
  findById(id: string): Task;
  create(input: { title: string; assigneeId?: string }): Task;
  update(id: string, input: Partial<Pick<Task, "title" | "status" | "assigneeId">>): Task;
}
// Step 2 & 3: Implementation with error handling and validation

class NotFoundError extends Error {
  constructor(resource: string, id: string) {
    super(`${resource} with id '${id}' not found`);
    this.name = "NotFoundError";
  }
}

const VALID_TRANSITIONS: Record<Task["status"], Task["status"][]> = {
  "todo": ["in-progress"],
  "in-progress": ["done", "todo"],
  "done": ["in-progress"],
};

class InMemoryTaskRepository implements TaskRepository {
  private store = new Map<string, Task>();

  findAll(filter?: TaskFilter): Task[] {
    let tasks = Array.from(this.store.values());
    if (filter?.status) {
      tasks = tasks.filter((t) => t.status === filter.status);
    }
    if (filter?.assigneeId) {
      tasks = tasks.filter((t) => t.assigneeId === filter.assigneeId);
    }
    return tasks;
  }

  findById(id: string): Task {
    const task = this.store.get(id);
    if (!task) throw new NotFoundError("Task", id);
    return task;
  }

  create(input: { title: string; assigneeId?: string }): Task {
    if (!input.title.trim()) {
      throw new Error("Task title must not be empty");
    }
    const task: Task = {
      id: crypto.randomUUID(),
      title: input.title,
      status: "todo",
      assigneeId: input.assigneeId ?? null,
      createdAt: new Date(),
    };
    this.store.set(task.id, task);
    return task;
  }

  update(
    id: string,
    input: Partial<Pick<Task, "title" | "status" | "assigneeId">>
  ): Task {
    const existing = this.store.get(id);
    if (!existing) throw new NotFoundError("Task", id);

    if (input.title !== undefined && !input.title.trim()) {
      throw new Error("Task title must not be empty");
    }

    if (input.status && input.status !== existing.status) {
      const allowed = VALID_TRANSITIONS[existing.status];
      if (!allowed.includes(input.status)) {
        throw new Error(
          `Invalid status transition: '${existing.status}' → '${input.status}'`
        );
      }
    }

    const updated: Task = { ...existing, ...input };
    this.store.set(id, updated);
    return updated;
  }
}

The final output is coherent, layered code where types, logic, and error handling are each solid because they were each the model's sole focus at their step.

Pattern 4: Example-Driven Specification

What It Is

Verbal descriptions of data transformations are ambiguous. "Reformat the date" could mean ISO-8601, US locale, Unix timestamp, or a dozen other things. Providing concrete input/output examples removes that ambiguity entirely. This pattern is particularly effective for formatting logic, parsing tasks, and any transformation where a handful of examples communicates more than a paragraph of specification prose.

Before and After

Descriptive prompt: "Write a function that reformats dates"

This typically produces code that parses ISO-8601 and ignores empty strings or malformed input. Example-driven specification eliminates ambiguity:

Prompt: "Write a TypeScript function `formatDate` where:
- formatDate('2025-01-15') returns 'Jan 15, 2025'
- formatDate('2025-12-03') returns 'Dec 3, 2025'
- formatDate('invalid') returns null
- formatDate('') returns null"
const MONTH_NAMES: readonly string[] = [
  "Jan", "Feb", "Mar", "Apr", "May", "Jun",
  "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];

function formatDate(input: string): string | null {
  if (!input) return null;

  const date = new Date(input + "T00:00:00Z");
  if (isNaN(date.getTime())) return null;

  const month = MONTH_NAMES[date.getUTCMonth()];
  const day = date.getUTCDate();
  const year = date.getUTCFullYear();

  return `${month} ${day}, ${year}`;
}

The examples told the model the exact format, the edge cases to handle, and the expected return type for invalid input, all without lengthy specification prose. This implementation uses explicit month-name lookup rather than toLocaleDateString, ensuring consistent output regardless of the Node.js ICU build configuration on the host system.

Pattern 5: Role and Review Framing

What It Is

Assigning the AI a specific expert role biases it toward domain-appropriate patterns. Instructing it to review its own output against stated criteria catches errors the initial generation misses. Combined, these two techniques produce output that accounts for operational concerns a flat prompt would ignore. Self-review catches some errors but misses others unpredictably — treat it as a first pass, not a guarantee. It does not replace human code review, particularly for security-sensitive or production-critical code.

Before and After

Prompt: "You are a senior backend engineer conducting a code review.
Write a rate limiter middleware for Express using a sliding window algorithm.
After writing, review your own code for: race conditions under concurrent requests,
memory leaks from unbounded storage, and missing edge cases for distributed deployments.
Revise before presenting the final version."

A prompt like this produces a more thorough implementation than a flat "write a rate limiter" request. For example, the model may generate middleware along these lines:

import { Request, Response, NextFunction } from "express";

interface SlidingWindowEntry {
  timestamp: number;
}

interface RateLimiterOptions {
  windowMs: number;
  maxRequests: number;
}

interface RateLimiterMiddleware {
  middleware: (req: Request, res: Response, next: NextFunction) => void;
  destroy: () => void;
}

// WARNING: In-memory storage is single-process only.
// For distributed deployments, replace with Redis or a shared store.
function rateLimiter(options: RateLimiterOptions): RateLimiterMiddleware {
  const { windowMs, maxRequests } = options;

  // Store is scoped to this factory instance — no cross-instance key collision
  const store = new Map<string, SlidingWindowEntry[]>();

  // Periodic cleanup to prevent unbounded memory growth
  const cleanupHandle = setInterval(() => {
    const now = Date.now();
    for (const [key, entries] of store) {
      const filtered = entries.filter((e) => now - e.timestamp < windowMs);
      if (filtered.length === 0) {
        store.delete(key);
      } else {
        store.set(key, filtered);
      }
    }
  }, windowMs * 2);

  // Prevent the interval from keeping the Node.js process alive
  if (cleanupHandle.unref) cleanupHandle.unref();

  const middleware = (req: Request, res: Response, next: NextFunction): void => {
    const rawIp = req.ip;

    if (!rawIp) {
      // Fail closed: no resolvable IP means we cannot enforce per-client limits
      res.status(400).json({ error: "Client IP unresolvable" });
      return;
    }

    // Normalize IPv6-mapped IPv4 addresses (e.g., "::ffff:127.0.0.1" → "127.0.0.1")
    const key = rawIp.replace(/^::ffff:/, "");

    const now = Date.now();
    const windowStart = now - windowMs;

    const entries = (store.get(key) ?? []).filter(
      (e) => e.timestamp >= windowStart
    );

    if (entries.length >= maxRequests) {
      const oldestInWindow = entries[0].timestamp;
      const retryAfterSec = Math.ceil(
        (oldestInWindow + windowMs - now) / 1000
      );
      res.set("Retry-After", String(Math.max(1, retryAfterSec)));
      res.status(429).json({ error: "Too many requests" });
      return;
    }

    entries.push({ timestamp: now });
    store.set(key, entries);
    next();
  };

  return {
    middleware,
    destroy: () => clearInterval(cleanupHandle),
  };
}

export { rateLimiter };

The factory function returns both the middleware handler and a destroy method that clears the cleanup interval. This prevents timer accumulation during hot-reload or test runs — each call to rateLimiter() creates an isolated store and a single interval that can be explicitly torn down. The cleanupHandle.unref() call ensures the interval does not prevent Node.js process exit on its own. When deploying behind a reverse proxy, ensure Express trust proxy is configured correctly so that req.ip reflects the true client address rather than a spoofable X-Forwarded-For value.

Usage:

import express from "express";
import { rateLimiter } from "./rateLimiter";

const app = express();

// Configure trust proxy if behind a reverse proxy (e.g., nginx, AWS ALB)
app.set("trust proxy", 1);

const limiter = rateLimiter({ windowMs: 60_000, maxRequests: 100 });
app.use(limiter.middleware);

app.get("/", (_req, res) => res.send("OK"));

const server = app.listen(3000);

// On graceful shutdown, clean up the rate limiter interval
process.on("SIGTERM", () => {
  limiter.destroy();
  server.close();
});

The role-framed, self-reviewed version addressed three architectural concerns the flat prompt ignored: TTL-based expiry to prevent unbounded memory growth, Retry-After headers for well-behaved clients, and distributed deployment caveats that the model would otherwise silently skip.

The Vibe Coding Prompt Checklist

Quick-Reference Checklist

Before prompting:

  • Define the stack, language version, and framework specifics
  • State project conventions (error handling, file structure, naming)
  • Identify constraints: forbidden libraries, type rules, size limits

During prompting (apply one or more patterns):

  1. Start with Context Priming: "We are working in [stack]. Our conventions are [patterns]. The relevant types are [types]. Now write..."
  2. Add Constraint Injection where needed: "Do not use [library/pattern]. Keep under [N] lines. Handle these edge cases: [list]."
  3. For multi-step work, use Incremental Scaffolding — define types first, implement against them, then layer in error handling or validation as separate prompts.
  4. When the task involves data transformation, lead with examples: "Write a function where [fn(input)] returns [output] and [fn(edge)] returns [fallback]."
  5. For production-grade output, wrap with Role and Review: "You are a [role]. Write [thing], then review for [criteria list]. Revise before presenting."

After output:

  • Ask the AI to review against your original constraints
  • Iterate with targeted follow-ups rather than reprompting from scratch

Tool-specific notes: In Cursor, use inline file context to automate Context Priming. In Claude Code, use CLAUDE.md files in the project root for persistent project-level priming (see Anthropic's Claude Code documentation for current supported features). In ChatGPT, set system prompts or custom instructions for Role Framing and constraints that persist across conversations.

Combining Patterns for Real Results

When to Stack Patterns

Context Priming paired with Constraint Injection is the default starting combination. It covers what the model should know and what it should avoid, which addresses the two most common failure modes in a single prompt.

For data-heavy tasks, Incremental Scaffolding combined with Example-Driven Specification keeps transformations precise across multiple steps. Role and Review Framing wraps around any other pattern when the target is production-grade output that needs to account for operational concerns like race conditions or memory management.

Stacking two to three patterns per prompt is a practical guideline, not a hard rule; the optimal combination depends on task complexity and model behavior.

Stacking two to three patterns per prompt is a practical guideline, not a hard rule; the optimal combination depends on task complexity and model behavior. In practice, loading all five patterns into a single prompt produces diminishing returns — the model must balance too many structural requirements at once and starts dropping constraints. Selectivity matters more than coverage.