Step 7 — The Database Layer: TagRepository in Rust
Part 2 traces one real, already-shipped iKanban feature — Tags — through every layer of the stack: database → route → shared type contract → API client → React hook → component. We start at the bottom: crates/remote/src/db/tags.rs.
Why start at the database?
Frontend engineers usually meet a feature at the component. Working backend-to-frontend instead makes each layer's reason to exist obvious: the component only needs what the hook gives it, the hook only needs what the API client returns, and so on — each layer exists because the one below it isn't shaped right for the one above it. You don't need to read Rust fluently to follow this; the point is the shape of the data at each hop.
The Tag struct
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Tag {
pub id: Uuid,
pub tag_name: String,
pub content: String,
pub color: Option<String>,
pub team_id: Option<Uuid>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Serialize, Deserialize)] (from the serde crate) means this struct can be turned into JSON and back automatically. Every field name here — tag_name, content, color — will reappear verbatim as a JSON key by the time it reaches the browser. Keep this struct in mind; you'll see its exact shape mirrored in a hand-written TypeScript type in Step 9.
TagRepository — one method per operation
pub struct TagRepository;
impl TagRepository {
pub async fn find_by_team(pool: &PgPool, team_id: Uuid) -> Result<Vec<Tag>, TagError> {
let rows = sqlx::query!(
r#"
SELECT id AS "id!: Uuid", tag_name AS "tag_name!", content AS "content!",
color, team_id AS "team_id: Uuid",
created_at AS "created_at!: DateTime<Utc>", updated_at AS "updated_at!: DateTime<Utc>"
FROM tags
WHERE team_id = $1
ORDER BY tag_name ASC
"#,
team_id
)
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(|r| Tag { id: r.id, tag_name: r.tag_name, /* ... */ }).collect())
}
pub async fn create(pool: &PgPool, payload: &CreateTag) -> Result<Tag, TagError> { /* ... */ }
pub async fn update(pool: &PgPool, id: Uuid, payload: &UpdateTag) -> Result<Tag, TagError> { /* ... */ }
pub async fn delete(pool: &PgPool, id: Uuid) -> Result<bool, TagError> { /* ... */ }
}
sqlx::query! is a compile-time-checked macro — the SQL string is validated against the real database schema when the project builds (via the SQLx offline cache, crates/remote/.sqlx/). A typo'd column name is a build failure, not a runtime surprise — the same "compiler catches it first" guarantee the rust-as-backend tutorial covers in depth for the Rust language itself.
CreateTag and UpdateTag — narrower structs for writes
#[derive(Debug, Clone, Deserialize)]
pub struct CreateTag {
pub tag_name: String,
pub content: Option<String>,
pub color: Option<String>,
pub team_id: Option<Uuid>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct UpdateTag {
pub tag_name: Option<String>,
pub content: Option<String>,
pub color: Option<String>,
}
Notice these are separate structs from Tag, not Tag with some fields left blank. CreateTag has no id (the database assigns it) and no timestamps (the database sets those too). UpdateTag makes every field Option — None means "leave this field unchanged," handled explicitly in TagRepository::update:
let tag_name = payload.tag_name.as_ref().unwrap_or(&existing.tag_name);
This three-struct pattern (Tag / CreateTag / UpdateTag) is the standard shape for any resource in this codebase — you'll see it mirrored again in the frontend's type file in Step 9.
Checkpoint
Open vibe-backend/crates/remote/src/db/tags.rs and find sanitize_color — notice it falls back to a DEFAULT_TAG_COLOR constant rather than rejecting an invalid hex color outright. That's a deliberate "don't error the whole request over a cosmetic field" choice.
Continue to Step 8 — The Route Layer: Axum Handlers for /api/tags, where this repository gets exposed over HTTP.