Use when designing database schema, writing migrations, or making data storage decisions - enforces UUIDs, account_id multi-tenancy, state-as-records, no foreign keys, and proper index patterns
View on GitHubZempTime/zemptime-marketplace
vanilla-rails
January 20, 2026
Select agents to install to:
npx add-skill https://github.com/ZempTime/zemptime-marketplace/blob/main/vanilla-rails/skills/data-modeling/SKILL.md -a claude-code --skill vanilla-rails-data-modelingInstallation paths:
.claude/skills/vanilla-rails-data-modeling/# Vanilla Rails Data Modeling
Database schema conventions following production 37signals patterns. Design for multi-tenancy, auditability, and operational flexibility.
## UUID Primary Keys
**All tables use UUIDs** - no auto-incrementing integers.
```ruby
# ❌ BAD - default integer
create_table :cards do |t|
t.string :title
end
# ✅ GOOD - explicit UUID
create_table :cards, id: :uuid do |t|
t.string :title
end
```
**UUID format:** UUIDv7 (timestamp-ordered), base36 encoded as 25-character strings.
**Why UUIDs:**
- No ID enumeration attacks
- Merge-safe across environments
- Timestamp ordering preserved (UUIDv7)
- No sequence contention under load
**Fixture considerations:** Fixtures need deterministic UUIDs that sort "older" than runtime records. Use a custom generator based on fixture name hash.
## Multi-Tenancy via account_id
**Every tenant-scoped table has `account_id`** - no exceptions for tables containing user data.
```ruby
create_table :cards, id: :uuid do |t|
t.uuid :account_id, null: false # Always present
t.uuid :board_id, null: false
t.string :title
t.timestamps
end
```
**Tables WITHOUT account_id** (global/cross-tenant):
- `identities` - email addresses span accounts
- `sessions` - tied to identity, not account
- `magic_links` - authentication, not tenant data
**Automatic scoping:** Use `Current.account` and ApplicationRecord to scope queries:
```ruby
class ApplicationRecord < ActiveRecord::Base
def self.default_scope
if Current.account
where(account_id: Current.account.id)
else
all
end
end
end
```
**Common mistake:** Forgetting account_id on join tables:
```ruby
# ❌ BAD - missing account_id
create_table :taggings, id: :uuid do |t|
t.uuid :card_id, null: false
t.uuid :tag_id, null: false
end
# ✅ GOOD - includes account_id
create_table :taggings, id: :uuid do |t|
t.uuid :account_id, null: false
t.uuid :card_id, null: false
t.uuid :tag_id, null: false
end
```
## State as Records (NOT BooleanIssues Found: