← Back to henry-molina.dev

CineAI Notes · ~11 min read

Three Decisions Behind CineAI's Backend

CineAI is a small system — three Lambda functions, one DynamoDB table, one outbound call to Claude. Small enough that most of the architectural choices behind it could have gone the obvious way without anyone noticing. Three of them didn't, and each one taught me something I didn't expect going in.

Skipping Express for Lambda Handlers

I've spent most of my career writing Express apps — app.use(), a router file, a global error handler at the bottom. When I sat down to build CineAI's backend, my hands wanted to reach for it out of habit. I didn't, and the reason isn't that I found something flashier. It's that Express solves a problem this system doesn't have.

Express exists to manage a long-running process: a router that stays resident in memory across thousands of requests, middleware that wires up once at boot and serves forever. Lambda's execution model doesn't have that continuity to assume — every invocation is its own context, cold or warm, but never a server that's "running" the way Express is built around. Bootstrapping an Express app inside a handler means paying the cost of constructing a router on every cold start, just to discard the one feature — persistence — that justifies the cost in the first place.

CineAI's entire backend is three routes behind API Gateway: GET /session for token validation, GET /movies for TMDB search, POST /chat for the conversation with Claude. API Gateway already does the job a router would — three routes don't need one.

What I actually missed from Express was the middleware ergonomics, and that's what Middy gives back without requiring a framework underneath it. validateToken looks up the magic-link token in DynamoDB, attaches the session to the event, and short-circuits with a typed error response before the handler body ever runs if the token is invalid or its budget is spent. errorHandler maps domain errors onto HTTP status codes. requestLogger wraps everything in structured logging via AWS Lambda Powertools. None of it needed an HTTP server to exist first — and none of it needed a test server either. Each middleware object exposes its before / onError function directly, so a test calls it with a minimal mock request and asserts on the result. No app to boot, no supertest instance, because there's no HTTP layer to fake.

Making Failure Part of the Type System

TypeScript has a blind spot it doesn't advertise: a function's signature never tells you it can throw. getSession(token): Promise<Session> reads like a promise that always resolves. It doesn't — DynamoDB calls fail, tokens expire, budgets run out — but the type system stays silent about all of it. The exception only becomes visible at runtime, usually in production, usually at the worst time.

CineAI talks to three external dependencies that all fail in different ways:

type DynamoError = 'TOKEN_NOT_FOUND' | 'TOKEN_EXPIRED' | 'BUDGET_EXCEEDED';
type TMDBError   = 'MOVIE_NOT_FOUND' | 'RATE_LIMITED'  | 'API_DOWN';
type ClaudeError = 'CONTEXT_TOO_LONG'| 'API_ERROR'     | 'TIMEOUT';

neverthrow's ResultAsync<T, E> turns each of those into part of the return type instead of an invisible throw. Every client method — DynamoDB, Claude, TMDB — is wrapped at the boundary so raw AWS or API errors get mapped to one of these typed unions before they ever reach a handler. The handler itself becomes a clean pipeline with exactly one branch: check result.isErr() once, at the edge, and let errorHandler middleware translate it to the right response. No nested try/catch, no error-shape guessing three calls deep.

The same pattern made the clients trivial to test, because they're built as factories that take their dependency as a constructor argument rather than importing a singleton:

// Production
const sessionClient = makeSessionClient(DynamoDBDocumentClient.from(new DynamoDBClient({})));

// Tests
const sessionClient = makeSessionClient({ send: vi.fn().mockResolvedValue(...) } as any);

One honest caveat: this guarantee lives at the backend boundary, and it ends the moment a value crosses into a context the type system can't see — which is exactly what bit me recently. The frontend's chat hook called the API client without a catch, so an occasional malformed response from Claude turned into a silent unhandled rejection: the loading state cleared, but nothing else happened, and the user just saw nothing. The fix was the same idea applied one layer further out — make the failure explicit instead of letting it vanish. The pattern only protects what it actually wraps.

Designing a Persona, Not a Chatbot

The system prompt is built in two layers. A static layer — persona, identity, boundaries, response format — is built once per session. A dynamic layer — mood, watchlist, conversation state — is prepended fresh on every request, always as a system update, never smuggled in as a fake user message. Splitting it this way means the parts of the prompt that define who Claude is being stay stable across a whole conversation, while the parts that define what it currently knows can shift without redefining the personality from scratch each time.

The persona itself is a short list of specific, almost petty rules: address the person by name naturally, not constantly. Never say "Great question!" Always explain the emotional reasoning behind a pick — never just list a title and a year. Cap recommendations at three, because a friend who hands you ten options isn't actually helping you decide. None of these rules are clever individually. What makes the persona feel like a person instead of a feature is that they're all enforced together, every time.

Claude's reply also has to commit to one of three exact shapes — a recommendation set, a plain conversational turn, or a surprise pick — so the frontend can render deterministically instead of guessing at free-form text:

type ClaudeResponse =
  | { type: 'recommendations'; movies: MovieRecommendation[]; message: string }
  | { type: 'conversation';    message: string }
  | { type: 'surprise';        movie: MovieRecommendation; reasoning: string }

Surprise Me runs through a separate prompt layer entirely — the static persona plus an additional instruction block that tells Claude to commit to one bold pick with no clarifying questions, framed as a friend pushing back on your instincts rather than asking what you're in the mood for. And the watchlist is folded into the dynamic layer as a supporting signal, not the main driver: mood answers "what do I want right now," the watchlist answers "what do I generally appreciate." If someone's mood points toward something light but their watchlist is full of Bergman, that's a cue to nudge toward intelligent comedy instead of pure popcorn — not to override the mood, just to calibrate around it.

The lesson that actually mattered, though, came from getting one of these rules wrong. The original instruction was "only discuss movies — redirect any off-topic questions gently." Reasonable on paper. Then someone typed, essentially, "it's my daughter's birthday party tonight, what should we watch" — and Claude refused, treating a kids'-movie request as off-topic because it didn't match some narrower, unstated idea of what "a movie question" was supposed to look like. The guardrail was doing its job too well, on the wrong target. The fix wasn't to loosen the boundary — it was to define it correctly: any movie-related question is in scope, including films for kids, families, groups, or specific occasions; the redirect only fires if the conversation leaves movies entirely. A persona rule that's too narrow doesn't fail loudly. It fails as a quiet, confident refusal of something completely reasonable — which is a more useful thing to have learned than almost anything else that came out of building this.


Three different decisions, one thread running under all of them: building something that has to behave like itself reliably, under a real budget, long after the demo is over — not just work the first time someone watches you click through it.