Every AI tutorial I've seen opens the same way: client.chat.completions.create(...). Within ten lines you have a response. Within twenty you feel like you're building something.

What you're actually building is a demo.

The instinct to reach for the LLM first

It makes sense. The LLM is the interesting part. You want to see it work. So you call the API, print the result, and layer on functionality from there.

The problem surfaces when you try to do something real with that result, for example save it to a database, validate it against business rules, test it without paying per call. You have a string from an API and no structure underneath it.

In the first week of our Agentic AI cohort, participants don't touch the LLM at all. Instead, they build the data layer: models, enums, repositories, and 29 tests that all pass before a single API call is made. This article shows why that order matters.

StrEnum for currencies: compile-time safety over runtime surprises

The app tracks expenses. Expenses have currencies. The obvious approach is a string field. The problem with strings is that "USD", "usd", "Us Dollars", and a typo like "USd" are all valid Python; and all wrong in different ways.

StrEnum fixes this:

from enum import UNIQUE, StrEnum, verify

@verify(UNIQUE)
class Currency(StrEnum):
    USD = "USD"
    EUR = "EUR"
    GBP = "GBP"
    JPY = "JPY"
    # ... 6 more

The @verify(UNIQUE) decorator raises at class definition time if any two members share a value. For currency codes that's not a real risk, but it documents intent: these values should be distinct. The decorator turns a convention into a constraint.

Because StrEnum members are also strings, they work everywhere strings work (database columns, JSON serialization, comparisons) without any conversion step.

SQLModel: one class, two roles

SQLAlchemy handles persistence. Pydantic handles validation. Traditionally you write two separate classes and keep them in sync. SQLModel collapses them into one:

from decimal import Decimal
from datetime import datetime, timezone
from sqlmodel import Field, SQLModel

class Expense(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    amount: Decimal
    currency: Currency = Field(default=Currency.EUR)
    description: str | None = None
    date: datetime = Field(default=datetime.now(timezone.utc))

    category_id: int | None = Field(default=None, foreign_key="expensecategory.id")
    category: ExpenseCategory | None = Relationship(back_populates="expenses")

table=True tells SQLModel to also create a database table from this class. The type annotations become both Pydantic validators and SQLAlchemy column definitions. Add an expense with wrong types and Pydantic raises before the database ever sees it.

The Decimal type for amount matters too. Floating point arithmetic is not appropriate for money. Decimal("10.50") + Decimal("0.10") gives Decimal("10.60"). 10.50 + 0.10 gives 10.600000000000001.

The repository pattern: swap implementations without changing the app

The real payoff of a proper data layer is the repository pattern.

from abc import ABC, abstractmethod
from typing import Sequence

class ExpenseRepository(ABC):
    @abstractmethod
    def add(self, expense: Expense) -> None: ...

    @abstractmethod
    def get(self, expense_id: int) -> Expense | None: ...

    @abstractmethod
    def list(self) -> Sequence[Expense]: ...

    @abstractmethod
    def delete(self, expense_id: int) -> None: ...
    # ... more methods

The abstract base class defines what a repository can do. Concrete implementations decide how. The app gets two: InMemoryExpenseRepository (a dict) and DBExpenseRepo (SQLite via SQLModel).

Tests use the in-memory version - fast, no I/O, no cleanup. The production app uses the database version. The classification service, the CLI, the Telegram bot - they all accept an ExpenseRepository and don't care which one they get.

When you add a new interface (an API endpoint, a dashboard), you wire it to the same repository interface you already have. No duplication. No hidden coupling.

The abstract class also enforces completeness:

class IncompleteRepo(ExpenseRepository):
    def add(self, expense: Expense) -> None:
        pass
    # forgot the other methods

repo = IncompleteRepo()
# TypeError: Can't instantiate abstract class IncompleteRepo
# without an implementation for abstract methods 'delete', 'get', 'list', ...

You find out at instantiation, not at runtime when a user tries to list their expenses.

Aggregator patterns exist for combining multiple data sources behind a single repository interface. But rolling your own is the point here: once you understand the pattern, the library that wraps it stops being magic.

TDD: write the test first

The discipline that makes all of this work is test-driven development. Not "write tests after." Write the test, watch it fail, then write the code that makes it pass.

A simple model test:

def test_create_expense(db_session):
    category = ExpenseCategory(name="Transportation")
    expense = Expense(
        amount=Decimal(100),
        currency=Currency.USD,
        description="Train tickets",
        date=datetime(2025, 8, 31, 20, 0, 0),
        category=category,
    )

    db_session.add(category)
    db_session.add(expense)
    db_session.commit()

    assert expense.id is not None
    assert expense.category == category
    assert expense.currency == Currency.USD

The db_session fixture creates a fresh in-memory SQLite database for each test and tears it down after. Tests are isolated and fast; the full model + repository suite runs in under a second.

In practice, the first friction is tooling, not the pattern: one student ran into the src-layout gotcha directly: pythonpath = ["src"] in pyproject.toml before pytest can even find your code. Pre-commit hooks need wiring, and six dev tools have to coexist before you submit your first PR. Another common discovery is conftest.py: that fixtures can be centralized rather than redefined per test class. One student skipped it entirely (no shared fixtures, each test class self-contained) and still hit 97% coverage. The discipline of testing at every step, for teaching and code quality both, is non-negotiable.

What this foundation buys you

When the LLM returns a classification next week, there's a typed ExpenseCategory to validate it against, a Decimal field to store the amount, and a repository to persist the result. The LLM output is just one more input to a data layer that already knows how to handle it.

Without the foundation, you're forever parsing strings from the API and hoping they match whatever your database expects. With it, the LLM integration is a small piece of a well-typed pipeline.

That's the switch in mindset this series is built on: the LLM is one component, not the app. The app is the data model, the service layer, the interfaces. Build those first. See how the full agent is structured once the data layer is in place.