The ORM decision record: why the data access layer you chose determines your query optimization ceiling and your schema migration surface

Published 2026-07-13 · WhyChose

ORM decisions are made in the database interaction design session when the team needs to talk to the database and does not want to write SQL by hand for every query. The AI session that produces the ORM decision is practical, grounded, and tightly scoped: it evaluates whether the team should use a full ORM (ActiveRecord, SQLAlchemy ORM, Hibernate, Prisma), a query builder (Knex, JOOQ, SQLAlchemy Core), or raw SQL with a thin driver. The session notes that a full ORM provides model-layer abstractions, automatic query generation for standard CRUD operations, schema migration tooling, and association handling that eliminates the join boilerplate that raw SQL requires for every endpoint that loads related objects. The session produces a clear decision — adopt the ORM, define the first model class, run the first migration, write the first query using the ORM's query interface. The decision accelerates the application's initial development, eliminates connection management boilerplate, and produces working, correct queries for the standard CRUD operations that constitute the majority of the application's early database interactions.

What the AI session does not produce is the ORM system's second half. The session answers "which data access layer?" and defines the first model. It does not ask: what is the policy for detecting N+1 query patterns before they reach production, where the ORM's lazy loading default produces hundreds of queries per request for a record set that the query's author tested with five records in development? At what complexity threshold does an ORM-generated query become the wrong tool and raw SQL the required escape hatch, and what is the review requirement for that escape hatch to ensure that the raw SQL is not worse than what the ORM would have generated? Who owns the schema migration files, what is the review requirement before a migration is merged, and what is the production migration protocol for table-locking ALTER TABLE operations on tables with millions of rows — operations that the ORM's migration generator produces identically for a ten-row development table and a forty-million-row production table? What is the query logging policy in production, and how does the team determine which ORM-generated queries are performing correctly and which are generating sequential table scans because the migration that added a new column did not add an index? Each of these questions has an answer that is not derivable from "we use ActiveRecord" or "we use Prisma." Each answer determines whether the ORM remains the correct tool as the application scales, or whether it becomes the ceiling on the query optimization work the team can do without rewriting the data access layer. The answers exist in the AI sessions. They are the operational commitments behind the framework choice. They are almost never written down.

Two ways ORM decisions produce the wrong outcome at scale

The N+1 query optimization ceiling

A B2B SaaS company adopts ActiveRecord for their Rails application in the first year. The decision is straightforward and well-founded: the team is moving fast, the schema is still evolving, and writing raw SQL for every endpoint would slow the iteration cycle. ActiveRecord's migration tooling means schema changes stay in version control alongside the application code. The association DSL — has_many, belongs_to, has_many :through — eliminates the join boilerplate for the relational data model the product requires. The AI session that produced this decision evaluated the alternatives (raw SQL, Sequel, Hanami::Model) and settled on ActiveRecord because the team is building on Rails and ActiveRecord is the conventional choice for good reasons: it is the most documented, the most supported, and the one the team's three engineers have all used before. The session is correct and the decision accelerates the first eighteen months of development substantially.

Three years later, the application has forty engineers, eighty thousand active records in the primary tables, and a performance audit triggered by customer complaints about slow dashboard load times. The performance audit begins with the team's observability stack: APM traces show the dashboard's primary API endpoint at 3.4 seconds median response time. The trace shows 0.3 seconds in the application server (template rendering, serialization, authorization checks) and 3.1 seconds in the database layer. A query count inspection reveals that the dashboard endpoint is issuing 1,218 database queries per request for a 100-record page. The N+1 chain runs three levels deep.

The endpoint loads a paginated list of projects. Each project has an owner (a User). Each project has a primary workspace (a Workspace). Each workspace has a subscription tier (a Subscription). The template renders, for each project, the project name, the owner's name, the workspace name, and the subscription tier label. The ActiveRecord query for the paginated project list returns 100 records in one query. The project.owner access in the template triggers a lazy-loaded query for each of the 100 owner records: 100 queries. The project.workspace access triggers 100 workspace queries. The workspace.subscription access triggers 100 subscription queries. The N+1 has three levels — projects → owners, projects → workspaces, workspaces → subscriptions — producing 1+100+100+100 = 301 queries per level combination, and the specific structure of the dashboard template requires all three associations for every project on the page, producing the 1,218 count that the trace reveals.

The fix — adding .includes(:owner, workspace: :subscription) to the project query — reduces the query count to 4 (one for projects, one for owners via IN, one for workspaces via IN, one for subscriptions via IN) and drops the endpoint's response time to 0.4 seconds. The fix is correct and takes ninety minutes to implement and deploy. What the audit also reveals is that the N+1 pattern exists in fourteen other endpoints that were not in scope for the immediate customer complaint, producing cumulative database load that explains the team's unexplained connection pool saturation during peak traffic hours over the past six months. The fourteen endpoints each require their own audit: identify which associations are accessed, determine which access patterns require eager loading, verify that adding eager loading does not load unnecessary data for the specific endpoint's use case (eager loading for a list endpoint that shows three fields does not require loading all associations that any endpoint that loads those records might ever need). The audit for the fourteen endpoints takes three weeks because there is no query count monitoring in the observability stack — the performance audit was triggered by customer complaints rather than by automated detection — and identifying N+1 patterns without query count metrics requires reading the application code for each endpoint to trace which associations are accessed and cross-referencing with the ORM's lazy loading defaults.

The founding session that selected ActiveRecord did not address: what is the N+1 detection policy? What tooling is required in development to make N+1 patterns visible before they reach production? (The bullet gem for Rails produces a warning in development logs every time an N+1 pattern is detected — a standard mitigation that requires a one-line configuration change and catches the class of problem before deployment, but is not installed by default.) What is the review requirement for endpoints that access associations inside loops? What is the eager loading policy for paginated list endpoints specifically, where the per-page query count is the metric that reveals N+1 patterns? Three years of feature development without these policies produced fourteen N+1 endpoints and six months of unexplained connection pool saturation, both of which were preventable if the founding session had treated N+1 detection as a policy question rather than a development practice that developers would learn individually through experience.

The second performance ceiling the audit surfaces is structural rather than fixable by adding eager loading: a reporting endpoint that generates a summary of project activity over a date range. The endpoint uses ActiveRecord's query interface — Project.where(created_at: range).group(:owner_id).count for one metric and a chain of scopes for filtering. The ORM generates a query that the database's query optimizer resolves with a sequential scan on the projects table, filtered post-scan, rather than using the composite index on (owner_id, created_at) that the team added six months ago. The explain plan shows the index exists, is not corrupt, and covers the query's columns — but the ORM generates the WHERE clause in an order that does not match the index's column order as the PostgreSQL planner resolves it for the specific data distribution in production. The fix requires dropping the ORM and writing raw SQL with explicit index hints or query structure that produces the correct plan. The team's founding decision session did not define the escape hatch policy: at what complexity threshold is raw SQL the correct answer, who reviews raw SQL before it merges, and does the explain plan output accompany the raw SQL in the PR? Without this policy, the team debates whether to rewrite the endpoint in raw SQL or to invest in a more complex ORM query expression, and the debate takes longer than either implementation path because there is no agreed criterion for which path applies to this specific case.

The schema migration table-lock incident

A developer tools startup uses Prisma for their Node.js application from the beginning. Prisma's migration tooling is one of the selection reasons: prisma migrate dev introspects the Prisma schema file, compares it to the current database schema, generates a migration SQL file with the diff, applies the migration in development, and records the migration in the _prisma_migrations table. The workflow is smooth for the first two years: the team adds features, models evolve, migrations are generated and merged with the code changes they accompany. The Prisma migration history is the audit trail of the schema's evolution. Each migration file is a SQL file in prisma/migrations/ alongside a migration.sql and a migration_lock.toml that records the migration's state. The AI session that selected Prisma cited the developer experience, the type-safe Prisma Client, and the migration tooling as three of the four deciding factors. The session was correct about all three at the time of the decision.

Two years later, the application has 47 migration files. A senior engineer joins and conducts an audit of the migration history as part of their onboarding. The audit reveals three problems. The first is a migration conflict in the history: two migrations have the same parent hash in their migration.toml records, meaning two branches both generated a migration against the same schema state without the conflict being detected before merge. The conflict was resolved by manually reordering the migrations when the second branch was merged, producing a migration sequence that is internally consistent but carries a comment in the migration file indicating manual intervention — without explaining what was manually adjusted or whether the adjustment was verified. The second is a migration that dropped a column that was in use: a migration named 20240118_remove_deprecated_user_fields drops three columns from the users table, including a column named legacy_auth_token. A git blame on the model file shows legacy_auth_token was removed from the Prisma schema on the same commit that generated the migration. A search of the codebase shows no remaining references to legacy_auth_token in the application code. What the search does not show is that a background job that runs weekly and processes an external data export still references legacy_auth_token in its database query — the job's codebase is in a separate repository that was not searched. The migration was deployed to production without incident only because the weekly job happened to run the day before the deployment. The third is a migration that adds an index on a column in the events table, which now has fourteen million rows. The migration SQL is CREATE INDEX ON events(user_id). The migration was applied in production as part of a standard deployment, locking the events table for nineteen minutes while the index was built. During those nineteen minutes, all writes to the events table — which includes every user action in the application — were blocked. The product experienced nineteen minutes of degraded behavior: user actions appeared to succeed in the UI (the application server responded successfully) but were not persisted to the database. The incoming technical leader who conducts this audit finds no migration review checklist, no protocol for identifying table-locking operations before a migration is deployed, no list of migrations that required manual intervention, and no record of the nineteen-minute events table lock incident in any postmortem or decision document. The Prisma migration history records what migrations were applied and when. It does not record what production impact they had, which required manual coordination, or which would have required CREATE INDEX CONCURRENTLY instead of CREATE INDEX to avoid a table lock.

The CREATE INDEX CONCURRENTLY case is the most consequential difference between Prisma's auto-generated migration SQL and the migration SQL that a migration review policy would require for a table above a specific row count threshold. CREATE INDEX acquires a ShareLock on the table, blocking all writes for the duration of the index build. CREATE INDEX CONCURRENTLY builds the index without holding a table lock, allowing writes to continue throughout the index build, but requires the index build to make two passes over the table and cannot run inside a transaction. Prisma's prisma migrate dev generates CREATE INDEX, not CREATE INDEX CONCURRENTLY, for every index addition regardless of table size, because the migration generator has no concept of table row count or lock risk. Converting the generated migration to use CREATE INDEX CONCURRENTLY requires editing the generated SQL file and marking the migration as a custom SQL migration that Prisma should not regenerate. This modification is straightforward to make, requires no application code change, and eliminates the table lock risk entirely for the fourteen-million-row events table — but requires knowing to make it. The founding session that selected Prisma documented the migration workflow. It did not document the migration review requirement: before any migration is merged, inspect the generated SQL for table-locking operations on tables above the team's row count threshold (commonly ten million rows for PostgreSQL), and replace any CREATE INDEX on a qualifying table with CREATE INDEX CONCURRENTLY, wrapping it in a migration file that is marked as run outside a transaction with the appropriate Prisma migration configuration. The nineteen-minute events table lock is a direct consequence of applying a generated migration to a fourteen-million-row table without this review step.

Three structural properties that ORM decisions determine

The N+1 problem and the query optimization ceiling

The N+1 query problem is not a bug in the ORM. It is the correct behavior of lazy loading applied in a context where eager loading was required, and it produces the correct query results at a cost that is linear in the record count rather than constant. The distinction matters because the fix for N+1 is not to avoid lazy loading — lazy loading is correct for single-record contexts — but to enforce eager loading for collection-loading contexts, and the policy that makes this enforcement systematic rather than ad hoc is the N+1 detection requirement.

The N+1 detection policy has three components. The first is development-time detection tooling: a library or framework feature that identifies N+1 patterns during development, before they reach production. In Rails, the bullet gem emits a warning to the development log when an N+1 pattern is detected during a request, identifying the association and the model. In Django, django-silk or nplusone provides similar detection. In SQLAlchemy, the lazy="raise" option on relationship definitions causes the ORM to raise an exception when lazy loading is attempted, which catches N+1 patterns as test failures in the test suite. In Prisma, the rejectOnNotFound option and custom middleware can detect unexpected query patterns, though Prisma's design pushes toward explicit eager loading via include and select rather than the automatic lazy loading model that makes N+1 less visible. The detection tooling is a configuration decision, not a coding task — it requires adding a library and a one-line configuration change — but it must be decided explicitly and applied in development, test, and CI environments, because N+1 patterns that do not manifest in development (five records in the test database, five queries per request) are not visible until the production dataset is large enough that the per-request query count becomes measurable as a latency contribution. The second component is the eager loading policy for collection-loading endpoints: a rule that any endpoint returning more than one record must explicitly specify which associations will be accessed in the template or serializer, using the ORM's eager loading API. This policy makes the intent of the data access layer explicit in the query code rather than implicit in the rendering code that accesses associations. The third component is the query count monitoring in production: a metric that records the number of database queries per request per endpoint, allowing automated alerting when a new deployment introduces a query count regression on a previously-characterized endpoint. Query count monitoring catches the case where a code change that adds a new association access to an existing template introduces an N+1 on an endpoint that previously had none.

The query optimization ceiling is the boundary at which the ORM's generated SQL is the limiting factor on performance, and raw SQL is the required solution. This ceiling is not fixed — it depends on the ORM's query generation quality, the database's query optimizer, and the query's structure — but it is a real boundary that every application with complex reporting, aggregation, or filter requirements will encounter. The policy for managing the ceiling has two parts: first, the escape hatch definition — the criteria under which raw SQL is required — and second, the raw SQL review requirement — what must accompany a raw SQL query in a PR. The escape hatch criteria should address at minimum: queries that require window functions with PARTITION BY that the ORM cannot express; queries that require recursive CTEs; queries where the ORM's generated SQL produces a sequential scan on a large table and index hinting or query restructuring in raw SQL would produce an index scan; and bulk operations where the ORM's row-by-row implementation is the performance bottleneck. The raw SQL review requirement should specify that the PR includes the EXPLAIN ANALYZE output from the production-scale database (or a production-scale staging environment) showing that the raw SQL produces the expected query plan, the query plan's estimated cost relative to the ORM-generated equivalent, and a comment in the code explaining why raw SQL is used rather than the ORM's query interface. Without the review requirement, raw SQL in the codebase represents inconsistently-reasoned escape hatches whose performance characteristics are unknown beyond the developer's local testing, and whose correctness is not verified against the production data distribution that the ORM's query optimizer would have been tested against implicitly through normal feature development.

The schema migration model and the migration surface

The schema migration model is the ORM decision's most consequential long-term operational surface, because schema migrations are the mechanism by which the application's code and the database's structure are kept in sync, and failures in that mechanism — a migration that locks a production table, a migration conflict in the history, a migration that destroys data because the column it drops was still in use — are production incidents rather than development problems. The migration model has three components: the generation strategy, the review requirement, and the production migration protocol.

The generation strategy determines whether migrations are auto-generated (from ORM schema introspection), hand-written (authored by the developer as raw SQL or ORM DSL), or a hybrid (auto-generated as a starting point, then reviewed and modified for production safety). Auto-generated migrations are correct for simple operations — adding a column with no default, adding a table, adding a foreign key between new tables — and carry risk for operations that the generator produces identically regardless of table size: CREATE INDEX versus CREATE INDEX CONCURRENTLY, adding a column with a default value (in PostgreSQL pre-11, this rewrites the entire table; in PostgreSQL 11+, columns with non-volatile defaults can be added without a table rewrite but the ORM generator does not always use the newer syntax), and removing a column that may still be referenced by code in a separate service or a background job that was not in scope for the search that verified the column was unused. The hybrid strategy is most appropriate for applications above a row count threshold: auto-generate the migration as a starting point, then apply the review requirement before merging. The review requirement for migrations in production should address: (1) for any index addition on a table above the team's row count threshold, verify that the generated SQL uses CREATE INDEX CONCURRENTLY (or the equivalent for the team's database vendor) rather than CREATE INDEX; (2) for any column drop or table drop, verify in the search scope that covers all repositories and background job code that no remaining code references the dropped object; (3) for any column addition with a default value, verify that the ORM-generated SQL will not produce a table rewrite in the team's database version; (4) for any migration that involves a constraint addition on a large table, verify that the constraint can be added without a full table scan under lock (PostgreSQL's ADD CONSTRAINT ... NOT VALID followed by VALIDATE CONSTRAINT allows the constraint to be added and then validated in a separate, non-locking pass). The production migration protocol documents the deployment-time procedure for migrations that cannot be executed inline with the deployment: migrations that run outside a transaction (CREATE INDEX CONCURRENTLY cannot run inside a PostgreSQL transaction), migrations that require a specific application version to be deployed before the migration (to avoid code that writes the new column format while old code that doesn't populate the column is still in rotation during a rolling deploy), and migrations that should run at low-traffic hours because even non-locking migrations produce database I/O that competes with production traffic.

The migration conflict model is a policy question that becomes important as soon as multiple feature branches generate migrations concurrently. Most ORM migration frameworks detect a migration conflict — two migrations with the same parent in the migration history — as an error and refuse to apply migrations until the conflict is resolved. The conflict resolution protocol must specify: how the conflict is identified (at merge time by the engineer merging the second branch, or by CI in the target branch), how the resolution is performed (squashing the conflicting migrations, reordering them with the conflict-detection tooling the ORM provides, or generating a new migration that supersedes both), and whether a resolved conflict requires a human review of the merged migration to verify that the resolution is correct. Without a conflict resolution protocol, resolutions are ad hoc, may produce migration histories with manual intervention comments that future engineers cannot interpret, and may produce a migration sequence that is internally consistent but that was never tested as a complete sequence in a development environment. The database migration strategy decision record is the companion document to the ORM decision: the migration strategy addresses the operational concerns — lock risk, conflict resolution, rollback protocol, environment parity — while the ORM decision addresses the query interface, the model abstraction, and the association model. The two documents are coupled because the ORM's migration generator is the source of the migration files that the migration strategy governs.

The ORM abstraction layer and the query visibility surface

The ORM abstraction layer produces SQL on behalf of the application code. The query visibility surface is the degree to which the generated SQL is visible to the team during development, during code review, and in production — and the degree to which the team can verify that the generated SQL is performing correctly rather than relying on the ORM's correctness guarantee for all cases. Low query visibility produces two failure modes: queries that are more expensive than the developer expected (because the ORM's query generation for a specific association access pattern produces a subquery rather than a JOIN, or produces a query that does not use an available index), and queries that change behavior when the ORM is upgraded (because the ORM's query generator is not part of the application's tested surface — the tests verify that the application returns the correct data, not that the SQL the ORM generates to retrieve it is the SQL the developer expected).

The query visibility policy has four components. The first is development-time query logging: the ORM should be configured to log all generated SQL to the development console, with the query parameters included (not parameterized placeholders), so that developers can see what SQL is being generated for each operation. Most ORMs provide this configuration: ActiveRecord's config.log_level = :debug, SQLAlchemy's echo=True on the engine, Prisma's log: [{ emit: 'stdout', level: 'query' }]. This configuration is often enabled in development environments and disabled in production for performance reasons; the policy should specify that query logging is enabled in development, test, and CI by default, and that any test for a new endpoint or model method includes a query count assertion that would catch query count regressions before deployment. The second is explain plan review for new endpoints: any PR that adds a new endpoint that queries a table above the team's row count threshold should include the output of EXPLAIN ANALYZE for the queries the endpoint generates, verified against a database that is populated with production-scale data (or a sample of it). This requirement catches the case where the ORM generates a query plan that the database's optimizer resolves correctly for the development database's small table but uses a sequential scan for the production table's row count. The third is production query monitoring: the ORM's query hook or event system configured to emit per-query duration metrics and per-request query count metrics to the team's observability stack, with alerting on query count regressions per endpoint. The fourth is ORM upgrade testing: a test suite component that verifies the SQL generated by the ORM for a set of representative queries does not change unexpectedly across ORM versions, catching cases where an ORM patch or major version change modifies the query generation for an association access pattern that the team relies on. The observability strategy decision record must address ORM-generated query monitoring explicitly: the standard observability stack for an HTTP application provides per-endpoint latency and error rate, but the breakdown between application-layer work and database-layer work — and within the database layer, the breakdown between N+1 queries, large-table sequential scans, and correctly-optimized queries — requires ORM-specific instrumentation that is not present in the standard application observability setup.

Three AI session types that embed ORM decisions without documenting them

The database interaction design session is where the ORM is chosen and the first model is defined. The session evaluates the team's options — full ORM, query builder, raw SQL with a driver — and produces a clear decision with practical reasoning: "we are using Rails so ActiveRecord is the conventional choice and the team has prior experience with it," or "we want type-safe database access and Prisma provides the best TypeScript integration for our stack." The session defines the first model class, runs the first migration, and writes the first ORM query. What the session does not produce is the operational policy: what is the N+1 detection requirement for development? What is the eager loading policy for collection-loading endpoints? At what query complexity does the team use raw SQL instead of the ORM? What is the migration review requirement? What is the production migration protocol for table-locking operations? These questions are not part of "choose a database interaction layer" as the team frames the task — they are the operational commitments that determine whether the ORM remains the correct tool as the application scales, or becomes the ceiling on query optimization and the source of table-lock incidents in production. The open-source extractor surfaces these founding database interaction sessions from AI chat history, recovering the ORM selection rationale, the first model design decisions, and the original query patterns that make visible which operational policies were implicit in the founding team's shared assumptions and which were never addressed.

The performance optimization session produces the most consequential ORM decisions for long-term query correctness. The session addresses a measured performance problem — a specific endpoint with high latency, a database identified as the bottleneck in the APM trace — and produces a technically correct solution for the specific problem: add eager loading to the endpoint, replace an ORM query with raw SQL for a specific reporting query, add an index for a specific filter pattern. The session is focused on the immediate outcome, which is verifiable by comparing the endpoint's response time before and after the fix. What the session does not produce is the systematic policy: if one endpoint had an N+1 pattern that was not detected before production, how many other endpoints have the same pattern? What change to the development workflow would catch this class of problem before it reaches production? If one raw SQL escape hatch was required for a specific reporting query, what is the policy for the next engineer who encounters a query that the ORM cannot express optimally? The performance optimization session creates a fix and a git commit. It does not create a policy document that would prevent the next occurrence. Three months after the session, a different endpoint with the same N+1 pattern produces the same latency spike, is discovered through the same customer complaint path, and requires the same three-week audit to characterize. The performance optimization decision record is the document that the optimization session should produce alongside the code fix: a policy document that converts the lesson learned from one N+1 incident into a systematic detection requirement that applies to all future development.

The schema migration session is where the ORM's migration tooling is first configured for a non-trivial operation: the first migration that drops a column the team believes is no longer used, the first migration on a table that has grown large enough to make lock risk a concern, or the first migration conflict that requires manual resolution. Each of these situations is an opportunity to define the migration review policy and the production migration protocol. Each is instead typically handled ad hoc: the column drop is reviewed by checking the application codebase for references, the large-table index is added during a low-traffic window, the migration conflict is resolved by the engineer who encounters it with the tools available. The ad hoc handling produces the correct outcome for the specific migration and leaves no policy document that would make the correct handling systematic for the next engineer who encounters the same situation. Two years of ad hoc migration handling produces the 47-migration history with three conflicting heads, the manually-intervened migration without documentation, and the production table lock incident — not because any individual migration decision was wrong, but because each decision was made independently without a shared policy that the team could consult. The database vendor decision record is the prerequisite document: the database vendor choice determines which migration operations require special handling (PostgreSQL's CREATE INDEX CONCURRENTLY, MySQL's ALTER TABLE ... ALGORITHM=INPLACE, SQLite's complete table rewrite for any ALTER TABLE), and the migration policy must be written specifically for the database vendor that the team is using, not for a generic ORM migration model.

The five sections of an ORM decision record

The first section documents the ORM selection and the data access layer abstraction boundary. The ORM selection rationale must be specific and evaluatable: "Prisma was selected because the team is building a TypeScript application and Prisma's generated client provides type-safe database access without a separate type generation step; the alternatives were Drizzle (lighter, but less migration tooling maturity at selection time) and Knex (query builder only, requiring hand-written type annotations for all result types)" is evaluatable against "has the type generation overhead or migration tooling of Drizzle matured to the point where it would be the better choice for a new service?" The abstraction boundary section must specify: which categories of database operation will use the ORM's query interface, which will use the ORM's raw SQL escape hatch, and which will use a separate query layer that does not use the ORM at all (a reporting service using a read-optimized query library, a background job using batch SQL that the ORM is not designed for). Without a documented abstraction boundary, the codebase accumulates a mix of ORM queries and raw SQL that reflects individual developer judgment rather than a consistent policy, and future developers cannot determine whether a specific piece of raw SQL is the escape hatch for a query that exceeds the ORM's capabilities or whether it should have been written using the ORM's query interface. The section must also document the association model: which ORM association types are used (eager-by-default versus lazy-by-default), and whether the default association loading strategy has been changed from the ORM's out-of-box default. An application that changes ActiveRecord's association loading from lazy (default) to strict loading (raises on unexpected lazy loads) has a fundamentally different N+1 detection posture than one that accepts the default, and this configuration decision must be documented to explain why the codebase's query patterns are structured the way they are. The database vendor decision record is the prerequisite document: the ORM must support the selected database vendor, and the migration strategy must account for the vendor's specific locking behavior for ALTER TABLE operations.

The second section documents the query optimization policy: the N+1 detection requirement, the eager loading policy for collection-loading endpoints, and the raw SQL escape hatch definition. The N+1 detection section must specify the tooling (which library or configuration is used to detect N+1 patterns in development and test), the coverage requirement (are N+1 detection tools run in CI, or only in development), and the policy for addressing a detected N+1 pattern (is an N+1 pattern in a PR blocked from merge, or is it a non-blocking finding). The eager loading policy section must specify the requirement for collection-loading endpoints: is eager loading explicit (the developer must specify which associations are loaded) or inferred (the ORM loads all associations that the template accesses, with N+1 detection as the backstop)? The explicit approach requires more developer discipline upfront and reduces the risk of inadvertent N+1; the inferred approach reduces the boilerplate in common cases and relies on N+1 detection to catch the cases where the inference is wrong. The escape hatch definition section must specify the criteria under which raw SQL is required rather than the ORM's query interface, the review requirement for raw SQL (explain plan included in PR, performance comparison with ORM equivalent, code comment with justification), and the ownership model for raw SQL in the codebase (is raw SQL audited periodically to verify it is still performing correctly as the table sizes grow and the database's statistics change?). The test strategy decision record is coupled to the N+1 detection policy: the test suite's query count assertions are the mechanism that catches N+1 regressions before deployment, and the decision to use query count assertions requires that the test environment uses the real database (not a mock or in-memory substitute) and that the test dataset is large enough to make query count differences measurable.

The third section documents the schema migration model: the generation strategy, the review requirement, and the production migration protocol. The generation strategy section must specify whether migrations are auto-generated, hand-written, or hybrid, and for hybrid migrations, which classes of migration are generated and which require hand-authoring. The review requirement section must specify the checklist applied before merging any migration: index creation on tables above the row count threshold (requiring CONCURRENTLY or equivalent), column drops (requiring codebase-wide search across all repositories and background job codebases), column additions with default values (requiring verification of the database version's table-rewrite behavior), constraint additions on large tables (requiring NOT VALID / VALIDATE CONSTRAINT protocol if applicable), and any migration that drops and recreates a table (requiring explicit sign-off because the operation is data-destroying if the table contains records not captured by the migration). The production migration protocol section must specify the deployment procedure for migrations that cannot run inline with the application deployment: which migration types are tagged as requiring a separate deployment step, who is responsible for identifying these migrations before a deployment, and what the rollback procedure is for a migration that fails midway through execution in production. The CI/CD pipeline decision record is coupled to the production migration protocol: the deployment pipeline must support the migration execution model — whether migrations run automatically on deployment or require a manual step — and the pipeline must have a mechanism for detecting migrations that are tagged as requiring special handling before the deployment proceeds.

The fourth section documents the query visibility and performance monitoring policy. The query logging section must specify the ORM's query logging configuration in each environment: development (all queries logged to console, with parameters), test (all queries logged, with query count assertions in the test framework), staging (all queries logged with duration, no parameters in logs if staging contains production-equivalent data), production (per-query duration and query count metrics emitted to the observability stack, not all queries logged to reduce I/O overhead). The explain plan requirement section must specify which endpoints require explain plan output in the PR before merge (all new endpoints that query tables above the row count threshold) and what the review criteria are (no sequential scans on tables above the threshold, index usage confirmed for all filter conditions, estimated cost within the team's acceptable range for the endpoint's SLO). The production query monitoring section must specify the metrics emitted by the ORM query hook: per-query duration percentiles per query template, per-request query count per endpoint, and the alert thresholds for query count regressions and duration regressions. The alert threshold for query count is particularly important: a new deployment that changes a collection-loading endpoint from 5 queries per request to 105 queries per request due to an introduced N+1 should produce an alert within minutes of the deployment, not be discovered through customer complaints three months later. The observability strategy decision record must explicitly include ORM-level instrumentation in its scope: the standard application observability stack measures request latency and error rate, but the database layer's contribution to request latency — and the breakdown within the database layer between query count overhead and individual query execution time — requires ORM-specific hooks that are not in the default application monitoring configuration.

The fifth section documents the ORM abstraction ceiling: the maximum acceptable query complexity before raw SQL is required, the boundary between ORM-managed schema and schema managed outside the ORM, and the policy for ORM version upgrades. The abstraction ceiling section must be specific enough to be applied by any engineer without asking a senior engineer to make a judgment call: "queries requiring window functions with PARTITION BY that the ORM cannot express in its current version use raw SQL with explain plan documentation" is applicable; "use raw SQL when the ORM query becomes too complex" is not. The schema ownership section must specify whether the ORM's migration tooling is the sole mechanism for schema changes (no manual schema changes outside of migrations, enforced by checking that the migration history is consistent with the schema state), or whether some schema objects are managed outside the ORM (database-level views, stored procedures, trigger functions, materialized views for reporting). Schema objects managed outside the ORM create a schema dependency that the ORM's migration tooling does not track, and changes to those objects must be tracked separately to avoid the case where an ORM migration produces a schema state that is inconsistent with the manually-managed objects. The version upgrade policy section must specify the process for upgrading the ORM library: whether upgrades are treated as routine dependency updates or as migrations (requiring the same review as a schema migration), what the test suite coverage requirement is before an ORM upgrade is deployed to production (particularly important for ORM versions that change query generation — a patch that changes how a specific association access generates SQL may change the query plan for an endpoint that was previously performing correctly), and what the rollback procedure is for an ORM upgrade that produces unexpected query behavior in production. The database sharding decision record is coupled to the ORM abstraction ceiling: most ORM frameworks have limited or no native support for sharded databases, and applications that require sharding must either implement sharding at the application layer above the ORM (adding routing logic that the ORM is not aware of) or replace the ORM with a sharding-aware data access layer, making the sharding decision the inflection point at which the ORM abstraction ceiling becomes a build-versus-extend decision.

The database interaction design session, the performance optimization session, and the schema migration session each produce ORM data access decisions whose long-term operational cost — a three-week performance audit to characterize fourteen N+1 endpoints discovered through customer complaints six months after their introduction, whose per-request query count would have been visible in development with one line of N+1 detection configuration; or a nineteen-minute events table write outage caused by a CREATE INDEX migration applied to a fourteen-million-row table that the migration generator produced identically to the one-row development table it was tested on — exceeds what an N+1 detection policy and a migration review checklist would have cost at the time the decision was made. The decisions are in the AI chat history: the ORM selection rationale, the association design for the first models, the first performance optimization that identified an N+1 pattern and fixed it for one endpoint without producing a systematic policy for all endpoints. WhyChose's open-source extractor surfaces these founding database interaction sessions as structured decision records before the next N+1 audit, the next production table lock incident, or the next migration conflict reveals the undocumented operational commitments as a multi-week performance investigation or a production write outage. The new CTO onboarding problem in the context of an ORM-backed application is a query visibility and migration audit problem: an incoming technical leader will find a collection of association definitions with inconsistent eager loading, a migration history with manual interventions and no review checklist, and no document explaining why some endpoints use raw SQL and others use the ORM's query interface, or what the policy should be for the next case that falls between the two categories.

Further reading