Skip to content
Santekno.com | Level Up Your Engineering Skills
EN
📖 0%
21 Jul 2026 · 17 min read ·Article 5 / 208
Go

Anatomy of a Good Specification: User Story, Acceptance Criteria, Edge Cases

A complete guide to writing good specifications: User Story, Acceptance Criteria, and Edge Cases that actually work for Specification-Driven Development with Claude Code in Golang.

IH
Ihsan Arif
Writer at Santekno · Backend Engineer

Anatomy of a Good Specification: User Story, Acceptance Criteria, Edge Cases

A bad spec produces bad code — even with the most sophisticated AI. A good spec produces code that is accurate, easy to test, and verifiable by anyone on the team. Getting the anatomy of a good specification golang SDD right is the highest-leverage skill in Specification-Driven Development.

But what makes a spec “good”? In this article we dissect the anatomy of an effective specification: each component, what makes each one succeed or fail, and how to write them for production Golang projects.


05.1 Why Spec Quality Matters More Than Quantity

There’s a common misconception: a good spec is a long, detailed spec. In reality it’s the opposite.

An effective spec is one that:

  • Can be read and understood in 5–10 minutes
  • Has every acceptance criterion writable directly as a test
  • Leaves no ambiguity after reading
  • Lets a developer start coding without needing to ask questions

To make the difference concrete, compare a bad spec indicator against a good one — the block below shows the vague version first.

text
1BAD:  "System must handle order management well and efficiently,
2       including all aspects necessary for modern e-commerce"
3
4GOOD: "AC1: POST /orders accepts { cart_id (UUID, required), shipping_address_id (UUID, required) }
5       AC2: System validates cart belongs to the authenticated user
6       AC3: System validates all cart items are available (stock > 0)
7       AC4: If validation fails → 422 with detail of which items are out of stock"

The takeaway: the first can’t be tested, while the second becomes test cases immediately. Testability, not length, is the mark of a good spec.


05.2 User Story: More Than Just a Template

The effective User Story answers one fundamental question: why does this feature exist? The base template below looks trivial, but each slot has to earn its place.

text
1As [specific role],
2I want [concrete action],
3so that [clear and measurable benefit].

That template is only a skeleton — the quality lives in how specific each slot is. Compare a weak fill against a strong one, weak first:

text
1BAD:
2As a user, I want to cancel an order, so I can cancel.
3(Problems: "user" too generic, benefit unclear, action not specific.)
4
5GOOD:
6As a Santekno Shop customer who has placed an order but changed their mind,
7I want to cancel an order that hasn't been processed by the merchant yet
8so that stock becomes available again and I don't wait for a manual refund.

The lesson: the good version nails a specific role (not just “user”), a concrete benefit (not “so I’m satisfied”), and relevant context (conditions and constraints). Those three are the elements most often skipped.


05.3 Acceptance Criteria: A Verifiable Contract

Acceptance Criteria (AC) is the contract between product/business and engineering — the set of conditions that must hold for the feature to be considered done.

The first characteristic of good AC is that one AC equals one behavior that can be isolated. The contrast below shows an overloaded criterion followed by focused ones.

text
1BAD (combines too much):
2"System accepts request, validates, saves to DB, and sends notification"
3
4GOOD (focused):
5AC1: System accepts POST /orders with { cart_id, shipping_address_id }
6AC2: System validates cart belongs to the authenticated user
7AC3: System creates an order record with PENDING status
8AC4: System publishes an ORDER_CREATED event to Kafka
9AC5: System returns 201 Created with order_id and total_amount_cents

Split like that, each line targets exactly one behavior — which is precisely what makes the second characteristic possible: each AC directly becomes a test. The Go test below is a one-to-one translation of AC2.

go
1// From AC2: "System validates cart belongs to the authenticated user"
2func TestCreateOrder_CartBelongsToOtherUser_Returns422(t *testing.T) {
3    // Given: User A authenticated, cart belongs to User B
4    // When: POST /orders with cart_id belonging to User B
5    // Then: 422 with error "cart not found or not accessible"
6}

Notice how the test name and the three Given/When/Then comments come straight from the AC wording — no interpretation needed. The third characteristic follows: AC must be explicit about HTTP status codes and response format, as the pair below demonstrates.

text
1BAD:  "System rejects invalid requests"
2GOOD: "Request without Authorization header → 401 Unauthorized
3       Body: { error_code: 'UNAUTHORIZED', message: 'authentication required' }"

The takeaway: a good AC pins the exact status code and the exact response shape, leaving nothing for the implementer to guess.


05.4 Complete AC Example — Create Order

Rules are easier to trust when you see them applied end to end. The block below is a full Create Order acceptance set, separating the happy path from error cases.

text
 1## Acceptance Criteria — Create Order
 2
 3### Happy Path
 4- AC1: POST /orders accepts { cart_id: UUID, shipping_address_id: UUID } — both required
 5- AC2: System validates cart_id refers to a cart that: (a) exists, (b) belongs to the authenticated user, (c) is not empty
 6- AC3: System validates all products in cart: (a) still active (ACTIVE status), (b) have stock >= quantity in cart
 7- AC4: System creates an order with PENDING status and assigns order_number (format: ORD-YYYYMMDD-XXXXXXX)
 8- AC5: System decrements each product's stock atomically in one database transaction
 9- AC6: System publishes an ORDER_CREATED event to Kafka topic "order-events"
10- AC7: Response: 201 Created, body: { order_id, order_number, total_amount_cents, status: "PENDING" }
11
12### Error Cases
13- AC8: Invalid UUID format → 400 Bad Request, error_code: INVALID_REQUEST
14- AC9: Cart not found or not owned by user → 422, error_code: CART_NOT_ACCESSIBLE
15- AC10: Cart empty → 422, error_code: CART_EMPTY
16- AC11: Product inactive → 422, error_code: PRODUCT_UNAVAILABLE
17- AC12: Insufficient stock → 422, error_code: INSUFFICIENT_STOCK, message: "insufficient stock for [name]: available [n], requested [m]"
18- AC13: Address not found → 422, error_code: ADDRESS_NOT_FOUND
19- AC14: No/invalid auth → 401, error_code: UNAUTHORIZED

The takeaway: every happy-path AC is a single behavior and every error case carries an explicit status code plus a specific error_code. That is what “ready to implement without questions” looks like in practice.


05.5 Edge Cases: The Conditions That Define Quality

Edge cases are boundary conditions that make developers say “I didn’t think of that” — and they cause the most painful production bugs. They fall into a handful of categories worth calling out explicitly.

The first category is concurrent access, where two requests race for the same resource. The block below shows the classic last-item checkout.

text
1EC1: Two users check out the last stock item simultaneously
2     → Only one succeeds (201 Created)
3     → The other gets 422 INSUFFICIENT_STOCK
4     Implementation: pessimistic lock via SELECT FOR UPDATE

The lesson from EC1: without a lock, both requests read the same stock and both succeed, oversell the item, and corrupt inventory. The second category is external dependency failure — what happens when a downstream system breaks after the main work is done, as below.

text
1EC4: Kafka publish fails after the order was saved to DB
2     → Log at WARNING level
3     → The order is still considered successful (return 201)
4     → The Kafka retry mechanism handles eventual consistency

The takeaway from EC4: a failure in a secondary side effect must not roll back the primary transaction. The third category is data integrity during in-flight changes, shown next.

text
1EC2: Product deactivated by merchant while user is in the cart
2     → Checkout fails with a specific error about that product
3     → User can remove the product and check out again

That EC keeps the error actionable rather than generic. The fourth category is rate limiting, protecting the endpoint from abuse:

text
1EC6: User tries to create an order more than 10 times in 1 minute
2     → 429 Too Many Requests
3     → Retry-After header states when they can try again

The overall lesson: naming the category first (concurrent, dependency failure, integrity, rate limiting) helps you reason about why each edge case matters, not just enumerate them.


05.6 Writing Useful Edge Cases

Not every boundary condition deserves a line in the spec. A useful edge case must:

  1. Actually happen in production (not just theoretically)
  2. Require behavior different from the happy path
  3. Have a clear implementation to address it

To keep edge cases from ballooning, prioritize them. The framework below sorts them into tiers you can defend in review.

text
 1P0 — MUST handle:
 2- Concurrent access that could cause data corruption
 3- Security (unauthorized access, injection)
 4- Financial transaction integrity
 5
 6P1 — SHOULD handle:
 7- External dependency failure (graceful degradation)
 8- Valid format but invalid business logic inputs
 9
10P2 — NICE to have:
11- Per-user rate limiting
12- Audit logging for unusual patterns
13
14P3 — Out of scope (document only):
15- Disaster recovery
16- Multi-datacenter failover

The takeaway: a priority label turns an endless list of “what ifs” into a defensible scope — you handle P0/P1 now and consciously defer P2/P3.


05.7 Non-Functional Requirements: The Often-Missed Section

Non-Functional Requirements (NFR) define how the system must behave — different from ACs, which define what it must do. The block below groups them by dimension so none get forgotten.

text
 1## Non-Functional Requirements — Create Order
 2
 3### Performance
 4- NFR1: Response time < 500ms at p95 under normal load (100 req/s)
 5- NFR2: Response time < 2000ms at p99 during peak load (1000 req/s flash sale)
 6
 7### Data Integrity
 8- NFR3: Stock decrement operation MUST be atomic
 9- NFR4: No duplicate orders for the same request (idempotency support)
10
11### Security
12- NFR5: Rate limit: 20 order creations per user per hour
13- NFR6: All requests must be authenticated (JWT Bearer token)
14
15### Observability
16- NFR7: Required metrics: order_creation_total (counter), order_creation_duration_seconds (histogram)
17- NFR8: Alert if error rate > 1% over 5 minutes

The takeaway: NFRs are where production-readiness is won or lost. Attaching concrete numbers (500ms, 99.9%, 1%) makes them testable rather than aspirational.


05.8 Spec Review Checklist

A checklist turns “is this spec good?” into a repeatable pass/fail. Use the one below before you hand any spec to Claude Code or a reviewer.

text
 1## Spec Quality Checklist
 2
 3### User Story
 4- [ ] Specific role (not just "user")
 5- [ ] Clear and concrete action
 6- [ ] Measurable and valuable benefit
 7- [ ] Relevant context included when needed
 8
 9### Acceptance Criteria
10- [ ] Each AC writable as a single test function
11- [ ] Happy path has at least 3 ACs
12- [ ] Each error case specifies an explicit HTTP status code
13- [ ] Each error case specifies a specific error_code
14- [ ] Response format defined (which fields are returned)
15- [ ] No ambiguous ACs (not interpretable in more than one way)
16
17### Edge Cases
18- [ ] At least 1 concurrent access EC if shared resources are involved
19- [ ] At least 1 external dependency failure EC
20- [ ] EC for boundary conditions (max/min values)
21- [ ] Each EC has clear behavior (not just "handle gracefully")
22
23### Non-Functional Requirements
24- [ ] Response time target present
25- [ ] Availability expectation stated
26- [ ] Rate limiting mentioned where relevant

The takeaway: keep this checklist in the repo and run it on every spec — it catches the same recurring gaps (missing error codes, vague ECs) before they reach code.


05.9 Reviewing a Spec with Claude Code

After writing a spec, use Claude Code to run a “spec review” before implementation. The prompt below asks for gaps only, not code.

text
 1I just finished writing the spec for the cancel order feature.
 2Here it is:
 3
 4[paste spec content]
 5
 6Please review this spec and identify:
 71. ACs that are not measurable (can't become tests)
 82. Edge cases that might be missing
 93. Ambiguities in the language used
104. Conflicts between ACs
115. NFRs that should be added
12
13Don't implement — just review spec quality.

The takeaway: Claude Code typically surfaces 2–4 improvements even for specs you thought were finished. Reviewing the spec is far cheaper than reviewing the code it would have produced.


05.10 Spec Traceability

Once a spec is approved and implemented, keep traceability — the ability to walk from an AC to the code and back. Anchoring that link in comments, as below, makes review and audits trivial.

go
 1// cancel_order.go — implements specs/order/cancel-order.md v1.3
 2
 3// Execute performs order cancellation.
 4// Implements: AC1 (authentication check via middleware)
 5// Implements: AC2 (order ownership validation)
 6// Implements: AC3 (status PENDING check)
 7// Implements: AC5 (stock restore + status update atomic)
 8// Implements: EC1 (concurrent cancel via SELECT FOR UPDATE)
 9func (uc *CancelOrderUseCase) Execute(ctx context.Context, input CancelOrderInput) error {
10    // ...
11}

The takeaway: with Implements: tags, a reviewer can confirm every AC has a home in the code, and any AC without one immediately stands out as unimplemented.


05.11 Spec for Kafka Events and Async Operations

Event-driven features need a spec that covers the event contract, not just the HTTP API. The block below specifies the ORDER_CANCELLED event end to end.

text
 1## Event Specification — ORDER_CANCELLED
 2
 3### Trigger
 4Published by Order Service after a successful order cancellation
 5
 6### Kafka Configuration
 7Topic: order-events, Partition key: order_id, Format: JSON
 8
 9### Event Schema
10{
11  "event_type": "ORDER_CANCELLED",
12  "event_id": "uuid",
13  "occurred_at": "ISO 8601",
14  "payload": { "order_id": "uuid", "user_id": "uuid", ... }
15}
16
17### Consumer Contracts
18- Notification Service: send cancellation email
19- Analytics Service: update cancellation rate metrics
20
21### Guarantees
22- At-least-once delivery
23- Consumers must handle duplicate events (check event_id)

The takeaway: an event spec must state the trigger, the schema, who consumes it, and the delivery guarantees — otherwise each consumer team invents its own assumptions about the payload.


05.12 Spec for Background Jobs

Specs aren’t only for REST endpoints — background jobs need clear ones too. The example below specifies an hourly cart-cleanup job.

text
 1# specs/jobs/cleanup-expired-carts.md
 2
 3## Overview
 4Background job running hourly to clean up expired carts.
 5
 6## Acceptance Criteria
 7- AC1: Job runs every hour (cron: 0 * * * *)
 8- AC2: Queries carts with updated_at < NOW() - INTERVAL '24 hours'
 9- AC3: Processes a maximum of 1000 carts per run (batch processing)
10- AC4: Soft delete: update deleted_at = NOW(), not physical delete
11- AC5: Logs the number of carts cleaned per run
12
13## Edge Cases
14- EC1: If the previous job is still running when the next hour arrives → skip run, log warning
15- EC2: If > 100,000 expired carts → process across multiple runs

The takeaway: a job spec still needs ACs and ECs — batch size, delete semantics, and overlap handling are exactly the details that cause silent data loss when left unspecified.


05.13 Common AC Writing Mistakes

Certain AC mistakes recur across teams. The first is being too granular — splitting one validation into four lines, as the contrast below shows (granular version first).

text
1BAD:
2AC1: product_id not null | AC2: product_id valid UUID | AC3: product_id in database
3
4GOOD:
5AC1: product_id required, UUID format, must exist in database
6     → 400 if wrong format, 404 if not found

The lesson: group related validations into one AC with clear outcomes. The second mistake is the opposite — excessive “and/or” in a single AC, which should be split into separate ACs. The third is an error AC without an error code, shown below.

text
1BAD:  "If insufficient stock, return an error"
2GOOD: "If insufficient stock → 422, error_code: INSUFFICIENT_STOCK,
3       message: 'insufficient stock for [name]: available [n], requested [m]'"

The takeaway: an error AC without a status code and error_code is not testable — always pin both.


05.14 Evolving the Spec as the Feature Develops

A spec is not written once and frozen; it evolves with the feature. The version history below shows how a Cancel Order spec accretes learning over time.

text
 1v1.0 — MVP spec:
 2Cancel: AC1 PENDING status check, AC2 stock restore, AC3 status to CANCELLED
 3
 4v1.1 — After initial feedback:
 5Added AC4: 15-minute window (from user research)
 6Added AC5: Merchant notification
 7
 8v1.2 — After production learnings:
 9Added EC3: Concurrent cancel (found in stress test)
10Added EC4: Kafka failure behavior (best effort)

The takeaway: git-commit every version with a clear changelog. The spec becomes a living record of accumulated knowledge about the feature, not a snapshot that rots.


05.15 Spec Testing: Validating the Spec Itself

Before trusting a spec, run a few “meta-tests” against it:

Test 1: Can-become-test test — for each AC, write a test-function header. If it’s hard to write, the AC is too ambiguous.

Test 2: Non-technical person understands — read the spec to a PM. If they have questions it doesn’t answer, there’s ambiguity.

Test 3: New developer can implement — imagine a developer with no context. Could they implement from just the spec + CLAUDE.md?

Test 4: Conflict detection — re-read all ACs. Any interpretable two ways? Any conflicts between them?


05.16 Integrating the Spec with OpenAPI

For APIs that also have an OpenAPI/Swagger definition, there are two ways to relate the two. The first treats the feature spec as a superset of OpenAPI, as mapped below.

text
1specs/order/cancel-order.md ← business rules, AC, EC
2api/openapi.yaml            ← HTTP contract, request/response schema

That split keeps business rules where they belong while OpenAPI stays a pure transport contract. The second approach generates OpenAPI from the spec:

text
1Based on specs/order/cancel-order.md, generate an OpenAPI 3.0 spec
2for the DELETE /orders/{id} endpoint. Include all error codes from ACs and ECs.

The takeaway: whichever direction you choose, the feature spec owns the “why” and the edge cases, and OpenAPI owns the wire format — don’t duplicate business rules into the YAML.


05.17 Spec for AI-Powered Features

Features backed by an LLM need ACs about latency, output bounds, and forbidden content — dimensions a normal CRUD spec never mentions. The block below specifies a product-description generator.

text
 1## AC — AI Product Description Generator
 2
 3- AC1: POST /products/{id}/generate-description accepts { tone: professional|casual|persuasive }
 4- AC2: System sends a structured prompt to the LLM API
 5- AC3: Response within 30 seconds maximum
 6- AC4: LLM timeout → 408 Request Timeout, error_code: LLM_TIMEOUT
 7- AC5: Generated description: 100–500 words
 8- AC6: Description in Indonesian by default (lang param overrides)
 9- AC7: Description must not include prices or competitor comparisons
10
11### Edge Cases
12- EC1: LLM API down → 503, "AI service temporarily unavailable"
13- EC2: Product has no existing description → generate from name and category

The takeaway: AI features demand explicit bounds (timeout, word count, disallowed content). Without them, an LLM will happily return a five-word answer, a novel, or a competitor comparison.


05.18 Tips & Gotchas

Tip 1: Write ACs from the system’s perspective, not the user’s — the contrast below shows why.

text
1BAD:  "User successfully cancels order"
2GOOD: "System returns 204 No Content and restores all item stock atomically"

The lesson: a system-centric AC is precise and testable; a user-centric one hides the observable behavior.

Tip 2: Use “must” language for ACs — stronger and clearer than “should.”

Tip 3: A good spec makes code review easier — every line of code can be linked to an AC, so there’s no debate about “is this correct?”

Tip 4: Review edge cases with senior engineers — they carry production intuition for what actually goes wrong.

Gotcha 1: AC that is too detailed becomes an unnecessary implementation constraint — a spec defines what, not how.

Gotcha 2: Too many edge cases make a spec unreadable — prioritize by probability and impact.

Gotcha 3: Frequently revised, already-approved ACs frustrate developers — revisions are normal, churn is not.

Gotcha 4: Don’t let AI write the whole spec from a brief description — AI fills in the form, you decide the content.


05.19 Spec Evolution: Living Document Practice

A spec must evolve with the feature, and a disciplined change process keeps that evolution trustworthy:

  1. Discuss the change with stakeholders
  2. Update the spec file (in a separate branch if significant)
  3. Get the spec reviewed before changing code
  4. Update the code in a separate PR referencing the new spec version
  5. Update any related test names and comments

Each version is git-committed with a clear changelog — the “living spec” reflecting accumulated learning about the feature.


05.20 Summary

The anatomy of a good specification consists of four mutually supporting components:

User Story answers “why” — it provides the business context that makes every AC meaningful. Good user stories are specific about role, concrete about action, and clear about benefit.

Acceptance Criteria is a verifiable contract. Each AC must become a test function, specify HTTP status codes for error cases, and define the response format explicitly.

Edge Cases are the boundary conditions that separate an “adequate” spec from a “production-ready” one. Prioritize them by probability and impact, not theoretical completeness.

Non-Functional Requirements define “how” the system must behave — performance, availability, security, observability. Often missed, but crucial for production systems.

In the next article we cover a more advanced technique: prompt engineering for spec — how to use Claude Code to help write, review, and refine specifications effectively.

Related Articles

💬 Comments