Skip to content

Sketch your data model before Cursor invents one for you

Robert Boylan7 min read

You type "build me a team to-do app" into Cursor or Lovable and hit enter. Two minutes later you have a working app. Tables for users, todos, teams. It looks right. It probably even works.

Then, six weeks later, you need to add assignees. Or labels. Or per-user completion state separate from the shared task. The schema that felt fine on day one starts fighting you. The columns are named wrong. The relationships are backwards. The todos table is doing three jobs. You spend a weekend writing migrations and explaining to your AI tool why the existing structure doesn't work for the new thing.

This is not a prompting problem. It's a data model planning problem, and the only time it's easy to fix is before any code gets written.

Why "the AI will figure it out" produces a schema that fights you by month two

AI coding tools are excellent at generating schemas. They're fast, they follow conventions, and they've seen a lot of apps. But "a lot of apps" does not mean "your app." It means the average of every to-do app, every CRM, every project management clone in the training data.

When you say "users, teams, and tasks," the AI picks the most statistically likely structure for those words. Often that's a user_id foreign key on the tasks table, a teams table with a join table for members, and a status column on tasks with values like "pending" and "done." Probably fine for a while.

The problem is the shape was invented in response to a prompt, not in response to your actual requirements. The AI had no way of knowing that your tasks belong to teams, not to users directly. It didn't know each team member tracks their own progress on a shared task. It didn't know you'd eventually need an audit log of who changed what.

You inherit the schema it invented. Every feature built on top of it assumes the original shape. Changing the shape later means touching every table, every query, and every prompt that references those tables. Migrations compound.

Fifteen minutes of napkin thinking before your first prompt doesn't eliminate the problem entirely. But it catches the structural mistakes before they're baked into six weeks of features.

The five-table sketch framework

You don't need a full entity-relationship diagram. You need enough structure to avoid the obvious traps. For most indie apps, five table-types cover almost everything:

Entity: the main thing your app manages. For a to-do app, it's task. For a recipe app, it's recipe. One entity table. Give it a clear singular name.

Owner: who created or owns the entity. Almost always user. This table is usually simple: id, email, name, created_at. Don't overthink it; your auth provider handles most of it.

Parent: the grouping or container the entity lives inside. For a to-do app, tasks live inside projects or lists. For a recipe app, recipes might live inside cookbooks. The parent-to-entity relationship is almost always one-to-many (one project has many tasks).

Status: any lookup values that categorise or filter entities. Instead of a status column with five possible strings, a separate task_statuses table (or even a Postgres enum) makes filtering easier and protects against typos. Not every app needs this, but if you have more than two meaningful states, it's worth the extra table.

Audit: who changed what, and when. A task_history or activity_log table with entity_id, user_id, action, changed_at, and a JSON blob of the old values. You will want this eventually. It's ten times easier to add before you've built than after.

Walk through your app idea against these five types. You'll find the entities immediately. The parent structure usually takes a moment. The audit table is the one most people skip and then regret.

For the team to-do app: the entity is task, the owner is user, the parent is project (which itself belongs to team), the status table is probably an enum, and audit goes in task_history. That's the sketch. It fits on a napkin and it rules out the most common structural mistakes before the AI writes a single line.

Naming conventions to set before the AI starts

This sounds tedious. It is not tedious. It saves enormous amounts of confusion in week four when you can't remember whether you used userId or user_id or owner_id or created_by and your AI tool has helpfully used three of the four in three different tables.

Decide these three things before you open your tool of choice:

Singular vs plural table names. Pick one. task or tasks. Both are common, both are defensible, and mixing them is a disaster. For what it's worth, Supabase and most Postgres conventions lean singular; Rails leans plural. The convention matters less than the consistency.

Snake case vs camel case for columns. Postgres is case-insensitive by default and treats userId as userid unless you quote it. Use snake_case (user_id, created_at, task_status_id). The AI coding tools generally follow this when they're generating SQL or Supabase schemas, but they'll use camelCase on the JavaScript side. Know which layer you're naming.

The id column name. Either every table uses id as the primary key, or every table uses table_name_id. Not a mix. When you're writing queries and joins two months in, you'll be grateful you never had to think about which convention that table used.

Put these three decisions in plain text at the top of your spec. "Singular table names, snake_case columns, id as primary key on every table." One sentence each. When you paste the spec into Cursor or Lovable later, these constraints come along for the ride.

One-to-many vs many-to-many: deciding before the AI does

The single most common schema mistake in AI-generated code is picking the wrong relationship cardinality. Specifically: treating a many-to-many as a one-to-many and then adding the join table as an afterthought when things break.

A one-to-many relationship is when each row in Table A belongs to exactly one row in Table B. A task belongs to one project. A comment belongs to one post. You represent this with a foreign key column on the child table (project_id on the tasks table).

A many-to-many relationship is when rows in Table A can relate to multiple rows in Table B, and vice versa. Users can belong to multiple teams. Tasks can have multiple assignees. You represent this with a join table (team_members with team_id and user_id, task_assignees with task_id and user_id).

The AI will almost always default to one-to-many because it's simpler. "Tasks have one assignee" is a single foreign key. "Tasks have many assignees" requires an extra table and changes every query that reads assignees. The AI takes the simpler path unless you tell it otherwise.

So tell it otherwise, explicitly, before you start. Go through your entities and ask: can this have multiple? Can a task have multiple assignees? Can a user belong to multiple teams? Can a project have multiple owners?

If the answer is yes, say so in the spec. "A task can be assigned to multiple team members" is one sentence that forces the AI to generate a join table instead of a column. You can build on a join table. You cannot easily migrate a column to a join table six weeks after the feature shipped.

This is the kind of decision the tech stack planning post talks about in the context of picking a database. The structural choice comes first; the tooling follows.

Putting the sketch in your spec so the AI doesn't override it

Here's the worked example. You're building the team to-do app. You've done the five-table sketch, set your naming conventions, and identified your many-to-many relationships. Now you need to put all of this into a spec that Cursor, Lovable, or Bolt.new won't silently ignore.

The sketch for the team to-do app looks like this:

Tables:
- user (id, email, display_name, created_at)           # handled by auth
- team (id, name, created_by, created_at)
- team_member (id, team_id, user_id, role, joined_at)  # many-to-many join
- project (id, team_id, name, created_by, created_at)
- task (id, project_id, title, description, status, due_date, created_by, created_at)
- task_assignee (id, task_id, user_id)                 # many-to-many join
- task_history (id, task_id, user_id, action, old_values, changed_at)

Conventions: singular table names, snake_case columns, id as primary key
Status values: 'todo' | 'in_progress' | 'done' (Postgres enum)

That's it. Twelve lines. Put it in your spec under a "Data Model" section. When you're writing feature descriptions (there's a full post on writing feature descriptions that AI coding tools won't misinterpret), reference the table names from this sketch. "A task belongs to a project. A project belongs to a team. A task can have multiple assignees via task_assignee."

When you paste this into your first prompt, the AI reads the constraints before it generates anything. It doesn't have to invent the schema. It follows yours.

Without the sketch, you get whatever the AI's training data suggests. With the sketch, you get an implementation of your structure. If the AI generates something that contradicts the sketch, it's easy to spot and correct.

The sketch also survives session breaks. You come back two days later, paste the spec, and the AI's memory of "what structure are we using" doesn't matter. It's in the document.

The 15 minutes that save the migration weekend

Most people skip data model planning because it feels like extra work. You want to ship something, not draw diagrams.

The argument isn't that it's fun. It's that the alternative is worse. Every schema decision the AI makes on your behalf gets baked into the codebase. The table names appear in queries, in components, in API routes. The relationships appear in join logic and in the patterns your AI tool learns across the session. Changing them later means changing all of those things.

The sketch takes fifteen minutes. The migration weekend takes, well, a weekend.

That's the thinking Draftlytic was built to support. When you describe your idea and work through the questions, the data model section ends up in your spec automatically, in a format Cursor and Lovable can read from the first prompt. The sketch doesn't feel like a detour. It feels like part of planning the thing, because it is.