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

Generate Go Code from a Spec: Iteration and Feedback Loop

An iterative technique for generating Golang code from a specification with Claude Code. An effective feedback loop for producing spec-compliant implementations of Santekno Shop.

IH
Ihsan Arif
Writer at Santekno · Backend Engineer

Generate Go Code from Spec: Iteration and Feedback Loop

Learning to generate Go code from a spec with Claude Code is where SDD stops being planning and becomes shipping. We already have a solid spec, an approved plan, and a clear task breakdown. Now it’s time to do the most visible part: write code.

But “generate code” does not mean one prompt, code done, finished. In SDD, code generation is an iterative process with a tight feedback loop between the AI’s output and the spec that serves as the reference. This article covers that technique concretely, using the cancel-order feature of our Santekno Shop.


12.1 Iterative vs One-Shot Code Generation

There are two ways to use AI for code generation, and the difference matters enormously in production. The first — one-shot — asks for the whole feature at once, which sounds efficient but is hard to review and easy to get subtly wrong. The block below shows why it fails.

text
1One-shot (not recommended for production):
2Prompt: "Implement the entire cancel order feature based on the spec"
3Output: [200 lines all at once]
4Review: difficult, too much to check at once
5Risk: miss subtle AC/EC, inconsistency with the existing codebase

The iterative approach instead builds the feature in small, reviewable increments, verifying each against the spec before moving on:

text
1Iterative (what we use):
2Prompt 1: "Implement CancelOrderInput and the CancelOrderUseCase struct"
3Output: [15 lines]  -> Review: quick, verify against spec
4
5Prompt 2: "Implement the Execute method"
6Output: [40 lines]  -> Review: trace to each AC/EC
7
8Prompt 3: "Add error handling for non-PENDING orders"
9Output: [15 lines]  -> Review: verify the error message matches spec AC9

The takeaway: the iterative approach trades a little speed for a lot of control — errors stay small and isolated, and every increment is validated against the spec before the next one is built on top of it.


12.2 Prompt Templates for Code Generation

Consistent prompts produce consistent code, so it’s worth keeping a few templates. The first isolates the struct and constructor so you can review the shape before any logic exists.

text
1Implement [StructName] for [Feature] based on the spec at [spec-path].
2
3Requirements:
4- Input: [required fields]
5- Dependencies: [required interfaces]
6- Follow the pattern from [reference file]
7
8Don't implement the Execute method yet. Only the struct definition and constructor.

The second template drives the business logic and, importantly, asks the AI to map each generated line back to a spec requirement:

text
 1Now implement the Execute method for [UseCase].
 2
 3Spec to follow:
 4- AC1: [description]
 5- AC3: [description]
 6- EC1: [description]
 7
 8For EC1 (concurrent cancel), use the same pattern as [reference].
 9
10After completing, list each AC/EC and which line implements it.

The third handles the HTTP boundary, where the key detail is the error-to-status mapping:

text
1Implement the CancelOrder HTTP handler for Echo v4.
2
3Error mapping:
4- ErrOrderNotFound -> 404 with { error_code: "ORDER_NOT_FOUND" }
5- OrderNotCancellableError -> 409 with the error code from the error
6- Other errors -> 500 with the error logged
7
8Success: 204 No Content (AC6 in the spec)
9Pattern from order_handler.go CreateOrder method.

The takeaway: every template shares one trait — it names a reference file and ties output to the spec. That combination is what keeps generated code both idiomatic to your codebase and provably compliant.


12.3 Generating the UseCase: Struct and Constructor

Let’s walk the first increment for real. We ask only for the types and constructor, so the review surface stays tiny. Note the two opening lines: every prompt points at the spec and the plan first, then gets specific. Without that anchor the list below reads as though we invented it — when in fact all five items come straight from the plan we reviewed in article 10.

text
 1Read specs/order/cancel-order.md and docs/plans/cancel-order-2025-07-15.md.
 2Do TASK 5 (UseCase Implementation), first stage only: struct and
 3constructor, NOT the Execute method yet.
 4
 5Per the plan, implement:
 61. CancelOrderInput struct: OrderID, UserID (uuid.UUID)
 72. OrderCancelledPublisher interface:
 8   PublishOrderCancelled(ctx, orderID, userID uuid.UUID) error
 93. CancelOrderUseCase struct: repo (OrderRepository), publisher
104. Constructor: NewCancelOrderUseCase(repo, publisher)
115. Sentinel + typed errors: ErrOrderNotFound, OrderNotCancellableError,
12   CancelWindowExpiredError (each typed error exposes ErrorCode())
13
14Follow the naming convention from internal/usecase/order/create_order.go.

Claude returns the types and constructor — no logic yet, exactly as requested:

go
 1package order
 2
 3import (
 4	"context"
 5	"errors"
 6	"fmt"
 7
 8	"github.com/google/uuid"
 9	domainorder "github.com/santekno/santekno-shop/internal/domain/order"
10)
11
12// ErrOrderNotFound is returned when the order does not exist or is not owned by the user.
13var ErrOrderNotFound = errors.New("order not found")
14
15// OrderCancelledPublisher publishes the ORDER_CANCELLED event.
16type OrderCancelledPublisher interface {
17	PublishOrderCancelled(ctx context.Context, orderID, userID uuid.UUID) error
18}
19
20// CancelOrderInput carries the input for cancelling an order.
21type CancelOrderInput struct {
22	OrderID uuid.UUID
23	UserID  uuid.UUID
24}
25
26// OrderNotCancellableError is returned when the order status prevents cancellation.
27type OrderNotCancellableError struct {
28	OrderID       uuid.UUID
29	CurrentStatus domainorder.Status
30}
31
32func (e *OrderNotCancellableError) Error() string {
33	return fmt.Sprintf("order %s cannot be cancelled: status is %s", e.OrderID, e.CurrentStatus)
34}
35
36func (e *OrderNotCancellableError) ErrorCode() string { return "ORDER_NOT_CANCELLABLE" }
37
38// CancelWindowExpiredError is returned when the cancellation window has passed.
39type CancelWindowExpiredError struct {
40	OrderID uuid.UUID
41}
42
43func (e *CancelWindowExpiredError) Error() string  { return "cancellation window has expired" }
44func (e *CancelWindowExpiredError) ErrorCode() string { return "CANCEL_WINDOW_EXPIRED" }
45
46// CancelOrderUseCase holds the cancel-order business logic.
47type CancelOrderUseCase struct {
48	repo      domainorder.Repository
49	publisher OrderCancelledPublisher
50}
51
52// NewCancelOrderUseCase constructs a CancelOrderUseCase.
53func NewCancelOrderUseCase(repo domainorder.Repository, publisher OrderCancelledPublisher) *CancelOrderUseCase {
54	return &CancelOrderUseCase{repo: repo, publisher: publisher}
55}

The takeaway: because each typed error already exposes an ErrorCode() method, the HTTP handler later gets a clean mapping from error to response code without a giant switch — a design decision that’s cheap to make now and painful to retrofit.


12.4 Generating the Execute Method

With the types reviewed, the next increment is the business logic. The prompt below spells out the exact flow and which AC/EC each step satisfies, so the output is traceable line by line.

text
 1Now implement Execute(ctx, input CancelOrderInput) error.
 2
 3Flow per spec:
 41. GetByIDAndUserID -> if nil, return ErrOrderNotFound (AC7, AC8)
 52. order.CanBeCancelled():
 6   - not PENDING -> OrderNotCancellableError (AC9)
 7   - window expired -> CancelWindowExpiredError (AC10)
 83. repo.CancelWithStockRestore -> atomic cancel (AC5, EC1, EC4 in repo)
 94. publisher.PublishOrderCancelled -> best effort (EC2: on failure, log + continue)
10
11Error wrapping: fmt.Errorf("cancel order: %w", err)

The generated method carries inline comments mapping each block back to the spec:

go
 1// Execute performs the order cancellation.
 2// Implements: specs/order/cancel-order.md v1.3
 3// AC1-AC6: main cancellation flow; AC7-AC10: error cases.
 4// EC1, EC4: handled by repo.CancelWithStockRestore (SELECT FOR UPDATE + rollback).
 5// EC2: Kafka failure is best-effort.
 6// EC3: DB timeout — not handled yet, see spec.
 7func (uc *CancelOrderUseCase) Execute(ctx context.Context, input CancelOrderInput) error {
 8	// AC7, AC8: nil means not found OR not owned by the user.
 9	order, err := uc.repo.GetByIDAndUserID(ctx, input.OrderID, input.UserID)
10	if err != nil {
11		return fmt.Errorf("cancel order: get order: %w", err)
12	}
13	if order == nil {
14		return ErrOrderNotFound
15	}
16
17	// AC3, AC4, AC9, AC10: validate status + time window.
18	if !order.CanBeCancelled() {
19		if order.Status != domainorder.StatusPending {
20			return &OrderNotCancellableError{OrderID: input.OrderID, CurrentStatus: order.Status}
21		}
22		return &CancelWindowExpiredError{OrderID: input.OrderID}
23	}
24
25	// AC5, EC1, EC4: atomic cancel with stock restore (SELECT FOR UPDATE in repo).
26	if err := uc.repo.CancelWithStockRestore(ctx, input.OrderID); err != nil {
27		return fmt.Errorf("cancel order: cancel with stock restore: %w", err)
28	}
29
30	// EC2: best-effort event publish; failure does not roll back.
31	if err := uc.publisher.PublishOrderCancelled(ctx, input.OrderID, input.UserID); err != nil {
32		slog.WarnContext(ctx, "failed to publish ORDER_CANCELLED event",
33			"order_id", input.OrderID, "error", err)
34	}
35
36	return nil // AC6: handler returns 204 No Content
37}

The takeaway: the inline // AC7, AC8 style comments are not decoration — they turn the code into its own compliance map, so a reviewer can trace every requirement to a line without leaving the file.


12.5 Spec Compliance Verification

After each generation block, don’t eyeball it — ask the AI to check its own output against the spec, requirement by requirement. The prompt below produces a pass/fail line for every AC and EC.

text
 1Code just generated:
 2@internal/usecase/order/cancel_order.go
 3
 4Spec to satisfy:
 5@specs/order/cancel-order.md (AC1-AC10, EC1-EC4)
 6
 7For each AC and EC, is it implemented in the code?
 8Output format:
 9[OK] AC1: implemented at line X
10[MISSING] AC4: no implementation
11[PARTIAL] EC2: partially implemented — missing X

The takeaway: a structured compliance check catches the subtle miss — the edge case that “looks handled” but isn’t — far more reliably than a human skim, and it produces a written record you can attach to the PR.


12.6 Generating the Repository Implementation

The repository is where the trickiest correctness lives: atomicity, locking, and rollback. The prompt below gives Claude the exact SQL and transaction shape rather than leaving it to guess.

text
1Implement two methods in internal/repository/postgres/order_repository.go:
2
31. GetByIDAndUserID(ctx, orderID, userID) (*domain.Order, error)
4   Return nil, nil when not found (don't leak existence to non-owners).
5
62. CancelWithStockRestore(ctx, orderID) error
7   Transaction: SELECT ... FOR UPDATE (EC1) -> UPDATE orders SET status='CANCELLED'
8   -> UPDATE products restore stock -> COMMIT. Roll back on any failure (EC4).

The generated repository code implements the ownership-safe read and the atomic cancel:

go
 1// CancelWithStockRestore cancels an order and restores item stock atomically.
 2// EC1 (concurrent cancel) via SELECT FOR UPDATE; EC4 (partial failure) via rollback.
 3func (r *orderRepository) CancelWithStockRestore(ctx context.Context, orderID uuid.UUID) error {
 4	tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{})
 5	if err != nil {
 6		return fmt.Errorf("begin transaction: %w", err)
 7	}
 8	defer tx.Rollback(ctx) // no-op if committed
 9
10	// EC1: lock the row to serialize concurrent cancels.
11	var status domain.Status
12	if err := tx.QueryRow(ctx,
13		`SELECT status FROM orders WHERE id = $1 FOR UPDATE`, orderID,
14	).Scan(&status); err != nil {
15		return fmt.Errorf("lock order: %w", err)
16	}
17	if status != domain.StatusPending {
18		return &domain.OrderNotCancellableError{CurrentStatus: status}
19	}
20
21	if _, err := tx.Exec(ctx,
22		`UPDATE orders SET status = 'CANCELLED', updated_at = NOW() WHERE id = $1`, orderID,
23	); err != nil {
24		return fmt.Errorf("update order status: %w", err)
25	}
26
27	if _, err := tx.Exec(ctx, `
28		UPDATE products p
29		SET stock = p.stock + oi.quantity, updated_at = NOW()
30		FROM order_items oi
31		WHERE oi.order_id = $1 AND oi.product_id = p.id`, orderID,
32	); err != nil {
33		return fmt.Errorf("restore stock: %w", err) // EC4: defer rolls back
34	}
35
36	if err := tx.Commit(ctx); err != nil {
37		return fmt.Errorf("commit transaction: %w", err)
38	}
39	return nil
40}

The takeaway: the defer tx.Rollback(ctx) paired with an explicit Commit is the pattern that makes EC4 correct by construction — any early return from an error automatically rolls back, so partial failures can never leave the order half-cancelled.


12.7 Generating the HTTP Handler

The handler’s only job is mapping usecase errors to HTTP responses. Because each error type exposes ErrorCode(), the mapping stays compact. The generated handler below uses errors.Is/errors.As for a clean switch.

go
 1// CancelOrder handles DELETE /api/v1/orders/:id (specs/order/cancel-order.md v1.3).
 2func (h *OrderHandler) CancelOrder(c echo.Context) error {
 3	orderID, err := uuid.Parse(c.Param("id"))
 4	if err != nil {
 5		return c.JSON(http.StatusBadRequest, ErrorResponse{ErrorCode: "INVALID_REQUEST", Message: "invalid order ID"})
 6	}
 7	userID, _ := c.Get("userID").(uuid.UUID)
 8
 9	err = h.cancelOrderUC.Execute(c.Request().Context(),
10		cancelorder.CancelOrderInput{OrderID: orderID, UserID: userID})
11	if err != nil {
12		var notCancellable *cancelorder.OrderNotCancellableError
13		var windowExpired *cancelorder.CancelWindowExpiredError
14		switch {
15		case errors.Is(err, cancelorder.ErrOrderNotFound):
16			return c.JSON(http.StatusNotFound, ErrorResponse{ErrorCode: "ORDER_NOT_FOUND", Message: "order not found"})
17		case errors.As(err, &notCancellable):
18			return c.JSON(http.StatusConflict, ErrorResponse{ErrorCode: notCancellable.ErrorCode(), Message: notCancellable.Error()})
19		case errors.As(err, &windowExpired):
20			return c.JSON(http.StatusConflict, ErrorResponse{ErrorCode: windowExpired.ErrorCode(), Message: windowExpired.Error()})
21		default:
22			slog.ErrorContext(c.Request().Context(), "cancel order failed", "order_id", orderID, "error", err)
23			return c.JSON(http.StatusInternalServerError, ErrorResponse{ErrorCode: "INTERNAL_ERROR", Message: "internal server error"})
24		}
25	}
26	return c.NoContent(http.StatusNoContent) // AC6: 204
27}

The takeaway: the handler contains zero business logic — it only translates errors to status codes and returns 204 on success. Keeping it that thin is exactly what makes the usecase testable in isolation and the handler trivial to reason about.


12.8 Handling Code That Doesn’t Match the Spec

Sometimes the generated code drifts from the spec, and the fix is a targeted correction rather than a full regeneration. The examples below show the three most common drifts and the precise prompts that fix them.

text
 1Wrong response format:
 2"Spec AC6 says 204 No Content, but the handler returns 200 with a body.
 3Fix CancelOrder to return 204 via c.NoContent(http.StatusNoContent)."
 4
 5Wrong error code:
 6"Spec says error_code should be ORDER_NOT_CANCELLABLE but the code uses
 7NOT_CANCELLABLE. Update OrderNotCancellableError.ErrorCode()."
 8
 9Missing edge case:
10"EC1 (concurrent cancel via SELECT FOR UPDATE) is missing from the repository.
11Add SELECT id FROM orders WHERE id = $1 FOR UPDATE before the UPDATE."

The takeaway: precise, spec-referenced corrections (“AC6 says…”) work far better than vague ones (“fix the handler”) — naming the exact requirement gives Claude the anchor it needs to make a surgical change.


12.9 Generated Code Quality Review Checklist

Functionality passing isn’t the same as production-ready. After each generation, run the output past a fixed checklist covering correctness, idioms, architecture, and security. The list below is that checklist.

markdown
 1### Correctness
 2- [ ] Output matches spec (trace each AC/EC)
 3- [ ] Error handling complete for all cases
 4- [ ] No nil-pointer risk
 5- [ ] Context propagated to all calls
 6
 7### Go Idioms
 8- [ ] Error wrapping via fmt.Errorf("...: %w", err)
 9- [ ] No unintended panics
10- [ ] Interfaces used correctly
11- [ ] Logging uses log/slog
12
13### Architecture
14- [ ] No layer violations
15- [ ] Dependency injection via constructor
16- [ ] No hardcoded values that should be configurable
17
18### Security
19- [ ] No sensitive data in logs
20- [ ] All SQL uses parameterized queries

The takeaway: the Security row is the one most easily forgotten under time pressure and the most expensive to miss — a fixed checklist ensures parameterized queries and clean logs get verified on every generation, not just the ones you remember to check.


12.10 Handling AI Hallucination

AI occasionally invents APIs or patterns that don’t exist. Knowing the red flags — and having a one-command verification — keeps hallucinations from reaching your branch. The reference below lists both.

text
 1Red flags:
 2- Unfamiliar import paths
 3- Methods/functions that don't exist in stdlib or your libraries
 4- Patterns different from the existing codebase
 5
 6Verification:
 7go build ./...   # import errors or undefined funcs => likely hallucinated
 8
 9Reduce hallucination by constraining the toolbox:
10"Implement using only: Go stdlib (context, fmt, errors, log/slog),
11github.com/jackc/pgx/v5, github.com/google/uuid, and internal packages.
12Don't use other packages without confirming first."

The takeaway: go build ./... after every generation is the cheapest hallucination detector you have — an undefined function fails the compile immediately, long before it can cause a subtle runtime bug.


12.11 Version Control for Iterations

Each significant iteration deserves its own snapshot so you can roll back to a known-good state. The commits below capture the types and the Execute method as separate, spec-referenced checkpoints.

bash
 1# After generating struct and types (Task 5a)
 2git commit -m "feat(order): add cancel order types and constructor
 3
 4WIP: struct and constructor only, Execute not yet implemented"
 5
 6# After generating the Execute method (Task 5b)
 7git commit -m "feat(order): implement CancelOrderUseCase.Execute
 8
 9Implements specs/order/cancel-order.md AC1-AC10, EC2
10EC1, EC4 handled by the repository transaction"

The takeaway: granular, spec-referenced commits let you rewind a single bad iteration without losing the good ones — the WIP checkpoint is a safety net, not clutter.


12.12 Using AI as a Code Reviewer

Before opening a PR, put the finished code back in front of the AI in a reviewer role. The prompt below asks for anti-patterns, leak risks, and readability improvements — a cheap pre-review pass.

text
 1Here is the completed cancel order implementation:
 2@internal/usecase/order/cancel_order.go
 3
 4Review as a senior Go engineer:
 51. Any Go anti-patterns?
 62. Is the error handling idiomatic?
 73. Any potential goroutine or resource leaks?
 84. A more readable approach for section X?
 95. Are the comments clear enough?
10
11Give specific, actionable feedback.

The takeaway: an AI reviewer won’t replace a human one, but it reliably catches leaks and non-idiomatic patterns before a teammate sees them — making the human review about design rather than mechanics.


12.13 Comparing Output with a Reference Implementation

A powerful consistency check is to diff the new code against an existing sibling. The prompt below compares the new cancel usecase to the established create usecase so both feel written by the same hand.

text
1Compare the new CancelOrderUseCase.Execute with the existing
2CreateOrderUseCase.Execute:
3@internal/usecase/order/cancel_order.go and @specs/order/cancel-order.md
4
5Identify inconsistencies in error handling style, logging pattern,
6and naming — and parts of Cancel that should align with Create.
7
8Goal: both should feel written by the same person.

The takeaway: consistency across usecases is what keeps a codebase learnable — comparing against a reference implementation catches the stylistic drift that no single-file review would ever surface.


12.14 AI as Pair Programmer, Not Code Monkey

The mental model that underlies everything above is simple: you decide design and approach, and the AI executes. That boundary is what separates SDD from vibe coding, and it’s worth stating the division of labor explicitly:

  • You decide the error-handling strategy
  • You decide the concurrency approach
  • You decide what goes in which layer
  • The AI implements your decisions efficiently

The takeaway: surrendering architectural decisions to the AI is how you end up with code that works but doesn’t fit — keep the design authority yourself and let the AI handle the mechanical execution.


12.15 Tips & Gotchas

A few habits make iterative generation dramatically smoother, and a few traps reliably bite the unwary.

  • Tip 1: One generation, one review — don’t queue multiple generations without reviewing in between.
  • Tip 2: Always include a reference file in the prompt — concrete examples beat abstract guidelines.
  • Tip 3: Request inline comments that map code to spec — they make review and maintenance far easier.
  • Tip 4: Run go build ./... after every generation to catch compile errors before they accumulate.
  • Gotcha 1: AI can produce code that “looks right” but behaves wrong — always trace each EC to its implementation.
  • Gotcha 2: Long sessions fill the context window — start a fresh session for each large task.
  • Gotcha 3: Don’t blindly accept “improvement” suggestions — verify they don’t change behavior.
  • Gotcha 4: Generated code isn’t always idiomatic Go — review for idioms, not just functionality.

The takeaway: the through-line is verification after every step — build it, trace it to the spec, and never let an unreviewed generation become the foundation for the next one.


12.16 Summary

Generating Go code from a spec is a structured, iterative process, not a one-shot dump. The proven workflow is consistent: generate the struct, review, generate the business logic, trace it against the spec, generate the tests, and finish with a full compliance check.

The engine that makes it work is a tight feedback loop — after every generation you run a spec compliance check, identify gaps explicitly, and request specific fixes. Quality beats quantity: 20 perfect lines from a focused prompt are worth more than 200 lines that need heavy editing, so start small and iterate. Above all, treat the AI as a pair programmer, not a code monkey — you own the design and approach, and the AI executes your decisions.

In the next article we cover writing unit tests from the spec — turning acceptance criteria directly into test cases so the implementation is provably compliant.

Related Articles

💬 Comments