Skip to main content
ship before you scale

Prompt Engineering for Backend Code Generation

5 min read Chapter 8 of 42

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.