Skip to main content

SeaORM 2.0: Entity First Workflow

· 6 min read
SeaQL Team
Chris Tsang
SeaORM 2.0 Banner

In our last post, we introduced a new Entity format - designed to be more concise, more readable, and easy to write by hand.

With this format, you can embrace an Entity‑first workflow: stay focused on your domain model without getting bogged down in database tables or migration scripts.

And the best part? Pair it with Seaography and you'll have a working GraphQL API instantly - meaning you can skip writing most of the backend logic code until much later in your project's lifecycle.

This combination keeps you in the flow, and lets you focus on what really matters: building apps.

What's Entity first?

SeaORM used to adopt a schema‑first approach: meaning you design database tables and write migration scripts first, then generate entities from that schema.

Entity‑first flips the flow: you hand-write the entity files, and let SeaORM generates the tables and foreign keys for you.

All you have to do is to add the following to your main.rs right after creating the database connection:

let db = &Database::connect(db_url).await?;
// synchronizes database schema with entity definitions
db.get_schema_registry("my_crate::entity::*").sync(db).await?;

This requires two feature flags schema-sync and entity-registry, and we're going to explain what they do.

Unfolding

The above function get_schema_registry desugars into the following:

db.get_schema_builder()
.register(comment::Entity)
.register(post::Entity)
.register(profile::Entity)
.register(user::Entity)
.sync(db)
.await?;

You might be wondering: how can SeaORM recognize my entities when, at compile time, the SeaORM crate itself has no knowledge of them?

Rest assured, there's no source‑file scanning or other hacks involved - this is powered by the brilliant inventory crate. The inventory crate works by registering items (called plugins) into linker-collected sections.

At compile-time, each Entity module registers itself to the global inventory along with their module paths and some metadata. On runtime, SeaORM then filters the Entities you requested and construct a SchemaBuilder.

The EntityRegistry is completely optional and just adds extra convenience, it's perfectly fine for you to register Entities manually like above.

Resolving Entity Relations

If you remember from the previous post, you'll notice that comment has a foreign key referencing post. Since SQLite doesn't allow adding foreign keys after the fact, the post table must be created before the comment table.

This is where SeaORM shines: it automatically builds a dependency graph from your entities and determines the correct topological order to create the tables, so you don't have to keep track of them in your head.

Schema Sync in Action

The second feature, schema-sync, compares the in‑memory entity definitions with the live database schema, detects missing tables, columns, and keys, and creates them idempotently - no matter how many times you run sync, the schema converges to the same state.

Let's walk through the different scenarios:

Adding Table

Let's say you added a new Entity under mod.rs

//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0.0-rc.14

pub mod prelude;

pub mod post;
pub mod upvote; // ⬅ new entity module

The next time you cargo run, you'll see the following:

CREATE TABLE "upvote" ( "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, .. )

This will create the table along with any foreign keys.

Adding Columns

mod profile {
use sea_orm::entity::prelude::*;

#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "profile")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub picture: String,
pub date_of_birth: Option<DateTimeUtc>, // ⬅ new column
}

impl ActiveModelBehavior for ActiveModel {}
}

The next time you cargo run, you'll see the following:

ALTER TABLE "profile" ADD COLUMN "date_of_birth" timestamp with time zone

How about adding a non-nullable column? You can set a default_value or default_expr:

#[sea_orm(default_value = 0)]
pub post_count: i32,

// this doesn't work in SQLite
#[sea_orm(default_expr = "Expr::current_timestamp()")]
pub updated_at: DateTimeUtc,

Rename Column

If you only want to rename the field name in code, you can simply remap the column name:

pub struct Model {
..
#[sea_orm(column_name = "date_of_birth")]
pub dob: Option<DateTimeUtc>, // ⬅ renamed for brevity
}

This doesn't involve any schema change.

If you want to actually rename the column, then you have to add a special attribute. Note that you can't simply change the field name, as this will be recognized as adding a new column.

pub struct Model {
..
#[sea_orm(renamed_from = "date_of_birth")] // ⬅ special annotation
pub dob: Option<DateTimeUtc>,
}

The next time you cargo run, you'll see the following:

ALTER TABLE "profile" RENAME COLUMN "date_of_birth" TO "dob"

Nice, isn't it?

Add Foreign Key

Let's say we create a new table with a foreign key:

mod upvote {
use sea_orm::entity::prelude::*;

#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "upvote")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub post_id: i32,
#[sea_orm(belongs_to, from = "post_id", to = "id")]
pub post: HasOne<super::post::Entity>,
..
}

impl ActiveModelBehavior for ActiveModel {}
}
CREATE TABLE "upvote" (
"post_id" integer NOT NULL PRIMARY KEY,
..
FOREIGN KEY ("post_id") REFERENCES "post" ("id")
)

If however, the post relation is added after the table has been created, then the foreign key couldn't be created for SQLite. The relational query would still work, but functions completely client-side.

Add Unique Key

Now, let's say we've forgotten to add a unique constraint on user name:

mod user {
use sea_orm::entity::prelude::*;

#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "user")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(unique)] // ⬅ add unique key
pub name: String,
#[sea_orm(unique)]
pub email: String,
..
}
}

The next time you cargo run, you'll see the following:

CREATE UNIQUE INDEX "idx-user-name" ON "user" ("name")

As mentioned in the previous blog post, you'll also get a shorthand method generated on the Entity for free:

user::Entity::find_by_name("Bob")..

Remove Unique Key

Well, you've changed your mind and want to remove the unique constraint on user name:

pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
// no annotation
pub name: String,
#[sea_orm(unique)]
pub email: String,
..
}

The next time you cargo run, you'll see the following:

DROP INDEX "idx-user-name"

Footnotes

Note that in general schema sync would not attempt to do any destructive actions, so meaning no DROP on table, column and foreign keys. Dropping index is an exception here.

Every time when the application starts, a full schema discovery is done. It's not recommended to enable this in production, and so this is gated behind a feature flag schema-sync that can be turned off depending on build profile.

GraphQL API

With Seaography, the Entities you wrote can instantly be exposed as a GraphQL schema, with full CRUD, filtering and pagination. The GraphQL data loader is actually more powerful, as it allows nesting relations with arbitrary complexity.

With SeaORM and Seaography, you can prototype quickly and stay in the flow. And because Seaography is highly customizable, you can gradually shift resolver logic into your own implementation as the application evolves, and layer access control on top before the project goes to production.