There's a pattern separating developers who get consistently good output from Claude Code and those who keep fighting it: the ones who get good output write the spec first.
Not a prompt. A spec. There's a difference.
A prompt is "build me a user authentication system with email and password." A spec is a structured document describing what the system needs to do, what it connects to, what the edge cases are, and what done looks like. One of these produces a generic auth scaffold. The other produces code that fits your actual project.
This is spec-driven development — and it's becoming the standard workflow for developers who use AI coding tools seriously in production.
Why Prompting Alone Breaks Down
When you prompt Claude Code without context, it fills the gaps with assumptions. Reasonable assumptions, usually — but not your assumptions.
"Add a checkout flow" produces Stripe integration. But maybe you're on Paddle. Maybe you have a custom pricing model. Maybe checkout needs to hit three internal services before confirming. Claude doesn't know any of that from five words.
The result: you spend three rounds of corrections steering output toward what you actually wanted. The third round is often a rewrite of the first round. You've spent more time than if you'd written it yourself.
The fix isn't better prompting. It's less prompting — replaced by more upfront specification.
What a Spec Actually Looks Like
A spec for Claude Code isn't a formal PRD. It's a structured markdown document that answers the questions Claude would otherwise guess at:
# Feature: User Subscription Management
## What it does
Users can subscribe to plans (free, pro, enterprise), upgrade/downgrade,
and cancel. Billing goes through Stripe. Plan changes take effect immediately
for upgrades, at end of billing period for downgrades.
## Data model
- User has one active Subscription
- Subscription has: planId, status, stripeSubscriptionId, currentPeriodEnd
- Plans: free (default), pro ($29/mo), enterprise ($99/mo)
- Existing: users table (Drizzle + Postgres), auth via Better Auth
## API surface
- POST /api/subscriptions/create — creates Stripe checkout session
- POST /api/subscriptions/cancel — cancels at period end
- POST /api/subscriptions/webhook — handles Stripe events (idempotent)
- GET /api/subscriptions/current — returns user's active plan
## Business rules
- Free plan: no credit card required
- Cancellation: user keeps pro access until currentPeriodEnd
- Webhook events to handle: checkout.session.completed,
customer.subscription.updated, customer.subscription.deleted
- Failed payments: send email after 3 attempts, downgrade to free
## What NOT to build
- No trial periods (adding later)
- No team/seat-based billing (separate feature)
- No invoice history UI (use Stripe customer portal)
## Done looks like
- User can go from free → pro in under 30 seconds
- Webhook handles duplicate events without creating duplicate records
- Cancel flow shows when access expires, not just "cancelled"This spec takes 20-30 minutes to write. It saves 2-3 hours of iteration.
The Workflow
Step 1: Write the spec in CLAUDE.md or a dedicated file
For project-wide context, the spec goes in CLAUDE.md. For a specific feature, a separate specs/[feature].md file that you reference explicitly works better.
# Create specs directory at project root
mkdir specs
touch specs/subscription-management.mdThe separation matters: CLAUDE.md carries permanent project context. Feature specs are temporary — they describe what you're building now, not forever.
Step 2: Open Plan Mode before writing a line of code
Claude Code's Plan Mode is the right place to validate the spec before execution. You're not asking Claude to build anything yet — you're asking it to read the spec and tell you what it's going to do.
/plan
Read specs/subscription-management.md and tell me:
1. What files you'll create or modify
2. What the webhook handler will look like (pseudocode)
3. What database migrations are needed
4. What you'll skip and why
The output from Plan Mode is the spec check. If Claude's plan doesn't match your mental model, the spec is underspecified. Fix the spec, not the code.
This step catches mismatches before they cost you time. A webhook handler that doesn't handle idempotency correctly is a bug in production — not something you want to discover after it's written.
Step 3: Execute in sections, not all at once
Don't ask Claude Code to implement the entire spec in one shot. Break it into logical sections and execute them in order:
Implement the database schema changes from specs/subscription-management.md.
Use Drizzle. Don't touch the API layer yet.
Review. Then:
Add the Stripe checkout session creation endpoint (POST /api/subscriptions/create).
Use the schema you just created. Validate with Zod before hitting Stripe.
Review. Then the webhook. Then the cancel flow.
This isn't slower — it's faster, because each piece is reviewable before the next one builds on it. A bug in the schema caught at step one doesn't compound into a bug in every layer above it.
Step 4: The spec as the test oracle
When the code is written, the spec tells you whether it's done. Each requirement in the spec is a test case:
// From the spec: "Webhook handles duplicate events without creating duplicate records"
describe('subscription webhook', () => {
it('is idempotent for duplicate checkout.session.completed events', async () => {
const sessionId = 'cs_test_abc123'
// First event
await handleWebhook({ type: 'checkout.session.completed', data: { object: { id: sessionId } } })
const sub1 = await db.query.subscriptions.findFirst({ where: eq(subscriptions.stripeSessionId, sessionId) })
// Duplicate event
await handleWebhook({ type: 'checkout.session.completed', data: { object: { id: sessionId } } })
const count = await db.select({ count: count() }).from(subscriptions).where(eq(subscriptions.stripeSessionId, sessionId))
expect(count[0].count).toBe(1) // Still one record, not two
})
})Ask Claude Code to generate tests directly from the spec:
Generate Vitest tests for specs/subscription-management.md.
Each business rule in the spec should have at least one test.
Focus on the webhook idempotency and the cancel flow timing.
A Real Spec Template
This is the format I use for every non-trivial feature:
# Feature: [Name]
## Context
[One paragraph: what exists, what this builds on, what the user need is]
## What it does
[Bullet list of capabilities from the user's perspective]
## Technical constraints
[Existing tech stack, services already in use, things to not change]
## Data model
[New tables/fields, changes to existing, relationships]
## API surface (if applicable)
[Endpoints, expected inputs/outputs, auth requirements]
## Business rules
[The non-obvious logic — edge cases, timing, error states]
## What NOT to build
[Explicit exclusions — saves endless scope creep]
## Done looks like
[Acceptance criteria — observable, testable outcomes]The "What NOT to build" section is underrated. It eliminates an entire category of Claude over-engineering — building the general version of something when you need the specific version.
Spec-Driven Development for Bugs
Specs aren't just for new features. A bug report written as a spec produces faster fixes:
# Bug: Dashboard chart shows wrong totals when filtering by date range
## Current behavior
Selecting "Last 30 days" on the dashboard shows totals that don't match
the raw data in the DB. Off by approximately 3-5% consistently.
## Expected behavior
Chart totals match SELECT SUM(amount) FROM orders WHERE created_at >= NOW() - INTERVAL '30 days'
## What I've checked
- The query in analytics.ts line 84 looks correct
- The issue appears only with the date filter, not with "All time"
- Started after the timezone migration on 2026-08-15
- Postgres timezone is UTC, app is configured for Europe/Madrid
## Hypothesis
The date filter is applying the timezone offset twice — once at the query
level and once in the frontend date calculation.
## What I need
Find where the double offset is happening and fix it. Don't change
the chart rendering logic — only the query/date handling.This spec gives Claude Code everything needed to find the bug without inventing plausible-sounding but wrong explanations. The "What I need" section prevents the over-fix — changing things that aren't broken.
For complex debugging workflows, pairing the bug spec with a targeted investigation step first produces better results than asking for a fix directly.
When Specs Don't Help
Spec-driven development is a workflow for known unknowns. It doesn't help when:
- You're genuinely exploring and don't know what you want yet. For that, free-form prompting to generate options is fine — you're not building, you're researching.
- The task is trivial (add a field to a form, rename a variable, write a simple util). Specs add overhead that isn't worth it under 10 minutes of work.
- You need multiple agents working in parallel on different parts. In that case, the spec becomes the shared contract between agents — still useful, but the workflow differs.
The threshold I use: if implementing this wrong would cost more than an hour to fix, write a spec. Below that, prompt freely.
The Shift This Creates
The cognitive shift with spec-driven development is that you're doing the hard thinking before the code exists, not after. The spec forces you to answer questions you'd otherwise discover mid-implementation:
- What happens when the Stripe webhook fires before the user record is created?
- Does cancel-then-resubscribe create a new subscription or reactivate the old one?
- Who can access the subscription management page — the account owner only, or any admin?
Answering these in a markdown file is cheap. Answering them in code that's already integrated into three other layers is expensive.
Claude Code is capable of building complex systems correctly. The limiting factor is usually not the AI — it's the underspecified input. Specs fix the input.