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.
- Maak een blauwdruk: vertaal de projecteisen naar een formeel, machineleesbaar schema.
- Definieer het datamodel: pas een consistent architectuurpatroon toe om datamodellen uit dat schema te genereren.
- Bouw het endpoint: gebruik de datamodellen om API-endpoints te maken.
- Bouw de test: genereer property-based tests om te controleren of het endpoint correct en robuust is.
- 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.mdand 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 = NoneDit 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 = NoneStap 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 uitmodels.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
pytestenHypothesis. - Context: het endpoint dat een criterium aanmaakt, het bijbehorende
EligibilityCriterionCreate-schema, voorbeelden vanhttpx.AsyncClienten voorbeelden vanHypothesis. - 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.pyand use the asynctest_clientto 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.pyals 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.pyals 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.pyals 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 testHiermee 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.

