Every week there's a new post about "adding AI to your API" — a chat endpoint, a summarization feature, an autocomplete widget. And every week, teams discover the same thing: the AI feature isn't the hard part. The hard part is that their API was never designed to answer real questions in the first place.
AI doesn't create bad architecture. It just puts a spotlight on it and asks it to perform live, in front of an audience.
🔥 The Pattern Nobody Wants to Admit
Here's the usual sequence:
- Team builds a CRUD API around whatever tables were easiest to model.
- Product asks for an AI feature — "summarize customer sentiment," "suggest a response," "cluster similar feedback."
- Engineering discovers the API can't answer "similar to what?" or "sentiment over what time window, grouped how?" without a pile of N+1 queries, ad hoc joins, or a background job nobody wants to own.
- Someone ships a
/ai/summarizeendpoint that quietly does three database round trips, a Python script, and a prayer.
The AI didn't break the system. The AI just needed the system to answer real, compositional questions — and it turns out the system was only ever designed to answer "give me row 42."
🧩 Case Study: minimalist-feedback-api
Let's make this concrete with a small, honest example — a feedback API that looks totally reasonable at first glance.
sql
CREATE TABLE feedback (
id SERIAL PRIMARY KEY,
message TEXT NOT NULL,
rating INTEGER,
submitted_at TIMESTAMP DEFAULT now(),
user_email TEXT
);
And the API surface:
http
GET /feedback
GET /feedback/:id
POST /feedback
DELETE /feedback/:id
This is fine for a v1. It's minimal, it's CRUD, it ships fast. The problem is what it's missing: there's no concept of a category, no tags, no source (web, mobile, support ticket), no status (new, triaged, resolved), and no relationship to a product area or feature. rating is a bare integer with no scale documented anywhere except a Slack message from eight months ago.
Nobody complained, because the only client was an admin dashboard doing SELECT * FROM feedback ORDER BY submitted_at DESC LIMIT 50.
🤖 Where the AI Feature Broke Everything
Then someone asks for: "Can we get an AI summary of feedback trends by feature area, this week vs. last week?"
Suddenly every missing modeling decision becomes a blocking issue:
- There's no
feature_area, so the LLM prompt starts doing keyword matching on free text ("if message contains 'checkout'...") — which is just a worse, slower, non-deterministic version of a foreign key. -
ratingisn't validated or scaled consistently, so "average sentiment" is comparing 1–5 stars against some rows where someone typed-1two years ago and it never got caught. - There's no
submitted_atindex strategy for range queries, so "this week vs last week" becomes two full table scans through a text-heavy table, on every request, because there's no caching layer and no aggregation endpoint either. - The endpoint that gets built to serve this,
/ai/summary, ends up doing the query, the grouping, the prompt construction, and the LLM call all inline, with no separation between "fetch relevant data" and "generate summary," which means you can't cache the first part or test it independently of the model.
http
GET /ai/summary?range=week
{
"summary": "Feedback improved slightly...",
"note": "best effort, based on keyword matching, may be wrong"
}
That note field is the tell. It's an apology baked into the response schema.
🛠 The Actual Fix: Model the Domain, Not the Table
The fix has almost nothing to do with AI. It's the modeling work that should have happened before anyone typed CREATE TABLE.
sql
CREATE TABLE feedback (
id SERIAL PRIMARY KEY,
message TEXT NOT NULL,
sentiment_score NUMERIC(3,2), -- normalized -1.0 to 1.0, computed once
source TEXT NOT NULL, -- 'web', 'mobile', 'support'
feature_area_id INTEGER REFERENCES feature_areas(id),
status TEXT NOT NULL DEFAULT 'new',
submitted_at TIMESTAMP NOT NULL DEFAULT now(),
user_id INTEGER REFERENCES users(id)
);
CREATE INDEX idx_feedback_submitted_at ON feedback (submitted_at);
CREATE INDEX idx_feedback_feature_area ON feedback (feature_area_id);
And the endpoint set stops being pure CRUD and starts modeling actual questions people ask:
http
GET /feedback?feature_area=checkout&since=2024-05-01&until=2024-05-08
GET /feedback/aggregate?group_by=feature_area&range=week
GET /feature-areas/:id/trend?window=30d
Notice what changed: the aggregation is a first-class resource (/feedback/aggregate), not something invented inline inside an AI endpoint. Now the AI feature is almost boring:
python
def generate_weekly_summary(feature_area_id: int) -> str:
trend = api.get(f"/feature-areas/{feature_area_id}/trend?window=7d")
prompt = build_summary_prompt(trend) # deterministic, testable
return llm.complete(prompt)
The LLM call is now the last step, operating on well-shaped, pre-aggregated, already-correct data. If the summary is wrong, you can tell immediately whether it's a data problem or a prompting problem — because they're separated.
📐 What Good Looks Like
A few concrete rules that fall out of this case study, not as abstract principles but as things you can check in a PR review:
- If an AI feature needs a join your API can't express, that join was always missing. The AI request just made it visible faster than a human analyst would have.
-
Aggregation endpoints are not optional sugar.
/resource/aggregateor/resource/:id/trendshould exist before anyone builds a summarization feature on top, not as a side effect of building one. -
Free text fields are where schema debt hides.
messagebeing a TEXT blob is fine; using string matching against it as a substitute for afeature_area_idis a design smell wearing an AI costume. -
Normalize before you summarize. If
ratingorsentiment_scoreisn't validated at write time, no amount of prompt engineering downstream will make the aggregate trustworthy. - Keep the retrieval and the generation separate, and testable separately. If your only way to verify the LLM's output is to eyeball it, you've merged two very different failure modes into one.
None of this is AI-specific advice. It's just API design discipline that AI features are unusually good at exposing, because they demand compositional answers instead of row lookups.
💬 Over to You
If you added an AI feature to an existing API recently — what actually broke first? Was it the schema, the missing aggregation layer, or something in how endpoints were shaped around CRUD instead of around the questions people actually ask?
The uncomfortable version of this post is: if the AI feature made your API look bad, the API was already bad. AI is just an unusually blunt code reviewer.
Top comments (0)