Blog

Samen code maken met LLMs

| Colin Kerkhof

AI-engineering verandert snel en wij zoeken steeds naar manieren om verder te gaan dan code-aanvulling en Large Language Models (LLMs) in te zetten voor complexere, projectspecifieke ontwikkeltaken. De uitdaging is niet zozeer het genereren van stukjes code, maar het integreren van de LLM in onze ontwikkelcyclus als samenwerkingspartner. Hoe stuur je er dan een die robuuste, projectspecifieke boilerplate bouwt volgens jouw eigen architectuurpatronen?

Voor een recent project bouwden we een systeem dat de beoordeling van aanvragen voor sociale uitkeringen stroomlijnt. Dat werk zit vol complexe bedrijfsregels, gedetailleerde bewijsbeoordeling en strenge eisen aan dataconsistentie. De boilerplate voor datamodellen, API's en tests met de hand schrijven, voor elk type uitkering en elke voorwaarde, zou traag en foutgevoelig zijn geweest. Een goede kandidaat dus voor onze co-creatie-workflow met een LLM, waarmee we consistente code genereerden vanuit een set duidelijk gedefinieerde patronen.

Deze post beschrijft een gestructureerde, iteratieve workflow waarin de engineer de architect is en de LLM de zeer bekwame pair programmer. De samenwerking rust op een simpel motto: "Pas patroon X toe op onze specifieke context Y." Met heldere patronen en gerichte context verschuift het werk van boilerplate schrijven naar systemen ontwerpen.

Dit is een route die voor ons goed werkt. Probeer hem uit en pas hem aan op je eigen manier van werken.

Onze ontwikkelworkflow: een cyclus van vijf stappen

We hebben het proces teruggebracht tot vijf stappen die een idee van ruw concept naar een geïmplementeerde en geteste functie brengen, steeds in nauwe samenwerking met een LLM.

  1. Maak een blauwdruk: vertaal de projecteisen naar een formeel, machineleesbaar schema.
  2. Definieer het datamodel: pas een consistent architectuurpatroon toe om datamodellen uit dat schema te genereren.
  3. Bouw het endpoint: gebruik de datamodellen om API-endpoints te maken.
  4. Bouw de test: genereer property-based tests om te controleren of het endpoint correct en robuust is.
  5. Herhaal en breid uit: pas de vastgelegde patronen opnieuw toe om de rest van de applicatie te bouwen.

We lopen ze stuk voor stuk langs.

Stap 1: maak een blauwdruk van de kernideeën

Elk project begint met ideeën die verspreid liggen over vergadernotities, ontwerpdocumenten en whiteboardschetsen. De eerste stap is die samenbrengen in een formeel databaseschema, dat vanaf dat moment het geheugendocument is voor alle volgende stappen.

  • Doel: de projecteisen vertalen naar een formeel databaseschema.
  • Patroon: een algemene LLM-vaardigheid, namelijk een entity-relationship diagram (ERD) in Mermaid-markdown genereren uit ongestructureerde tekst.
  • Context: onze projectnotities over de benodigde entiteiten en hun relaties.
  • Voorbeeldprompt: "Given these notes about benefit types, applications, and eligibility criteria, generate a Mermaid ERD for a database schema."

Het resultaat is een bestand database_schema.md dat dient als enige bron van waarheid voor onze datastructuren.

Voorbeeldblauwdruk: het schema

erDiagram
    BENEFIT_TYPES {
        UUID id PK
        STRING name
        TEXT description
    }

    APPLICATIONS {
        UUID id PK
        UUID benefit_type_id FK
        ENUM status
        TIMESTAMP submitted_at
    }

    ELIGIBILITY_CRITERIA {
        UUID id PK
        UUID benefit_type_id FK
        STRING criterion
    }

    APPLICATION_DATA {
        UUID id PK
        UUID application_id FK
        STRING data_type
        STRING value
    }

    BENEFIT_TYPES ||--o{ APPLICATIONS : "are for"
    BENEFIT_TYPES ||--o{ ELIGIBILITY_CRITERIA : "have"
    APPLICATIONS ||--o{ APPLICATION_DATA : "contain"

Stap 2: definieer het datamodel met het command-query-modelpatroon

Met een blauwdruk in handen implementeren we de databasemodellen. Om ze consistent te houden gebruiken we een vast architectuurpatroon voor onze SQLModel-classes.

  • Doel: een enkel, hoogwaardig databasemodel implementeren.
  • Patroon: het command-query-modelpatroon (Base, Create, Table, Update), een architectuurpatroon dat we voor SQLModel hebben vastgelegd.
  • Context: een tabeldefinitie uit onze database_schema.md.
  • Voorbeeldprompt: "Using the 'EligibilityCriteria' table from @database_schema.md and our documented Command-Query model pattern, generate the corresponding SQLModel classes in @models.py."

Hiervoor heeft de LLM zowel het databaseschema nodig als een heldere uitleg van ons modelpatroon. Dat patroon is sterk geïnspireerd op de officiële SQLModel-tutorial over meerdere modellen met FastAPI, en die tutorial is zelf een uitstekend document om als context mee te geven.

Voorbeeld van een command-query-model

De LLM levert vaak code die klopt maar omslachtig is. Hier zet hij velden als id, created_at en updated_at in elk model afzonderlijk.

De eerste poging van de LLM

# LLM's first pass is functional, but repetitive.
class EligibilityCriterionBase(SQLModel):
    criterion: str
    benefit_type_id: uuid.UUID = Field(foreign_key="benefit_types.id")

class EligibilityCriterionCreate(EligibilityCriterionBase):
    pass

class EligibilityCriterion(EligibilityCriterionCreate, table=True):
    __tablename__: str = "eligibility_criteria"
    id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True, nullable=False)
    created_at: datetime | None = Field(default_factory=lambda: datetime.now(UTC), nullable=False)
    updated_at: datetime | None = Field(default_factory=lambda: datetime.now(UTC), nullable=False)


"""For a PATCH request, all fields should be optional."""
class EligibilityCriterionUpdate(SQLModel):
    criterion: str | None = None
    benefit_type_id: uuid.UUID | None = None

Dit werkt, maar het herhaalt zichzelf. Verander je hoe ID's of tijdstempels werken, dan moet je elk model aanpassen. De gemeenschappelijke velden losweken in een BaseRecord-model is beter. Je kunt de LLM opdragen dat te doen, of het zelf doen.

Door een mens bijgeschaafde code

Zo ziet het patroon eruit toegepast op een EligibilityCriteria-model. Het scheidt de basisvelden, het aanmaakschema, het tabelmodel en het updateschema.

class BaseRecord(SQLModel):
    id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True, index=True, nullable=False)
    created_at: datetime | None = Field(default_factory=lambda: datetime.now(UTC), nullable=False)
    updated_at: datetime | None = Field(default_factory=lambda: datetime.now(UTC), nullable=False)

class EligibilityCriterionBase(SQLModel):
    criterion: str
    benefit_type_id: uuid.UUID = Field(foreign_key="benefit_types.id")

class EligibilityCriterionCreate(EligibilityCriterionBase):
    pass

class EligibilityCriterion(EligibilityCriterionCreate, BaseRecord, table=True):
    __tablename__: str = "eligibility_criteria"

"""For a PATCH request, all fields should be optional."""
class EligibilityCriterionUpdate(SQLModel):
    criterion: str | None = None

Stap 3: de endpoints bouwen

Met de datamodellen op hun plek maken we de API-endpoints. Ook deze stap volgt een vast patroon.

  • Doel: generieke CRUD-functies maken plus een specifiek API-endpoint voor een model.
  • Patroon: een standaard FastAPI-router met GET, POST, PATCH en DELETE.
  • Context: onze command-query EligibilityCriterion-modellen uit models.py.
  • Voorbeeldprompt: "Implement standard RESTful CRUD operations in a FastAPI router for the 'EligibilityCriterion' table. Use the appropriate Schemas from @models.py."

De SQLModel-tutorial over integratie met FastAPI geeft een compleet voorbeeld van hoe je deze modellen aan CRUD-endpoints knoopt. Er is wel wat voorwerk nodig, zoals een database-dependency voor FastAPI. Wij raden tijdens het ontwikkelen een SQLite-database in het geheugen aan, voor snelheid en eenvoud.

Voorbeeld van een CRUD-router: van specifiek naar generiek

De eerste uitvoer definieert de endpoints maar laat de implementatie leeg. Vraag je om die in te vullen, dan genereert een LLM specifieke, omslachtige logica per functie.

De eerste poging van de LLM: specifieke CRUD-logica

@criterion_router.post("/")
async def create_criterion(
    session: AsyncSessionDep, criterion: EligibilityCriterionCreate
) -> EligibilityCriterion:
    """Creates a new criterion. This is verbose and will be repeated for every model."""
    db_criterion = EligibilityCriterion.model_validate(criterion)
    session.add(db_criterion)
    await session.commit()
    await session.refresh(db_criterion)
    return db_criterion

# ... (and imagine similar verbose implementations for read, update, and delete)

Dit herhaalt zich sterk. Generieke functies die CRUD afhandelen voor elke SQLModel-tabel zijn veel schoner, en die schrijft de menselijke programmeur.

Door een mens bijgeschaafde code: generieke CRUD-functies

T = TypeVar("T", bound=SQLModel)

async def generic_create(schema: type[T], data: BaseModel, session: AsyncSession) -> T:
    new_data = data.model_dump(exclude_unset=True)
    try:
        insert = schema.model_validate(new_data)
    except ValidationError as e:
        raise HTTPException(status_code=422, detail=str(e)) from e

    session.add(insert)
    await session.commit()
    await session.refresh(insert)
    return insert

# ... imagine similar generics for Read, Update, Delete ...

Daarmee wordt de router een stuk leesbaarder en beter te onderhouden.

De herschreven router

criterion_router = APIRouter(prefix="/criteria", tags=["criteria"])

@criterion_router.post("/")
async def create_criterion(
    session: AsyncSessionDep, criterion: EligibilityCriterionCreate
) -> EligibilityCriterion:
    return await generic_create(EligibilityCriterion, criterion, session)

@criterion_router.get("/")
async def read_criteria(
    session: AsyncSessionDep, skip: int = 0, limit: int = 100
) -> list[EligibilityCriterion]:
    return await generic_get_all(EligibilityCriterion, session, skip, limit)

@criterion_router.get("/{criterion_id}")
async def read_criterion(
    session: AsyncSessionDep, criterion_id: UUID
) -> EligibilityCriterion:
    return await generic_get(EligibilityCriterion, criterion_id, session)

@criterion_router.patch("/{criterion_id}")
async def update_criterion(
    session: AsyncSessionDep,
    criterion_id: UUID,
    criterion_update: EligibilityCriterionUpdate,
) -> EligibilityCriterion:
    return await generic_update(EligibilityCriterion, criterion_id, criterion_update, session)

@criterion_router.delete("/{criterion_id}")
async def delete_criterion(
    session: AsyncSessionDep, criterion_id: UUID
) -> EligibilityCriterion:
    return await generic_delete(EligibilityCriterion, criterion_id, session)

Stap 4: bouw property-based tests met hoge zeggingskracht

Een robuuste API vraagt om robuuste tests. Wij gebruiken property-based testing om te controleren of onze endpoints standhouden bij een brede reeks invoer.

  • Doel: zorgen dat de API robuust, betrouwbaar en correct is.
  • Patroon: property-based testing met pytest en Hypothesis.
  • Context: het endpoint dat een criterium aanmaakt, het bijbehorende EligibilityCriterionCreate-schema, voorbeelden van httpx.AsyncClient en voorbeelden van Hypothesis.
  • Voorbeeldprompt: "Write tests for the criterion router as defined in @crud.py. Use Hypothesis to generate test data based on the Schemas defined in @models.py and use the async test_client to call the API."

Deze stap gaat ervan uit dat de testinfrastructuur er al staat, zoals een pytest-fixture voor de httpx.AsyncClient die als testclient dient.

Voorbeeld van property-based testing

"""Generate Objects to Match the `Create` Model"""
@st.composite
def criterion_strategy(draw: st.DrawFn) -> dict[str, Any]:
    """Generate valid EligibilityCriterion data."""
    return {
        "criterion": draw(st.text(min_size=1, max_size=200)),
        "benefit_type_id": uuid.uuid4(),
    }
    """
    In a production test suite, we'd replace uuid.uuid4()
    with a pytest fixture that creates a BENEFIT_TYPES record
    and provides its ID, ensuring our foreign key constraint
    is always satisfied during testing.
    """

"""Supply the Object factory to the test to quickly test all properties"""
@given(new_criterion=criterion_strategy())
@pytest.mark.asyncio
async def test_create_criterion(
    self, new_criterion: dict[str, Any], test_client: AsyncClient
) -> None:
    """Test creating an EligibilityCriterion."""
    # In a real test, you'd ensure the benefit_type_id exists.
    validated_input = models.EligibilityCriterionCreate.model_validate(new_criterion)
    response = await test_client.post(
        "/criteria/", content=validated_input.model_dump_json()
    )

    assert response.status_code == HTTPStatus.OK
    created = response.json()

    assert "id" in created
    assert "created_at" in created
    assert "updated_at" in created
    assert created["criterion"] == new_criterion["criterion"]
    assert created["benefit_type_id"] == str(new_criterion["benefit_type_id"])

Voorbeeld van een testclient-fixture

import pytest_asyncio
from httpx import AsyncClient, ASGITransport

from main import app, get_db

# ... other fixtures or test DB setup etc. ...

@pytest_asyncio.fixture
async def test_client() -> AsyncGenerator[AsyncClient, None]:
    app.dependency_overrides[get_db] = get_test_db
    transport = ASGITransport(app)
    async with AsyncClient(transport=transport, base_url="http://test") as client:
        yield client
    app.dependency_overrides.clear()

Stap 5: herhaal en breid uit

Hier betaalt het voorwerk zich uit. Nu de patronen vastliggen in een hoogwaardige implementatie, gaat opschalen snel. We vragen de LLM diezelfde patronen toe te passen op de andere tabellen in ons schema.

Prompt 1: modellen maken

  • Patroon: ons command-query-modelpatroon.
  • Context: alle overige modellen in @database_schema.md, met @models.py als voorbeeld.
  • Prompt: "Based on the examples in @models.py, implement all other models as defined in @database_schema.md."

Prompt 2: CRUD-logica maken

  • Patroon: ons FastAPI-routerpatroon.
  • Context: alle overige modellen in @models.py, met @crud.py als voorbeeld.
  • Prompt: "Based on the examples in @crud.py, implement CRUD logic for all models defined in @models.py."

Prompt 3: tests maken

  • Patroon: onze property-based teststrategie.
  • Context: alle nieuwe endpoints in @crud.py, met @test_crud.py als voorbeeld.
  • Prompt: "Based on the examples in @test_crud.py, implement tests for all endpoints defined in @crud.py."

Een handvol goed opgestelde prompts genereert een groot deel van de boilerplate, wat meer tijd overlaat voor de logica waar je echt over moet nadenken.

Uitgangspunten voor co-creatie met een LLM

Een paar principes houden deze workflow bij elkaar.

Ontwerp code, schrijf hem niet

Je rol verschuift van code schrijven naar code ontwerpen. Door eerst een hoogwaardig voorbeeld van een patroon neer te zetten, en dat daarna te vermenigvuldigen, geef je de LLM een sjabloon om te volgen. Jouw werk wordt beoordelen, bijschaven en sturen in plaats van boilerplate typen, en je besteedt meer tijd aan architectuur.

Bewaak je context en gebruik bronnen

LLMs werken het best met kleine, gerichte context. Begin elke stap met een schoon contextvenster. Gebruik externe documenten, zoals onze database_schema.md, als blijvend geheugen dat je er steeds bij kunt geven. Werk je met een bibliotheek, voed de LLM dan met de officiële documentatie en tutorials: voor de SQLModel-classes en FastAPI-endpoints geven we de officiële tutorial over meerdere modellen mee. En begin gerust een nieuw gesprek als de LLM afdwaalt.

Laat de AI zijn eigen rommel opruimen

Zet statische analyse en geautomatiseerd testen in. Een LLM kan code opleveren die er goed uitziet en bij nader inzien faalt.

  • Laat de LLM zichzelf niet herhalen. Zie je repetitieve code, stop dan en maak er samen een generieke functie of een factory van.
  • Laat hem zijn eigen werk controleren. Een toevoeging aan je prompt als "Verify your work by running make ci" scheelt veel.

Dit is de Makefile die we gebruiken voor onze statische analyse:

.PHONY: static_analysis
static_analysis:
 @uvx ruff format .
 @uvx ruff check . --fix
 @uvx complexipy --details low src/app tests/
 @uv run --all-groups --with pip-audit pip-audit -l
 @uv run --all-groups --with pyright pyright src/app tests/

.PHONY: test
test:
 @uv run --group testing pytest -n auto -m "not slow" --cov=src/app --cov-report=xml
 @uvx diff-cover coverage.xml --fail-under=80 --compare-branch=main

ci: | static_analysis test

Hiermee draai je alles in een keer met make ci, en alleen de statische analyse met make static_analysis, wat handig is zolang de tests nog niet geschreven zijn. Wij gebruiken uv en zijn tool runner uvx, maar vergelijkbare tools werken net zo goed.

Conclusie

Deze workflow van vijf stappen verandert het ontwikkelproces. Door op te treden als architecten die de patronen vastleggen en de LLM sturen, automatiseren we het genereren van hoogwaardige boilerplate en houden we onze aandacht bij de complexe, unieke delen van een applicatie.

Een gestructureerde aanpak als deze maakt van de LLM geen code-aanvuller maar een echte ontwikkelpartner, en dicht een deel van het gat tussen wat deze modellen kunnen en wat echte software-engineering vraagt.

Colin Kerkhof

Auteur

Colin Kerkhof

Ik ben AI-engineer en bouw productieklare AI-systemen.

LinkedIn

Nieuwsbrief

Blijf op de hoogte

Meld je aan voor onze nieuwsbrief en ontvang onze kijk op alles wat speelt in data en AI.

Contact

Wat wil jij met AI bereiken?

Neem contact op met ons team via contact@mozaik.ai of gebruik het formulier hieronder.

Of kom langs op ons kantoor: Pakhuis De Hoop, Breestraat 59, Amersfoort.

Pakhuis De Hoop, Breestraat 59, Amersfoort

We gebruiken je gegevens alleen om op je bericht te reageren. Lees meer in ons privacybeleid.