Spec Driven Development: Build Software That Actually Does What You Promised
"A well-written specification is not a constraint on creativity — it's a contract that frees everyone to move fast with confidence."
The Problem We've All Lived
You've been there. A sprint ends, the feature ships, and someone from the QA team — or worse, the client — asks: "Wait, but what happens when the user submits a negative amount? What if the email is missing?"
Silence. Awkward GitHub blame. A hotfix at 11pm.
The root cause is almost never bad code. It's a missing or ambiguous specification. The developer built something — just not the right something, because no one clearly defined what "right" looked like before a single line was written.
This is the problem Spec Driven Development (SDD) solves.
What Is Spec Driven Development?
Spec Driven Development is a software methodology where a formal, machine-readable (or at minimum, unambiguous human-readable) specification is written and agreed upon before any implementation begins.
The spec becomes the single source of truth for:
- What inputs are valid
- What outputs are expected
- What edge cases must be handled
- What errors should be raised and when
Unlike TDD (Test Driven Development), which starts from test cases, SDD starts one layer above — from a contract between all stakeholders. Tests are then derived from the spec, not invented alongside the code.
SDD vs. Other Methodologies
| TDD | BDD | SDD | |
|---|---|---|---|
| Starts with | Test cases | User behavior scenarios | Formal specification |
| Written by | Developers | Devs + QA + PO | All stakeholders |
| Primary artifact | Test file |
.feature file |
Spec document / schema |
| Code follows | The tests | The scenarios | The spec |
| Catches | Regressions | Behavior gaps | Contract violations |
SDD doesn't replace TDD or BDD — it precedes them. Once your spec exists, you can generate tests from it automatically.
Core Principles of SDD
1. 🔏 The Spec Is Law
No behavior is implemented without being in the spec. No spec item goes unimplemented. The spec and the code stay in sync — if the spec changes, the code changes, and vice versa.
2. 📐 Precision Over Prose
Specs written in vague English ("the system should handle errors gracefully") are useless. Good specs define exactly what "gracefully" means: which HTTP status code, which error payload shape, which log level.
3. 🤝 Shared Ownership
The spec is co-authored. Developers, product managers, QA engineers, and sometimes clients all sign off. This eliminates the "that's not what I meant" conversation after delivery.
4. 🔄 Living Document
Specs evolve. But every change goes through a deliberate revision process — not a Slack message at 4pm asking to "quickly tweak something."
Real-World Example: A Payment Processing Module
Let's walk through SDD end-to-end using a concrete scenario.
📋 The Business Request
"We need a charge endpoint. It should take a user's card and amount, and process a payment."
Classic. Now watch how SDD transforms this vague request into airtight software.
Step 1: Write the Spec
Before touching an IDE, the team drafts the specification. We'll use OpenAPI 3.0 format — widely adopted, tooling-rich, and human-readable.
# payment-spec.yaml
openapi: 3.0.3
info:
title: Payment Processing API
version: 1.0.0
paths:
/payments/charge:
post:
summary: Charge a payment method
operationId: chargePayment
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ChargeRequest'
responses:
'200':
description: Payment successful
content:
application/json:
schema:
$ref: '#/components/schemas/ChargeResponse'
'400':
description: Invalid request data
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'402':
description: Payment declined by provider
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'500':
description: Internal server error
components:
schemas:
ChargeRequest:
type: object
required: [amount, currency, card_token, idempotency_key]
properties:
amount:
type: integer
minimum: 50 # Minimum charge: $0.50 (in cents)
maximum: 99999999 # Maximum charge: $999,999.99
description: Amount in the smallest currency unit (e.g., cents for USD)
currency:
type: string
enum: [USD, EUR, PEN, GBP]
description: ISO 4217 currency code
card_token:
type: string
pattern: '^tok_[a-zA-Z0-9]{24}$'
description: Tokenized card reference from the frontend SDK
idempotency_key:
type: string
minLength: 16
maxLength: 64
description: Client-generated unique key to prevent duplicate charges
description:
type: string
maxLength: 255
description: Optional human-readable description for the charge
ChargeResponse:
type: object
required: [charge_id, status, amount, currency, created_at]
properties:
charge_id:
type: string
description: Unique identifier for this charge
status:
type: string
enum: [succeeded, pending]
amount:
type: integer
currency:
type: string
created_at:
type: string
format: date-time
ErrorResponse:
type: object
required: [error_code, message]
properties:
error_code:
type: string
enum:
- INVALID_AMOUNT
- INVALID_CURRENCY
- INVALID_CARD_TOKEN
- CARD_DECLINED
- INSUFFICIENT_FUNDS
- DUPLICATE_IDEMPOTENCY_KEY
- INTERNAL_ERROR
message:
type: string
details:
type: object
Notice what just happened. In ~90 lines of YAML, we have answered:
- ✅ What fields are required vs. optional?
- ✅ What are the valid currencies?
- ✅ What's the minimum and maximum charge?
- ✅ What does the card token format look like?
- ✅ What exact error codes exist, and when?
- ✅ What does a success response look like?
Zero ambiguity. Every stakeholder reviews and signs off on this before Sprint 1 starts.
Step 2: Generate Validation From the Spec
With a spec in place, we don't write validation logic by hand — we generate it. Tools like ajv, zod, or fastify's built-in schema support can consume OpenAPI schemas directly.
// src/schemas/chargeSchema.ts
import { z } from 'zod';
// This mirrors our OpenAPI spec exactly — one source of truth
export const ChargeRequestSchema = z.object({
amount: z
.number()
.int()
.min(50, 'Amount must be at least 50 cents')
.max(99_999_999, 'Amount exceeds maximum allowed'),
currency: z.enum(['USD', 'EUR', 'PEN', 'GBP']),
card_token: z
.string()
.regex(/^tok_[a-zA-Z0-9]{24}$/, 'Invalid card token format'),
idempotency_key: z.string().min(16).max(64),
description: z.string().max(255).optional(),
});
export type ChargeRequest = z.infer<typeof ChargeRequestSchema>;
Step 3: Implement Against the Contract
Now developers write the controller. The spec is their guide — they're not making design decisions on the fly.
// src/controllers/paymentController.ts
import { Request, Response } from 'express';
import { ChargeRequestSchema } from '../schemas/chargeSchema';
import { PaymentService } from '../services/paymentService';
import { IdempotencyService } from '../services/idempotencyService';
export async function chargePayment(req: Request, res: Response) {
// Step 1: Validate against spec
const parsed = ChargeRequestSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error_code: 'INVALID_AMOUNT', // Derive from Zod issues
message: parsed.error.errors[0].message,
details: parsed.error.flatten(),
});
}
const { amount, currency, card_token, idempotency_key, description } = parsed.data;
// Step 2: Check idempotency (spec requires this)
const existing = await IdempotencyService.find(idempotency_key);
if (existing) {
// Spec says: return the original response, not an error
return res.status(200).json(existing.response);
}
// Step 3: Process payment
try {
const charge = await PaymentService.charge({
amount,
currency,
cardToken: card_token,
description,
});
const response = {
charge_id: charge.id,
status: charge.status, // 'succeeded' | 'pending' — per spec
amount,
currency,
created_at: charge.createdAt.toISOString(),
};
// Store for idempotency
await IdempotencyService.store(idempotency_key, response);
return res.status(200).json(response);
} catch (err: any) {
if (err.code === 'card_declined') {
return res.status(402).json({
error_code: 'CARD_DECLINED',
message: 'Your card was declined. Please try a different payment method.',
});
}
if (err.code === 'insufficient_funds') {
return res.status(402).json({
error_code: 'INSUFFICIENT_FUNDS',
message: 'Insufficient funds on the card.',
});
}
return res.status(500).json({
error_code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred.',
});
}
}
Every error_code in the catch blocks maps directly to the spec's ErrorResponse.error_code enum. If a developer tries to return 'CARD_FAILED' instead of 'CARD_DECLINED', the TypeScript types — derived from the spec — will reject it at compile time.
Step 4: Tests Are Derived From the Spec
The spec enumerates every case. Tests write themselves.
// src/__tests__/chargePayment.spec.ts
import request from 'supertest';
import { app } from '../app';
describe('POST /payments/charge — Spec Compliance', () => {
const validPayload = {
amount: 2500,
currency: 'USD',
card_token: 'tok_A1B2C3D4E5F6G7H8I9J0K1L2',
idempotency_key: 'idem_test_key_12345678',
};
// ✅ Spec: 200 on valid charge
it('returns 200 with charge_id and status on success', async () => {
const res = await request(app).post('/payments/charge').send(validPayload);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
charge_id: expect.any(String),
status: expect.stringMatching(/^(succeeded|pending)$/),
amount: 2500,
currency: 'USD',
});
});
// ❌ Spec: 400 when amount < 50
it('returns 400 when amount is below minimum (50)', async () => {
const res = await request(app)
.post('/payments/charge')
.send({ ...validPayload, amount: 10 });
expect(res.status).toBe(400);
expect(res.body.error_code).toBe('INVALID_AMOUNT');
});
// ❌ Spec: 400 for unsupported currency
it('returns 400 for unsupported currency', async () => {
const res = await request(app)
.post('/payments/charge')
.send({ ...validPayload, currency: 'JPY' });
expect(res.status).toBe(400);
});
// ❌ Spec: 400 for malformed card_token
it('returns 400 for malformed card token', async () => {
const res = await request(app)
.post('/payments/charge')
.send({ ...validPayload, card_token: 'invalid-token' });
expect(res.status).toBe(400);
});
// 🔁 Spec: idempotency — same key returns same response
it('returns same response for duplicate idempotency_key', async () => {
const first = await request(app).post('/payments/charge').send(validPayload);
const second = await request(app).post('/payments/charge').send(validPayload);
expect(second.status).toBe(200);
expect(second.body.charge_id).toBe(first.body.charge_id);
});
// 💳 Spec: 402 on card decline
it('returns 402 with CARD_DECLINED when provider declines', async () => {
const res = await request(app)
.post('/payments/charge')
.send({ ...validPayload, card_token: 'tok_declineSimulator000000000' });
expect(res.status).toBe(402);
expect(res.body.error_code).toBe('CARD_DECLINED');
});
});
Every test maps to a spec statement. Code review becomes easy: "Does this test correspond to a spec requirement? Does every spec requirement have a test?"
The Payoff: What SDD Actually Gives You
🚀 Faster Onboarding
New developers read the spec and immediately understand what the system does — without reading implementation code.
🧪 Automatic Test Coverage Guidance
The spec is a checklist. If a spec item has no test, your coverage is incomplete. No guessing.
🤝 Fewer Miscommunications
When a PM asks "does it handle duplicate submissions?", you point to the spec: idempotency_key — yes, it does, here's exactly how.
🔒 Contract Stability
Frontend and backend teams can work in parallel. The API contract is defined. The frontend mocks from the spec; the backend implements against the spec. Integration becomes trivial.
📦 Tooling Bonuses
From a single OpenAPI spec you can auto-generate:
- SDK client code (TypeScript, Python, Go...)
- Interactive API documentation (Swagger UI, Redoc)
- Mock servers for frontend development
- Postman collections
- Load test configurations
Common Pitfalls to Avoid
❌ Writing the spec after the code
This defeats the purpose. The spec becomes documentation, not a contract.
❌ Over-specifying implementation details
The spec defines what, not how. Don't put database queries or internal algorithms in the spec.
❌ Letting the spec drift from the code
Treat spec changes like code changes: pull requests, reviews, version tags. A stale spec is worse than no spec.
❌ Only developers writing the spec
If QA and product haven't reviewed it, edge cases will be missed. SDD is a team sport.
Tools to Get Started
| Tool | Purpose |
|---|---|
| OpenAPI / Swagger | API specification format |
| Zod | TypeScript-first schema validation |
| Prism | Mock server from OpenAPI spec |
| Dredd | Test API against its spec automatically |
| Spectral | OpenAPI linting |
| oapi-codegen | Go code generation from OpenAPI |
| openapi-typescript | TypeScript types from OpenAPI |
Conclusion
Spec Driven Development shifts the conversation from "we'll figure it out as we build" to "we've agreed on the contract, now let's build." It's not bureaucracy — it's engineering discipline that saves you from the 11pm hotfix, the post-launch confusion, and the eternal question of whose fault the bug was.
The payment module we built today had zero ambiguity from day one:
- Every error code was named before a single
catchblock was written - The idempotency requirement was specified before anyone thought to implement it
- The frontend team could mock the API on day one
That's the power of writing the spec first.
What about you — do you use any spec-first approaches in your team? Have you tried OpenAPI or GraphQL SDL as a contract? Let me know in the comments 👇
#softwaredevelopment #api #webdev #typescript #bestpractices

























