AI Spec-Driven Development

I handed an AI my full project spec and told it to drive. No hand-holding, no micromanaging, just pure spec-driven development in action. What happened next genuinely surprised me, and I think it will change how you think about building with AI.

A while ago, I started seriously experimenting with what people are now calling AI spec-driven development. The short version: instead of writing code and hoping it matches the requirements, you write a detailed, machine-readable specification first, feed it to an AI coding assistant (I was using Claude Sonnet 3.5 at the time, though I've since tried GPT-4o and Cursor's built-in model), and let the AI generate an implementation grounded in that spec. Not just a prompt like "build me a notification service." An actual spec. It sounds obvious when I write it out like that but the difference in practice is enormous. What "AI Spec-Driven Development" Actually Means I want to be clear about what this is and isn't, because I've seen the term used loosely. This isn't just prompting an AI with a feature request. And it's not the same as test-driven development, though there's significant overlap, and I'll get into exactly how they fit together in a bit. AI spec-driven development (I'll call it ASDD from here on, mostly to save myself from typing it out repeatedly) is a workflow where: You write a structured specification document before any code exists That spec is the primary input to the AI model, not your mental model of the feature The AI generates code, tests, and sometimes documentation from that spec You validate the output against the spec, not against your intuition The spec itself can take different forms. I've seen people use OpenAPI definitions for API-first projects, Gherkin-style scenarios for behavior-heavy features, plain markdown with clearly delineated sections, or a hybrid of all three. What matters isn't the format so much as the level of specificity, and the fact that it exists as an artifact the AI can actually parse. This part is important: the spec has to be written by a human who understands the problem domain. The AI doesn't replace that thinking. It replaces the translation step between "we know what we want" and "here is working code." Why Vague Prompts Produce Vague Code When I first started using AI assistants for coding (back in the early Copilot days, probably late 2022), my prompts looked like this: // Write a function that sends email notifications to users when their subscription is about to expire And Copilot would produce something. Plausible. Often it would even compile. But it would make dozens of micro-decisions I hadn't thought about. What's "about to expire"? 7 days? 3 days? Both? Does it send one email or multiple? What happens if the user has opted out? Does it log failures? Does it retry? Every one of those decisions was a place where the AI's guess might not match what the product actually needed. And I wouldn't notice until someone filed a bug. The AI wasn't bad. My input was bad. ASDD is basically a discipline for fixing your input. You do the hard thinking upfront, put it in writing, and then let the AI do what it's genuinely good at: translating a well-specified problem into working code. Building a Spec That an AI Can Actually Use This is where I've spent most of my experimentation time, and I have some opinions. The spec needs to answer three things clearly: what the system does, what it does not do (the boundaries matter a lot), and what "correct" looks like for edge cases. Here's a trimmed-down example from the notification service project. We were spec'ing out the subscription expiry email logic: # Spec: Subscription Expiry Notification ## Behavior - Send a warning email when a subscription has <= 7 days remaining - Send a final warning email at <= 1 day remaining - Do NOT send if the user has `notifications_enabled = false` - Do NOT send if the subscription is in status `cancelled` or `paused` - Each email type (7-day, 1-day) should be sent at most once per subscription cycle ## Inputs - User object with fields: id, email, notifications_enabled - Subscription object with fields: id, expires_at, status, last_7day_notice_sent_at, last_1day_notice_sent_at ## Expected Outputs - Queue a job to `notifications.email` with payload `{ user_id, template_id, subscription_id }` - Update `last_7day_notice_sent_at` or `last_1day_notice_sent_at` after queuing ## Error Handling - If the queue push fails, log to `app.notifications` channel with level ERROR and do not update the timestamp fields - Do not throw; return a failure result object ## Out of Scope (for this spec) - Actual email rendering (handled by a separate template service) - User preference management That's it. Not fancy. But when I fed this to Claude with a prompt like "implement this spec in Java 25 using RabbitMQ queues," the output was dramatically more accurate than anything I'd gotten from vague prompts. It respected the idempotency constraints. It handled the error case correctly. I didn't have to go back and add the notifications_enabled check after the fact because I'd already stated it up front. The whole feedback loop dropped from three or four rounds of corrections down to one. Maybe two if I was being picky about naming. Validating the Spec with TDD Before You Write a Line of Implementation Here's where things get genuinely interesting, and where I think ASDD and TDD belong in the same conversation. My current workflow has a specific step that I don't skip anymore: before asking the AI to generate any implementation code, I ask it to generate tests directly from the spec. Not "write tests for this function." The function doesn't exist yet. I mean: "given this spec, write tests that would prove an implementation is correct." // Prompt pattern I use before any implementation exists: // "Given this spec [paste spec], write JUnit 5 unit tests that cover // the specified behaviors and edge cases. Do not generate the implementation yet." The tests act as a translation of the spec into something executable and reading them is often the fastest way to find out whether your spec actually says what you think it says. More than once I've looked at the generated tests and thought "wait, that's not what I meant" and fixed the spec before any implementation existed. That's a much cheaper place to catch a misunderstanding. Here's what that looks like in practice for the notification service spec above. The AI generated something close to this: import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import java.time.Instant; import java.time.temporal.ChronoUnit; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class SubscriptionExpiryNotifierTest { @Mock private NotificationQueue queue; @Mock private SubscriptionRepository subscriptionRepo; private SubscriptionExpiryNotifier notifier; @BeforeEach void setUp() { notifier = new SubscriptionExpiryNotifier(queue, subscriptionRepo); } private User makeUser(boolean notificationsEnabled) { return new User(1L, "user@example.com", notificationsEnabled); } private Subscription makeSubscription(int daysRemaining, String status, Instant last7day, Instant last1day) { return new Subscription( 42L, Instant.now().plus(daysRemaining, ChronoUnit.DAYS), status, last7day, last1day ); } @Test void queues7DayWarningWhenWithinWindow() { User user = makeUser(true); Subscription sub = makeSubscription(5, "active", null, null); notifier.process(user, sub); verify(queue).push(argThat(payload -> payload.getTemplateId().equals("7day_warning") && payload.getUserId().equals(1L) && payload.getSubscriptionId().equals(42L) )); } @Test void queues1DayWarningWhenWithinWindow()...