It happened on a Tuesday night, two weeks into building the AI service for Materia AI — a bilingual menu-description generator I was building off real problems I'd seen as a waiter. I was testing description generation for the tenth item in a row when the request just... died. Not a timeout. Not a 500. A very polite "you're out of credits" from the Anthropic API.
I didn't have a backup plan. I had a backend that called anthropic.messages.create() and, if I'm honest, I assumed I'd deal with "what if this provider goes away" later. Later showed up faster than I expected.
Here's the part I didn't expect: swapping to Google's Gemini API took about 20 minutes. Not because I'm fast — because of a boring decision I'd made weeks earlier that I almost didn't bother making.
The decision that saved me
When I started building the backend, I split it into three layers instead of writing AI calls directly inside my route handlers: routers (HTTP layer), services (business logic — including the actual AI call), and schemas (Pydantic models defining what goes in and out).
# schemas.py
class DescriptionRequest(BaseModel):
item_name: str
ingredients: list[str]
language: str = "en"
class DescriptionResponse(BaseModel):
description: str
language: str
# services/ai_service.py
async def generate_description(request: DescriptionRequest) -> DescriptionResponse:
prompt = build_prompt(request)
raw_text = await ai_client.complete(prompt) # <- the only line that touches the provider
return DescriptionResponse(description=raw_text, language=request.language)
Notice ai_client.complete(). My router never imports Anthropic or Gemini. It imports generate_description. The provider is an implementation detail hidden behind one function call.
What the swap actually looked like
# ai_client.py — before
from anthropic import Anthropic
client = Anthropic(api_key=settings.ANTHROPIC_KEY)
async def complete(prompt: str) -> str:
resp = client.messages.create(model="claude-...", messages=[{"role": "user", "content": prompt}], max_tokens=300)
return resp.content[0].text
# ai_client.py — after
import google.generativeai as genai
genai.configure(api_key=settings.GEMINI_KEY)
model = genai.GenerativeModel("gemini-1.5-flash")
async def complete(prompt: str) -> str:
resp = await model.generate_content_async(prompt)
return resp.text
One file changed. Zero routes touched. Zero schemas touched. My error-handling middleware, my validation, my endpoint tests — none of it cared which company wrote the model I was calling.
The lesson
I didn't build this separation because I was anticipating a credit shortage — I did it because "separate concerns" is what every FastAPI tutorial tells you to do, and I almost skipped it to move faster. Turns out the boring architectural decision is the one that saves you when the thing you didn't plan for happens: a price hike, a rate limit, a deprecated model, or — in my case — just running out of free credits at 11pm.
If your AI calls are sprinkled across your route handlers right now, this is your sign to pull them into one place before you need to.
This exact pattern — routers, services, and schemas kept separate, with the AI call isolated behind one function — is what I packaged into the Materia-AI Starter Kit, a FastAPI backend template you can grab if you want the fuller structure to start from instead of building it from scratch.
I'm currently open to freelance and full-time backend/AI-integration work — you can see more of what I've built at github.com/GerAle30.
Top comments (0)