references/data.md
Naming data
Database names are the most expensive names in the system. A function can be renamed in one commit; a column or field rename is a migration, coordinated across deploys, on live data. Get these right first.
Tables and collections
Plural, snake_case, the domain noun.
users bank_accounts proxy_bids order_batches
Plural because a table holds many rows. user reads as one, and then every query says FROM user which is grammatically wrong in a language built on SELECT ... FROM <things>.
snake_case because a collection or table is a namespace, like a file name, and because SQL is case-insensitive in ways that make camelCase actively dangerous (unquoted identifiers fold case, so bankAccounts becomes bankaccounts).
No prefixes. No tbl_, no app_, no mongo_. The database already knows what it contains.
Join tables name both sides, alphabetically: user_roles, auction_watchers. If the relationship has its own identity and fields, it is not a join table, it is an entity, and it deserves a domain name: bid, not user_auctions.
Columns and fields
SQL columns: snake_case. created_at, price_minor, auction_id.
MongoDB fields: camelCase. createdAt, priceMinor, auctionId.
These differ on purpose, and the reasoning is worth stating because otherwise it looks like drift:
- A SQL column is written in SQL, a case-insensitive language where camelCase
requires quoting to survive. snake_case is the only safe choice.
- A MongoDB field is serialized directly into the JSON your API returns and your
browser consumes. camelCase there means no mapping layer, no renaming on the way out, and one name for the field across the database, the API, and the frontend.
The mistake is not choosing one convention per engine. The mistake is mixing them within one engine, so a developer has to remember which collection got which. Pick per engine, apply everywhere.
The reasonable alternative, snake_case everywhere including Mongo, is also defensible if your clients are Python-first. Choose once, write it down, never mix.
Identity
Use the engine's own key. In MongoDB that is _id. Do not add a parallel id field mirroring it: it duplicates identity, needs its own unique index on every collection, and creates a permanent question about which one is authoritative.
An external, human-facing identifier is a different concept and gets its own name: orderNumber, publicSlug, reference. Those are for humans and URLs, _id is for the database, and conflating them is how you end up unable to change an order number.
Foreign keys: <entity>Id / <entity>_id. Singular entity, always the suffix.
auctionId userId bankAccountId parentOrderId
Never bare auction for an id field, since that name should mean the object. If a field holds a list of ids, plural the entity and keep the suffix: vehicleIds.
Timestamps
Always <verb>At, past tense, always an instant.
createdAt updatedAt settledAt cancelledAt lastSeenAt
This is the rule that fixes the lastSeen versus lastSeenAt split. There is no judgement call: if it holds a moment in time, it ends in At. A reader never has to guess whether a field is a timestamp, a boolean, or a count.
Two corollaries:
<noun>Dateonly for genuine date-only values, with no time component:
birthDate, invoiceDate. If it has a time, it is At.
- Store UTC, always, as a native date type, never a string. If a local time
matters to the domain, store the timezone as a separate field (scheduledAt plus scheduledTimezone) rather than a local timestamp.
createdAt and updatedAt belong on every collection. They cost nothing and you will want them during the first incident.
Money and units
Covered in SKILL.md as a principle; here as a rule.
Money is an integer in minor units, and the name says so.
priceMinor totalMinor feeMinor balanceMinor
Store the currency next to it whenever more than one is possible: priceMinor plus currency: "GHS". A money amount without a currency is not a money amount.
All other units are suffixed too:
timeoutMs ttlSeconds sizeBytes weightKg distanceKm
The reason this matters more than it looks: a units bug does not crash. It produces a plausible number that is wrong by a factor of 100 or 1000, and it is usually found by a customer.
Booleans and status
Booleans read as assertions, positively: isActive, hasVerifiedEmail, canBid.
Prefer a status enum to a pile of booleans. Three booleans encode eight states, of which perhaps three are legal, and nothing prevents the illegal ones:
// bad: isDraft, isPublished, isArchived -> what does all-true mean?
// good:
status: "draft" | "listed" | "sold" | "archived"
Status values are lowercase domain words. Name the state, not the transition: settled, not settle or settling, unless in-progress is genuinely a distinct state you query for.
When a status change matters, pair the enum with a timestamp rather than a boolean: status: "settled" plus settledAt. You get the state and the history in the same shape.
Nullability and absence
Prefer omitting a field to storing null, unless "explicitly cleared" is meaningfully different from "never set". Absence works with sparse and partial indexes; null is a value that has to be indexed and filtered.
Never use a sentinel: 0, "", 1970-01-01, -1. They look like data, aggregate like data, and eventually appear in a report.
Multi-tenancy
tenantId, on every document, in every collection. Same name everywhere, no exceptions, including audit logs and job records. See the mongodb-production skill for why the exceptions are the ones that hurt.
tenantId is also the first field of every index, which the ESR rule wants anyway.
Schema evolution
schemaVersion on every document, an integer, from day one. Adding it later means backfilling a collection you can no longer reason about.
When renaming a field on live data, the name change is a migration:
- Write both the old and new name
- Backfill the new one in batches
- Switch reads to the new name
- Stop writing the old one
- Drop the old field
Four deploys, not one. Which is exactly why the effort belongs at design time, and why lastSeen versus lastSeenAt is worth arguing about before the first insert.
Indexes
Let the engine generate index names in most cases; they encode the keys and are predictable. Name explicitly when an index has a purpose that its keys do not convey:
db.orders.createIndex(
{ tenantId: 1, status: 1, createdAt: -1 },
{ name: "orders_pending_queue" }
)
Every index declaration gets a comment naming the query it serves. Without it, nobody can safely delete any index later, and the set only ever grows.