Prompt Engineering for Code Generation
Master prompt engineering techniques for code generation. Specify architecture, request tests, and iterate effectively.
Most developers treat AI coding assistants like search engines: type what you want, get code back, move on. This approach produces mediocre code that creates the mess described in How to Use AI Coding Assistants Without Creating a Mess.
Effective prompt engineering for code generation is a skill. It's the difference between getting code you'll rewrite and getting code you'll keep. Here's how to develop that skill.
Why Prompt Quality Determines Code Quality
The AI model generates code based on three inputs:
You can't control #3. You can partially control #2 through context management. You have full control over #1.
Most code quality issues from AI tools trace back to prompt quality. Vague prompts produce vague code. Specific prompts produce specific code.
Technique 1: Specify Architecture Before Implementation
The most common mistake is asking for implementation without specifying architecture. The AI will choose an architecture for you — usually the most common pattern in its training data, which may not match your project.
Before (Vague Prompt)
Create a user authentication API endpoint.
This produces whatever the AI thinks a "typical" auth endpoint looks like. It might use sessions when you want JWT. It might use a different ORM than your project. It might create a monolithic function when your project uses a service layer.
After (Architecture-Specific Prompt)
Create a POST /api/auth/login endpoint.
- Use Express.js with TypeScript
- Validate input with Zod schema
- Use bcrypt for password comparison
- Return JWT access token (15 min) and refresh token (7 days)
- Use the existing User model from src/models/user.ts
- Follow the service pattern: controller → service → repository
- Throw AppError for authentication failures (existing error class)
- Add request logging via the existing logger middleware
The second prompt produces code that fits your project. It doesn't introduce new patterns, doesn't choose wrong libraries, and doesn't create unnecessary abstractions.
Architecture Prompt Template
Create [specific endpoint/feature].
- Framework: [your framework]
- Language: [your language]
- Input validation: [your validation approach]
- Data access: [your ORM/repository pattern]
- Error handling: [your error pattern]
- Response format: [your API response convention]
- Dependencies: [list existing modules to use]
- Pattern: [controller/service/repository or whatever you use]
Technique 2: Request Tests as Part of the Generation
AI generates code faster than it generates tests. If you don't ask for tests, you won't get them. And untested AI code is the highest-risk code in your codebase.
Before (No Tests Requested)
Create a function that calculates shipping costs based on weight and destination.
You get a function. It might work. You don't know.
After (Tests Included)
Create a function that calculates shipping costs based on weight and destination.
Requirements:
- Weight in kg, destination as country code
- Free shipping under 0.5kg for domestic
- Tiered pricing: 0.5-2kg, 2-5kg, 5-10kg, 10kg+
- International rates 2x domestic
- Throw ShippingError for invalid country codes
Also create tests covering:
- Each weight tier
- Domestic vs international
- Edge cases: exactly 0.5kg, negative weight, empty string
- Invalid country code handling
Now you get a function with tests. The tests verify the behavior you specified. You can review both together and catch discrepancies.
The Test-First Generation Pattern
For complex logic, use test-first generation:
First, create tests for a function that [describe behavior].
- Test each requirement
- Test edge cases
- Test error conditions
Then implement the function to pass all tests.
This produces better code because the tests constrain the implementation. The AI can't take shortcuts when the tests define what "done" means.
Technique 3: Use Constraints to Control Output
Constraints are the most underused prompt engineering technique. They prevent the AI from making choices you don't want it to make.
Common Code Constraints
No unnecessary abstractions:Create a utility function. Do not use classes, factories, or design patterns.
Keep it as a simple exported function.
Match existing patterns:
Look at src/services/user.ts for the pattern to follow.
Create a similar service for the Product entity.
Specific implementation:
Use a for loop, not Array.reduce.
Use explicit types, not any.
Use named functions, not arrow functions.
Performance constraints:
This function will be called 10,000 times per minute.
Optimize for performance over readability.
No async operations in the hot path.
Security constraints:
Never log user passwords.
Always use parameterized queries.
Validate all inputs before processing.
The Constraint Stack
Layer constraints for maximum control:
anyTechnique 4: Iterative Refinement
The best AI-generated code comes from iteration, not from a single perfect prompt. Treat each generation as a draft.
The Refinement Cycle
Refinement Prompts
For missing edge cases:Good start. Now handle these edge cases:
- What happens when input is null?
- What happens with concurrent requests?
- What happens when the database is unavailable?
For pattern matching:
This doesn't match our project patterns. See src/services/order.ts.
Rewrite to use the same structure: validation → business logic → data access → response.
For simplification:
This is too complex. Simplify:
- Remove the abstract base class
- Use a single function instead of a class
- Inline the helper functions
For testing:
Add tests for this implementation:
- Happy path
- Invalid input
- Boundary conditions
- Error handling
Technique 5: Context Management
Your AI assistant's context window is limited. How you manage context determines the quality of generated code.
The Context Hierarchy
Order your context by importance:
Context Loading Strategy
For a new module:Here's the existing pattern (from src/services/user.ts):
[paste code]
Create a similar module for [entity].
Follow the same structure, naming conventions, and error handling.
For modifying existing code:
Current implementation:
[paste current code]
I need to add [feature]. Modify the existing code to include this,
maintaining the same patterns and style.
For understanding before generating:
I need to understand [module] before generating similar code.
What patterns does it use? What are the key abstractions?
Then create a similar module for [new entity].
Context Window Optimization
- Don't paste your entire codebase. Paste only what's relevant.
- Summarize large files instead of including them fully.
- Reference files by path when the AI can access them.
- Include only the most relevant examples of patterns you want followed.
Before/After Prompt Examples
Example 1: API Endpoint
Before:Create a REST API for managing products.
After:
Create a REST API for managing products with these endpoints:
- GET /api/products (list with pagination)
- GET /api/products/:id (single product)
- POST /api/products (create)
- PUT /api/products/:id (update)
- DELETE /api/products/:id (soft delete)
Constraints:
- Express.js + TypeScript
- Use the existing Product model from src/models/product.ts
- Follow the controller → service → repository pattern from src/services/user.ts
- Validate all inputs with Zod
- Return standard API response format: { success, data, error, pagination }
- Use the existing error handler middleware
- Add request validation middleware
- Include basic rate limiting (100 req/min)
Also create tests for each endpoint covering success, validation errors, not found, and unauthorized access.
Example 2: Database Query
Before:Write a query to get user orders.
After:
Write a PostgreSQL query using the existing database connection (src/db/index.ts):
Get all orders for a user, including:
- Order ID, status, total, created_at
- Items count and item names (comma-separated)
- Shipping address from the addresses table
Requirements:
- Filter by user_id parameter
- Filter by status (optional, defaults to all)
- Paginate results (offset/limit)
- Order by created_at descending
- Return total count for pagination metadata
Use parameterized queries (no string concatenation).
Handle empty results gracefully.
Example 3: React Component
Before:Create a product card component.
After:
Create a ProductCard React component in src/components/ProductCard.tsx:
Props:
- product: { id, name, price, imageUrl, rating, inStock }
- onAddToCart: (productId: string) => void
- variant: 'compact' | 'full' (default: 'full')
Behavior:
- Display product image, name, price, rating (stars), stock status
- Compact variant: horizontal layout, smaller image
- Full variant: vertical layout, larger image, show add-to-cart button
- Disable button if out of stock
- Show loading state when adding to cart
Constraints:
- Use existing Button component from src/components/ui/Button.tsx
- Use existing StarRating component from src/components/ui/StarRating.tsx
- Use CSS modules (existing pattern in src/components/)
- TypeScript with explicit prop types
- No inline styles
- Accessible: proper alt text, aria labels on interactive elements
Include Storybook stories for both variants and states (loading, disabled, in stock, out of stock).
Building a Prompt Library
After you've refined prompts that produce good results, save them.
Prompt Library Structure
prompts/
api/
create-endpoint.md
add-pagination.md
add-auth-middleware.md
components/
create-card.md
create-form.md
create-table.md
services/
create-service.md
add-validation.md
add-caching.md
tests/
unit-tests.md
integration-tests.md
api-tests.md
Prompt Template Format
For each prompt template, document:
- What it produces: The type of code generated
- When to use it: The situation where this prompt is most effective
- Customization points: What to change for your specific needs
- Common modifications: Follow-up prompts to refine the output
FAQ
How many iterations does good prompt engineering usually take?
For simple code (utility functions, basic CRUD), 1-2 iterations usually produces good results. For complex features (authentication, payment processing, multi-service orchestration), expect 3-5 iterations. The key is that each iteration should be targeted — fix specific issues rather than starting over.
Should I use system prompts or user prompts for code generation?
Both, for different purposes. Use system prompts for project-wide constraints: coding standards, technology choices, architectural patterns. Use user prompts for specific tasks: the feature you're building, the function you need, the test you want. The system prompt sets the rules; the user prompt gives the task.
How do I handle AI that keeps generating the same bad pattern?
Be explicit about what you don't want, not just what you do want. "Don't use reduce" is sometimes more effective than "use a for loop." You can also provide a positive example: "Like this: [paste good code]." The AI learns from the pattern you show it.
Can I use these techniques with any AI coding tool?
Yes. These techniques work with Copilot, Cursor, Codeium, Claude, ChatGPT, and any other AI coding assistant. The underlying principle is the same: specific prompts produce better code. The tools differ in how they handle context and how they integrate with your editor, but the prompting fundamentals are universal.
What's the biggest prompt engineering mistake developers make?
Being too vague. "Create a login page" is not a prompt — it's a wish. "Create a login page with email/password fields, client-side validation, CSRF protection, rate limiting, and integration with our existing auth service" is a prompt. The specificity of your prompt directly determines the quality of the output.
Need help with your vibe-coded codebase?
Get a free assessment. We'll tell you exactly what needs fixing and in what order.