Prompt Engineering for Backend Code Generation
Prompt Engineering for Backend Code Generation
The Feature
A developer generates FastAPI backend code using consistent prompt templates that produce output meeting the project’s quality standards without requiring major rewrites.
The Decision
Prompts are structured, not conversational. “Write me an endpoint” produces generic code. A structured prompt with explicit constraints produces code that matches the project’s patterns. The upfront cost of writing the prompt template is paid once. The benefit compounds across every endpoint generated.
The Implementation
Pydantic Schema Prompt
Generate Pydantic v2 models for [entity] with these requirements:
ENTITY: [name]
FIELDS: [list fields with types and constraints]
OPERATIONS: create, read, update (generate separate models for each)
RULES:
- Use model_config = ConfigDict(from_attributes=True) for read models
- Create models omit id, created_at, updated_at (server-generated)
- Update models make all fields Optional
- String fields have max_length constraints
- Email fields use EmailStr
- No field named 'model_config' or 'schema' (Pydantic reserved)
- Use UUID for all IDs, not int
- Add field_validator for any field with business rules
Output without the prompt template:
# Unprompted: missing constraints, using dict, no separation of concerns
class Vendor(BaseModel):
id: int
name: str
email: str
data: dict # What goes here? Anything, apparently.
Output with the prompt template:
# Prompted: proper constraints, separate models, validated fields
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
import uuid
class VendorCreate(BaseModel):
business_name: str = Field(max_length=200)
email: EmailStr
product_categories: list[str] = Field(min_length=1, max_length=10)
description: str = Field(max_length=2000)
@field_validator("product_categories")
@classmethod
def validate_categories(cls, v: list[str]) -> list[str]:
allowed = {"produce", "baked_goods", "crafts", "prepared_food", "beverages", "other"}
invalid = set(v) - allowed
if invalid:
raise ValueError(f"Invalid categories: {invalid}")
return v
class VendorRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
business_name: str
email: EmailStr
product_categories: list[str]
description: str
created_at: datetime
class VendorUpdate(BaseModel):
business_name: str | None = Field(default=None, max_length=200)
product_categories: list[str] | None = Field(default=None, min_length=1, max_length=10)
description: str | None = Field(default=None, max_length=2000)
The prompted version produces three models instead of one, adds field constraints, validates business rules, and separates create/read/update concerns. The unprompted version produces a single model with an untyped dict field.
SQLAlchemy Model Prompt
Generate a SQLAlchemy 2.0 model for [entity]:
TABLE: [name]
COLUMNS: [list with types, constraints, and relationships]
RULES:
- Use Mapped[] and mapped_column() syntax (SQLAlchemy 2.0)
- Primary key is UUID, server-generated with uuid7
- Include created_at and updated_at timestamps with server defaults
- Foreign keys reference other tables by UUID
- Add __tablename__ explicitly
- Use proper index=True on columns used in WHERE clauses
- Add unique constraints where business rules require them
- Relationships use back_populates, not backref
# Generated with the prompt template
import uuid as uuid_mod
from datetime import datetime
from sqlalchemy import ForeignKey, String, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
class Stall(Base):
__tablename__ = "stalls"
id: Mapped[uuid_mod.UUID] = mapped_column(
primary_key=True,
server_default=text("gen_random_uuid()"),
)
market_id: Mapped[uuid_mod.UUID] = mapped_column(
ForeignKey("markets.id", ondelete="CASCADE"),
index=True,
)
label: Mapped[str] = mapped_column(String(50))
size_category: Mapped[str] = mapped_column(String(20))
price_per_day: Mapped[int | None] = mapped_column() # cents
assigned_vendor_id: Mapped[uuid_mod.UUID | None] = mapped_column(
ForeignKey("vendors.id", ondelete="SET NULL"),
index=True,
)
created_at: Mapped[datetime] = mapped_column(
server_default=text("now()"),
)
updated_at: Mapped[datetime] = mapped_column(
server_default=text("now()"),
onupdate=datetime.utcnow,
)
market: Mapped["Market"] = relationship(back_populates="stalls")
assigned_vendor: Mapped["Vendor | None"] = relationship()
Service Layer Prompt
Generate a service function for [operation]:
OPERATION: [what it does]
INPUT: [parameters with types]
OUTPUT: [return type]
DATABASE: AsyncSession (SQLAlchemy 2.0 async)
AUTH: [who can call this and what they can access]
RULES:
- async function, fully typed
- Use select() builder, not session.query()
- Handle not-found cases with explicit checks
- Handle permission denied with explicit checks
- Use proper SQLAlchemy 2.0 patterns (result.scalar_one_or_none())
- No string concatenation in queries
- Raise domain-specific exceptions, not HTTPException (that is the router's job)
This separation between service and router is important. Services raise domain exceptions (VendorNotFoundError, NotAuthorizedError). Routers catch them and convert to HTTP responses. The AI tends to mix these layers if not instructed otherwise.
The Trap
# TRAP: AI-generated service function that catches and swallows errors
async def get_vendor_applications(
db: AsyncSession,
market_id: uuid.UUID,
) -> list[Application]:
try:
result = await db.execute(
select(Application).where(Application.market_id == market_id)
)
return list(result.scalars().all())
except Exception:
return [] # Silently returns empty list on database errors
# SAFE: Let database errors propagate
async def get_vendor_applications(
db: AsyncSession,
market_id: uuid.UUID,
) -> list[Application]:
result = await db.execute(
select(Application).where(Application.market_id == market_id)
)
return list(result.scalars().all())
Coding assistants frequently add try/except blocks that catch Exception and return empty results or default values. This hides database connection failures, query errors, and schema mismatches behind an empty list that looks like “no results” to the caller. If the database is down, the application should fail loudly, not pretend everything is fine.
The Cost
Time invested in writing prompt templates: approximately 2 hours for the full set. Time saved per generated endpoint: approximately 15-30 minutes of cleanup and debugging. Break-even point: 8-10 endpoints, which Marketflow exceeds by Chapter 7.