<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>Stored-Procedures on EXPLAIN ANALYZE</title>
        <link>https://explainanalyze.com/tags/stored-procedures/</link>
        <description>Recent content in Stored-Procedures on EXPLAIN ANALYZE</description>
        <generator>Hugo -- gohugo.io</generator>
        <language>en-us</language>
        <lastBuildDate>Mon, 20 Apr 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://explainanalyze.com/tags/stored-procedures/index.xml" rel="self" type="application/rss+xml" /><item>
            <title>Where Business Logic Lives — Database vs. Application</title>
            <link>https://explainanalyze.com/p/where-business-logic-lives-database-vs.-application/</link>
            <pubDate>Mon, 20 Apr 2026 00:00:00 +0000</pubDate>
            <guid>https://explainanalyze.com/p/where-business-logic-lives-database-vs.-application/</guid>
            <description>&lt;img src=&#34;https://explainanalyze.com/&#34; alt=&#34;Featured image of post Where Business Logic Lives — Database vs. Application&#34; /&gt;&lt;div class=&#34;tldr-box&#34;&gt;&#xA;    &lt;strong&gt;TL;DR&lt;/strong&gt;&#xA;    &lt;div&gt;Business rules live somewhere: in a &lt;code&gt;CHECK&lt;/code&gt; constraint, a &lt;code&gt;NOT NULL&lt;/code&gt;, a trigger, a stored procedure, a validation library, a service-layer check — or, most commonly, in more than one of those places quietly drifting apart. The useful axis isn&amp;rsquo;t &amp;ldquo;database vs application&amp;rdquo; as two equally valid options. It&amp;rsquo;s a set of constraints: does the rule cross service boundaries (if yes, the database can&amp;rsquo;t enforce it — modern cloud databases like &lt;a class=&#34;link&#34; href=&#34;https://cloud.google.com/spanner/docs/reference/standard-sql/stored-procedures&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &gt;Spanner&lt;/a&gt; and &lt;a class=&#34;link&#34; href=&#34;https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Constraints.html&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &gt;DynamoDB&lt;/a&gt; don&amp;rsquo;t even support the mechanisms)? How often does the rule change (app code deploys in minutes; schema migrations are a migration window)? How expensive is it to evaluate (app tier scales horizontally; the primary database doesn&amp;rsquo;t)? Is there more than one write path to this schema (if yes, the database is the only layer that catches them all)? The balance that holds in practice: keep the database narrow — &lt;code&gt;NOT NULL&lt;/code&gt;, &lt;code&gt;UNIQUE&lt;/code&gt;, &lt;code&gt;FK&lt;/code&gt; within a service, simple &lt;code&gt;CHECK&lt;/code&gt; for per-row invariants, generated columns for stable derived values. Everything else — orchestration, computation, rules that change weekly, anything crossing services — belongs in an application-layer library that every writer uses. &amp;ldquo;Dumb database&amp;rdquo; is half right: dumb across service boundaries, narrowly smart within one.&lt;/div&gt;&#xA;&lt;/div&gt;&#xA;&#xA;&lt;p&gt;Every rule about valid data lives somewhere. Required fields are a &lt;code&gt;NOT NULL&lt;/code&gt; in the DDL, or a validator in the model, or both. Amount-must-be-positive is a &lt;code&gt;CHECK&lt;/code&gt; constraint, an application-layer guard, a service-boundary check, or all three. Order totals are a generated column, a trigger, a callback in the application, a nightly reconciliation job — and if the team never picked, all four at once.&lt;/p&gt;&#xA;&lt;p&gt;The question of where each rule belongs is older than any of the frameworks currently hosting it. The modern answer, taken seriously, isn&amp;rsquo;t &amp;ldquo;both layers for safety&amp;rdquo; or &amp;ldquo;application-only because we have microservices&amp;rdquo;. It&amp;rsquo;s a balance question across four axes: scope, cadence, cost, and write-path count. None of these axes is &amp;ldquo;which layer feels cleaner&amp;rdquo; — they&amp;rsquo;re the properties of the system that decide what the database can actually do well and what it can&amp;rsquo;t.&lt;/p&gt;&#xA;&lt;h2 id=&#34;the-short-history-of-the-dumb-database-position&#34;&gt;The short history of the &amp;ldquo;dumb database&amp;rdquo; position&#xA;&lt;/h2&gt;&lt;p&gt;The microservices canon, and the cloud databases built to support it, have already answered one half of this question.&lt;/p&gt;&#xA;&lt;p&gt;Chris Richardson&amp;rsquo;s &lt;a class=&#34;link&#34; href=&#34;https://microservices.io/patterns/data/database-per-service.html&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &gt;database-per-service pattern&lt;/a&gt; rules out cross-service foreign keys as a design choice: each service owns its schema and no one else touches it. &lt;a class=&#34;link&#34; href=&#34;https://martinfowler.com/articles/microservices.html&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &gt;Fowler and Lewis&amp;rsquo;s &amp;ldquo;Microservices&amp;rdquo;&lt;/a&gt; article coined &amp;ldquo;smart endpoints and dumb pipes&amp;rdquo; and &amp;ldquo;decentralized data management&amp;rdquo; — neither the middleware nor a shared database holds cross-service logic. Fowler calls the alternative — integration through a shared database — &lt;a class=&#34;link&#34; href=&#34;https://martinfowler.com/bliki/IntegrationDatabase.html&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &gt;the canonical encapsulation breach&lt;/a&gt;. Vaughn Vernon&amp;rsquo;s DDD work puts the consistency boundary at the &lt;a class=&#34;link&#34; href=&#34;https://www.dddcommunity.org/wp-content/uploads/files/pdf_articles/Vernon_2011_1.pdf&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &gt;aggregate&lt;/a&gt;, enforced in process, not in the DBMS.&lt;/p&gt;&#xA;&lt;p&gt;The storage layer follows suit. Google Spanner &lt;a class=&#34;link&#34; href=&#34;https://cloud.google.com/spanner/docs/reference/standard-sql/stored-procedures&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &gt;does not support user-defined stored procedures or triggers&lt;/a&gt;; its docs explicitly say that on migration, &amp;ldquo;business logic implemented by database-level stored procedures and triggers must be moved into the application.&amp;rdquo; DynamoDB has no &lt;code&gt;CHECK&lt;/code&gt;, no foreign keys, no triggers — &lt;a class=&#34;link&#34; href=&#34;https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Constraints.html&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &gt;integrity is a per-item conditional write&lt;/a&gt;. Cassandra, Bigtable, and &lt;a class=&#34;link&#34; href=&#34;https://www.uber.com/us/en/blog/schemaless-part-one-mysql-datastore/&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &gt;Uber&amp;rsquo;s Schemaless&lt;/a&gt; are the same story. Facebook&amp;rsquo;s &lt;a class=&#34;link&#34; href=&#34;https://engineering.fb.com/2013/06/25/core-infra/tao-the-power-of-the-graph/&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &gt;TAO&lt;/a&gt; keeps the social graph&amp;rsquo;s integrity inside TAO itself — the underlying MySQL shards don&amp;rsquo;t enforce it. Shopify, even inside a Rails monolith, &lt;a class=&#34;link&#34; href=&#34;https://shopify.engineering/shopify-made-patterns-in-our-rails-apps&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &gt;doesn&amp;rsquo;t enforce relationships at the database layer&lt;/a&gt; — foreign keys are maintained only in the model code, a choice driven by their sharding and cell architecture.&lt;/p&gt;&#xA;&lt;p&gt;That&amp;rsquo;s the position the last fifteen years of large-scale engineering has converged on, and it&amp;rsquo;s right in the scope it applies to. Across service boundaries the database physically can&amp;rsquo;t enforce most cross-cutting rules, the dominant cloud storage engines won&amp;rsquo;t host procs or triggers, and the pattern literature has codified the split.&lt;/p&gt;&#xA;&lt;p&gt;The mistake is generalizing from this to &amp;ldquo;the database should be dumb, period.&amp;rdquo; That collapses two different debates into one slogan.&lt;/p&gt;&#xA;&lt;h2 id=&#34;where-the-position-is-strong-and-where-it-isnt&#34;&gt;Where the position is strong and where it isn&amp;rsquo;t&#xA;&lt;/h2&gt;&lt;p&gt;The near-unanimous consensus is about &lt;strong&gt;cross-service&lt;/strong&gt; integrity: FK between services, triggers as integration glue, stored procs as the coordination layer. There the answer is genuinely settled — it&amp;rsquo;s application-layer, usually in a shared library, sometimes in an orchestration service.&lt;/p&gt;&#xA;&lt;p&gt;The &lt;strong&gt;within-service&lt;/strong&gt; question is different. Inside a single service&amp;rsquo;s private schema, with one team owning the reads and writes, the database still sees every write path the service produces: the normal request path, backfill scripts, admin tools, the occasional DBA command at 2am, the new code path the team added last sprint. Richardson, Fowler, and Vernon don&amp;rsquo;t argue against &lt;code&gt;NOT NULL&lt;/code&gt;, &lt;code&gt;CHECK&lt;/code&gt;, or &lt;code&gt;UNIQUE&lt;/code&gt; inside that boundary. Shopify&amp;rsquo;s position is an outlier driven by sharding operations, not ideology. Yugabyte goes further and &lt;a class=&#34;link&#34; href=&#34;https://www.yugabyte.com/blog/are-stored-procedures-and-triggers-anti-patterns-in-the-cloud-native-world/&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &gt;defends stored procedures and triggers inside a service boundary&lt;/a&gt;.&lt;/p&gt;&#xA;&lt;p&gt;So the real framing: &lt;strong&gt;the &amp;ldquo;dumb database&amp;rdquo; position is unanimous across service boundaries and contested within them.&lt;/strong&gt; The rest of this post is about where the line actually sits within a service — and the honest answer is still &amp;ldquo;mostly keep the database lean, but not empty,&amp;rdquo; for reasons that have more to do with deployment cadence and scaling economics than with purity.&lt;/p&gt;&#xA;&lt;h2 id=&#34;the-four-axes-that-actually-decide-the-split&#34;&gt;The four axes that actually decide the split&#xA;&lt;/h2&gt;&lt;p&gt;The rule-by-rule question is a balance across four properties of the system, not a preference between layers.&lt;/p&gt;&#xA;&lt;h3 id=&#34;1-scope-does-this-rule-cross-service-boundaries&#34;&gt;1. Scope: does this rule cross service boundaries?&#xA;&lt;/h3&gt;&lt;p&gt;If the rule spans services, the database can&amp;rsquo;t enforce it. A foreign key into another service&amp;rsquo;s database doesn&amp;rsquo;t exist; a trigger that writes to tables owned by another team isn&amp;rsquo;t compatible with any sane microservices pattern. Cross-service correctness lives in application code, typically in a library that every writing service depends on, or in event-driven compensation (sagas, outbox patterns, eventual-consistency protocols).&lt;/p&gt;&#xA;&lt;p&gt;The only databases that let you enforce cross-service rules are ones the pattern literature treats as an anti-pattern on purpose — shared databases with multiple writers.&lt;/p&gt;&#xA;&lt;h3 id=&#34;2-cadence-how-often-does-this-rule-change&#34;&gt;2. Cadence: how often does this rule change?&#xA;&lt;/h3&gt;&lt;p&gt;Application code deploys in minutes. Schema migrations deploy on a migration window, with expand-and-contract dances, &lt;code&gt;NOT VALID&lt;/code&gt; + &lt;code&gt;VALIDATE&lt;/code&gt; phases, and careful ordering across rolling deploys. A rule that lives in the database inherits the database&amp;rsquo;s deployment cadence.&lt;/p&gt;&#xA;&lt;p&gt;That&amp;rsquo;s fine for rules that change annually or never: &amp;ldquo;email column is not null&amp;rdquo;, &amp;ldquo;amount is non-negative&amp;rdquo;, &amp;ldquo;status is one of four values for the life of the product&amp;rdquo;. It&amp;rsquo;s painful for rules that change with product experiments: pricing logic, promotion codes, fraud thresholds, discount stacking rules, feature gates. The friction of modifying a &lt;code&gt;CHECK&lt;/code&gt; constraint or a stored procedure for a rule that&amp;rsquo;s going to change again next quarter adds up to &amp;ldquo;this probably shouldn&amp;rsquo;t have been in the database in the first place.&amp;rdquo;&lt;/p&gt;&#xA;&lt;h3 id=&#34;3-cost-where-can-this-rule-run-cheapest&#34;&gt;3. Cost: where can this rule run cheapest?&#xA;&lt;/h3&gt;&lt;p&gt;The application tier scales horizontally. The primary database, for most OLTP workloads, scales vertically until sharding — and sharding is a project, not a tuning knob. Every CPU cycle spent inside the database is a cycle not spent on I/O, lock management, query planning, or serving other requests. A busy primary at 80% CPU doesn&amp;rsquo;t have slack for an additional stored procedure body to run on every write.&lt;/p&gt;&#xA;&lt;p&gt;For a simple &lt;code&gt;CHECK (amount &amp;gt;= 0)&lt;/code&gt;, the cost is measured in nanoseconds per write — irrelevant. For a trigger that recomputes an aggregate on every insert, the cost is a hot row plus whatever the aggregation costs, charged to the most scarce compute tier in the system. For a stored procedure that loops over rows, the cost is full procedure-body CPU on the primary for every call.&lt;/p&gt;&#xA;&lt;p&gt;Application code, by contrast, has near-free horizontal scale. Adding a pod is cheap. Adding database CPU is vertical-scaling dollars until you&amp;rsquo;ve run out of instance sizes, then it&amp;rsquo;s a sharding project.&lt;/p&gt;&#xA;&lt;div class=&#34;warning-box&#34;&gt;&#xA;    &lt;strong&gt;The database is a vertical-scaling tier&lt;/strong&gt;&#xA;    &lt;div&gt;Moving computation into the database moves it toward the scaling ceiling. Declarative constraints (&lt;code&gt;CHECK&lt;/code&gt;, &lt;code&gt;FK&lt;/code&gt;, &lt;code&gt;UNIQUE&lt;/code&gt;) are cheap enough to be irrelevant. Triggers that do nontrivial work, procedures that run loops, and anything that touches multiple rows per call eat CPU on the one tier that&amp;rsquo;s hardest to scale. The &amp;ldquo;app can do this magnitudes faster&amp;rdquo; intuition is right when &amp;ldquo;faster&amp;rdquo; is measured in throughput under load — not because a single call is faster, but because the application tier absorbs more of them without a scaling event.&lt;/div&gt;&#xA;&lt;/div&gt;&#xA;&#xA;&lt;h3 id=&#34;4-write-path-count-how-many-things-write-to-this-schema&#34;&gt;4. Write-path count: how many things write to this schema?&#xA;&lt;/h3&gt;&lt;p&gt;One service, one codebase, one team, one ORM writing to a schema the team fully owns: application-layer enforcement works. A shared library is the single choke point; every write goes through it.&lt;/p&gt;&#xA;&lt;p&gt;More than one writer — multiple services, admin tools in a different language, backfill scripts maintained by a different team, DBA incident-response SQL — and the library has gaps. Every writer that isn&amp;rsquo;t the library bypasses the validation. The database is the only layer that catches them all, and the cost of catching them is a small set of declarative constraints.&lt;/p&gt;&#xA;&lt;p&gt;Two writers isn&amp;rsquo;t a lot. Most systems that survive a few years accumulate more: data-migration jobs for a table split, an admin dashboard written in a different stack than the service, a reporting ETL that occasionally writes aggregates back, a partner integration that writes through a shared DB user.&lt;/p&gt;&#xA;&lt;h2 id=&#34;the-balance-that-holds-in-practice&#34;&gt;The balance that holds in practice&#xA;&lt;/h2&gt;&lt;p&gt;The four axes point at a consistent split. Keep the database narrow and declarative. Put everything else in application code — ideally in a library that every writer depends on.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Narrow database:&lt;/strong&gt;&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;code&gt;NOT NULL&lt;/code&gt;, &lt;code&gt;UNIQUE&lt;/code&gt;, &lt;a class=&#34;link&#34; href=&#34;https://explainanalyze.com/p/foreign-keys-are-not-optional/&#34; &gt;&lt;code&gt;FOREIGN KEY&lt;/code&gt;&lt;/a&gt; within a service&amp;rsquo;s private schema.&lt;/li&gt;&#xA;&lt;li&gt;Simple &lt;code&gt;CHECK&lt;/code&gt; constraints for per-row invariants: ranges, regex on identifiers, enum membership.&lt;/li&gt;&#xA;&lt;li&gt;Generated columns for derived values that are deterministic, stable, and cheap to compute.&lt;/li&gt;&#xA;&lt;li&gt;Indexes that the application needs for performance (not business logic, but a reminder they belong in the schema, not in code).&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;These are declarative, near-zero CPU cost per write, cover every write path, and change rarely enough that the schema&amp;rsquo;s deployment cadence isn&amp;rsquo;t a problem. Foreign keys in particular are the canonical within-service example — &lt;a class=&#34;link&#34; href=&#34;https://explainanalyze.com/p/foreign-keys-are-not-optional/&#34; &gt;a post on their own&lt;/a&gt; goes deeper on why application-layer referential integrity consistently loses to database-enforced FKs over time, and that argument is this whole post&amp;rsquo;s framework applied to one specific constraint.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Everything else in application code:&lt;/strong&gt;&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;Orchestration across multiple statements, services, or external calls.&lt;/li&gt;&#xA;&lt;li&gt;Rules that depend on request context, caller identity, time-of-day, or anything outside the row.&lt;/li&gt;&#xA;&lt;li&gt;Rules that change with product experiments.&lt;/li&gt;&#xA;&lt;li&gt;Rules that span services.&lt;/li&gt;&#xA;&lt;li&gt;Computation that would cost measurable database CPU per call.&lt;/li&gt;&#xA;&lt;li&gt;Derived values that involve complex business logic or are likely to change.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;If there&amp;rsquo;s one writer, a shared library is the single source of truth. If there are multiple writers — or there will be, which is most systems after a year — the library is still valuable but needs a narrow safety net in the database for the invariants that would corrupt data if they slipped.&lt;/p&gt;&#xA;&lt;div class=&#34;note-box&#34;&gt;&#xA;    &lt;strong&gt;The library as the primary, the schema as the safety net&lt;/strong&gt;&#xA;    &lt;div&gt;The pattern that works in practice: a validation library (or a rich domain model) owns the full rule set — validation messages, business logic, cross-field checks, everything the UI and API need. The schema carries only the declarative subset that the database can enforce cheaply: &lt;code&gt;NOT NULL&lt;/code&gt;, &lt;code&gt;CHECK&lt;/code&gt;, &lt;code&gt;UNIQUE&lt;/code&gt;, &lt;code&gt;FK&lt;/code&gt;. When the library&amp;rsquo;s rules diverge from the schema&amp;rsquo;s, the database rejects the write — the schema is the safety net, not the primary enforcement path. Violations surface as 500s that flag drift, not silent corruption.&lt;/div&gt;&#xA;&lt;/div&gt;&#xA;&#xA;&lt;h2 id=&#34;check-constraints--the-cheap-defensible-middle-ground&#34;&gt;CHECK constraints — the cheap, defensible middle ground&#xA;&lt;/h2&gt;&lt;p&gt;Declarative &lt;code&gt;CHECK&lt;/code&gt; constraints are the strongest example of database-side logic that justifies itself on every axis.&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;&#xA;&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt;1&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;2&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;3&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;4&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;5&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;6&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;7&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;8&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;9&#xA;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#xA;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-sql&#34; data-lang=&#34;sql&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;CREATE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;TABLE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;orders&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;id&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;BIGINT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;PRIMARY&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;KEY&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;user_id&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;BIGINT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NOT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NULL&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;REFERENCES&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;users&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;id&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;),&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;amount_cents&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;BIGINT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NOT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NULL&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;CHECK&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;amount_cents&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;&amp;gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;mi&#34;&gt;0&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;),&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;currency&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;CHAR&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;mi&#34;&gt;3&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NOT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NULL&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;CHECK&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;currency&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;~&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s1&#34;&gt;&amp;#39;^[A-Z]{3}$&amp;#39;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;),&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;status&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;TEXT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NOT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NULL&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;CHECK&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;status&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;IN&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;s1&#34;&gt;&amp;#39;pending&amp;#39;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s1&#34;&gt;&amp;#39;paid&amp;#39;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s1&#34;&gt;&amp;#39;shipped&amp;#39;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s1&#34;&gt;&amp;#39;refunded&amp;#39;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)),&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;placed_at&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;TIMESTAMPTZ&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NOT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NULL&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;shipped_at&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;TIMESTAMPTZ&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;CHECK&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;shipped_at&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;IS&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NULL&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;OR&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;shipped_at&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;&amp;gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;placed_at&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;p&#34;&gt;);&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#xA;&lt;/div&gt;&#xA;&lt;/div&gt;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;Scope&lt;/strong&gt;: within the service&amp;rsquo;s schema — applicable.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Cadence&lt;/strong&gt;: these rules change annually or never. Adding a new status value is a planned migration, not a product-experiment iteration.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Cost&lt;/strong&gt;: near zero. The planner evaluates the expression once per write; for the operators shown, it&amp;rsquo;s nanoseconds.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Write-path count&lt;/strong&gt;: covers every path — including the backfill job someone writes next year in a different language.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;The trade-off is real but small: error messages from a constraint violation are less friendly than a hand-crafted validation message, and adding a &lt;code&gt;CHECK&lt;/code&gt; to a large existing table is a migration project (MySQL rewrites the table; PostgreSQL needs &lt;code&gt;NOT VALID&lt;/code&gt; then &lt;code&gt;VALIDATE CONSTRAINT&lt;/code&gt; to avoid long locks). Both are known problems with known workarounds.&lt;/p&gt;&#xA;&lt;p&gt;The common pattern that holds up: application library owns the error message and UX, the database owns the enforcement. The library&amp;rsquo;s check is effectively a fast-path for better errors; the constraint is the gate.&lt;/p&gt;&#xA;&lt;h2 id=&#34;generated-columns--the-most-underused-declarative-tool&#34;&gt;Generated columns — the most underused declarative tool&#xA;&lt;/h2&gt;&lt;p&gt;Generated columns produce a derived value from other columns in the same row. MySQL since 5.7, PostgreSQL since 12. Indexable. Can&amp;rsquo;t be written to. Consistency guaranteed by the engine.&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;&#xA;&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt; 1&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 2&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 3&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 4&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 5&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 6&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 7&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 8&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 9&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;10&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;11&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;12&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;13&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;14&#xA;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#xA;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-sql&#34; data-lang=&#34;sql&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;CREATE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;TABLE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;line_items&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;id&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;BIGINT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;PRIMARY&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;KEY&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;order_id&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;BIGINT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NOT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NULL&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;REFERENCES&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;orders&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;id&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;),&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;unit_price_cents&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;BIGINT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NOT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NULL&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;CHECK&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;unit_price_cents&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;&amp;gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;mi&#34;&gt;0&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;),&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;quantity&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;INT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NOT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NULL&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;CHECK&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;quantity&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;&amp;gt;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;mi&#34;&gt;0&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;),&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;total_cents&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;BIGINT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;GENERATED&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;ALWAYS&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;AS&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;unit_price_cents&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;*&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;quantity&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;STORED&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;p&#34;&gt;);&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;CREATE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;TABLE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;users&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;id&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;BIGINT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;PRIMARY&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;KEY&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;email&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;TEXT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NOT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NULL&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;email_normalized&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;TEXT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;GENERATED&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;ALWAYS&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;AS&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;k&#34;&gt;LOWER&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;email&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;))&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;STORED&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;UNIQUE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;email_normalized&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;p&#34;&gt;);&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#xA;&lt;/div&gt;&#xA;&lt;/div&gt;&lt;p&gt;Evaluated on the four axes: scope (within-service), cadence (stable — the formula is an identity, not a business rule), cost (pure arithmetic or string operations, negligible), write-path count (every writer gets the same result automatically). The checks pass across the board — which is why generated columns are the cleanest way to handle derived values that would otherwise be maintained by discipline.&lt;/p&gt;&#xA;&lt;p&gt;The cost: the derivation has to be stable. Changing &lt;code&gt;email_normalized = LOWER(email)&lt;/code&gt; to add Unicode normalization is a migration. If the formula is an active business rule, it&amp;rsquo;s the wrong tool.&lt;/p&gt;&#xA;&lt;h2 id=&#34;triggers--for-schema-migrations-only&#34;&gt;Triggers — for schema migrations only&#xA;&lt;/h2&gt;&lt;p&gt;Triggers run procedural code on insert, update, or delete. That&amp;rsquo;s exactly what makes them wrong for implementation logic: a trigger mutates rows the caller didn&amp;rsquo;t ask to change, fires cascades the caller didn&amp;rsquo;t initiate, and makes &amp;ldquo;this update touches one column&amp;rdquo; a lie. The caller&amp;rsquo;s application logs say one thing; the database does something else. When a bug surfaces, the stack trace goes to application code that never ran the hidden logic.&lt;/p&gt;&#xA;&lt;p&gt;The usual defenses — &lt;code&gt;updated_at&lt;/code&gt; maintenance, audit logging, soft-delete cascades, counter caches — are all better handled in application code:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;&lt;code&gt;updated_at&lt;/code&gt;&lt;/strong&gt; belongs in the ORM&amp;rsquo;s model callback, the shared write library, or a middleware that sets it on every persist. Every writer already goes through that path; adding a timestamp is one line. If backfill scripts or admin tools bypass the library, the fix is to make them use the library, not to paper over the gap with a trigger.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Audit logs&lt;/strong&gt; need application context — the user ID, the request ID, the reason, the session, the tenant. A trigger can&amp;rsquo;t see any of that without awkward session-variable tricks that break across connection pools. Write the audit row in application code, next to the logic that knows &lt;em&gt;why&lt;/em&gt; the change is happening.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Soft-delete cascades&lt;/strong&gt; are business rules. Which child rows get deleted when a parent is soft-deleted, and in what order, and with what side effects, is a product decision — not a storage concern. Orchestrate it in the application.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Counter caches&lt;/strong&gt; via trigger create a hot row where every concurrent write serializes on the same parent lock. Application-side counters, background rollups, or a separate events-with-aggregation pipeline all scale better and leave the hot path free.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;The general principle: application logic should be visible in application code. A trigger that modifies data the application wrote is a hidden side effect, and hidden side effects are an anti-pattern for the same reason global variables are — they make the reachable state of the system larger than the code the reader is looking at.&lt;/p&gt;&#xA;&lt;div class=&#34;warning-box&#34;&gt;&#xA;    &lt;strong&gt;The debugging cost is the real cost&lt;/strong&gt;&#xA;    &lt;div&gt;When an on-call engineer is looking at a production incident, they read the application code that ran. A trigger that fired three levels down, in a language they may not read fluently, mutating rows nobody expected — is the single biggest source of &amp;ldquo;the code says X, the database did Y&amp;rdquo; incidents. That&amp;rsquo;s not a tooling problem. It&amp;rsquo;s a design choice that can be avoided by not writing triggers as implementation.&lt;/div&gt;&#xA;&lt;/div&gt;&#xA;&#xA;&lt;p&gt;This gap widens when an ORM sits between the application and the database. ORMs model what they created — columns and relations — and don&amp;rsquo;t reflect triggers, &lt;code&gt;CHECK&lt;/code&gt; constraints, or generated columns in the model class. A trigger that mutates a row after insert produces data the ORM didn&amp;rsquo;t know would be there, and the in-memory object diverges from the persisted row until someone thinks to reload. &lt;a class=&#34;link&#34; href=&#34;https://explainanalyze.com/p/orms-are-a-coupling-not-an-abstraction/#database-side-logic-doesnt-round-trip&#34; &gt;The ORM coupling post&lt;/a&gt; covers this failure mode in more depth — triggers are one of the specific shortfalls that show up as &amp;ldquo;the model says one thing, the database did another.&amp;rdquo;&lt;/p&gt;&#xA;&lt;h3 id=&#34;the-legitimate-case-schema-migrations&#34;&gt;The legitimate case: schema migrations&#xA;&lt;/h3&gt;&lt;p&gt;The one place triggers earn their keep is &lt;strong&gt;time-bounded, explicit migration work&lt;/strong&gt;. During an expand-and-contract schema change — renaming a column, splitting a table, changing a type — a trigger can dual-write between the old and new shape so that mixed old-application and new-application traffic both see consistent data. The trigger exists for the duration of the migration window and is dropped once the backfill is complete and all writers are on the new shape.&lt;/p&gt;&#xA;&lt;p&gt;This is trigger-as-scaffolding: a temporary mechanism that bridges a specific transition, with a clear removal criterion. It doesn&amp;rsquo;t hide business logic; it handles transitional compatibility between two versions of a schema while the application rolls forward.&lt;/p&gt;&#xA;&lt;p&gt;The most common real-world instance of this pattern in MySQL is &lt;a class=&#34;link&#34; href=&#34;https://docs.percona.com/percona-toolkit/pt-online-schema-change.html&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &gt;Percona&amp;rsquo;s &lt;code&gt;pt-online-schema-change&lt;/code&gt;&lt;/a&gt;: it creates a shadow table with the target schema, installs &lt;code&gt;INSERT&lt;/code&gt;/&lt;code&gt;UPDATE&lt;/code&gt;/&lt;code&gt;DELETE&lt;/code&gt; triggers on the original to replicate writes into the shadow while data is copied in chunks, then atomically renames and drops the triggers. The triggers exist for the migration&amp;rsquo;s duration and nothing longer. In PostgreSQL, &lt;a class=&#34;link&#34; href=&#34;https://github.com/xataio/pgroll&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &gt;&lt;code&gt;pgroll&lt;/code&gt;&lt;/a&gt; does the same kind of dual-write-via-trigger for zero-downtime schema changes. Both treat triggers exactly as this section argues they should be treated: time-bounded scaffolding with an explicit tear-down step.&lt;/p&gt;&#xA;&lt;p&gt;Worth noting the counter-example: &lt;a class=&#34;link&#34; href=&#34;https://github.com/github/gh-ost&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &gt;GitHub&amp;rsquo;s &lt;code&gt;gh-ost&lt;/code&gt;&lt;/a&gt; performs the same migrations without triggers, reading the binlog instead. Their stated reason is that triggers add synchronous load to the primary during the migration and share its locking fate. That argument is about migration tooling trade-offs, not a defense of triggers in application logic — the conclusion in both camps is the same: triggers outside of migration scaffolding don&amp;rsquo;t earn their keep.&lt;/p&gt;&#xA;&lt;p&gt;Everything outside that narrow case — cross-cutting concerns, derived values, audit logs, product rules — belongs in application code where it&amp;rsquo;s visible, testable, and traceable from the same stack trace as the logic that caused the write.&lt;/p&gt;&#xA;&lt;h3 id=&#34;how-companies-end-up-with-triggers-anyway&#34;&gt;How companies end up with triggers anyway&#xA;&lt;/h3&gt;&lt;p&gt;A large share of production databases carrying heavy trigger logic didn&amp;rsquo;t get there by choice — they got there by losing track of the write boundary. The pattern is predictable. A database starts as one service&amp;rsquo;s store. A second team needs the same data and connects directly because it&amp;rsquo;s easier than building an API. A data-warehouse ETL starts writing back aggregates. An analytics job needs a &amp;ldquo;last seen&amp;rdquo; column updated. A partner integration gets a read-write user &amp;ldquo;just for this quarter.&amp;rdquo; Five years later the database has a dozen clients, some inside the company, some not, some on systems nobody actively maintains, and nobody has a full list.&lt;/p&gt;&#xA;&lt;p&gt;At that point, asking every writer to go through a shared library stops being possible. The library is only the single source of truth if every writer imports it, and &amp;ldquo;every writer&amp;rdquo; now includes a Java batch job, a Go analytics worker, a legacy PHP admin tool, a vendor ETL, and a spreadsheet someone&amp;rsquo;s been running for years. The company doesn&amp;rsquo;t know where all the calls are coming from, so moving rules into an API layer isn&amp;rsquo;t an option — there&amp;rsquo;s no API layer that every caller can be forced through.&lt;/p&gt;&#xA;&lt;p&gt;The database, meanwhile, sees every writer. That&amp;rsquo;s how a team ends up with a trigger enforcing a rule that should have been in application code: it&amp;rsquo;s not the right place, it&amp;rsquo;s the only remaining place. The trigger is a symptom of losing the boundary, not a design choice made on its merits.&lt;/p&gt;&#xA;&lt;p&gt;The real lesson isn&amp;rsquo;t &amp;ldquo;triggers are acceptable once you&amp;rsquo;ve lost control of the boundary.&amp;rdquo; It&amp;rsquo;s that the boundary is the thing worth defending. Once multiple unknown clients are writing to a schema, every future rule either becomes a trigger by necessity or goes un-enforced. Greenfield systems should treat &amp;ldquo;who is allowed to write to this schema&amp;rdquo; as a first-class architectural decision, with one service in front of it and everyone else going through that service. Migrations out of the trap exist — service extraction, proxying direct-DB clients through a write API, introducing a write-time event bus — but they&amp;rsquo;re multi-quarter projects, and the trigger layer usually stays in place throughout because it&amp;rsquo;s doing the job nothing else is available to do.&lt;/p&gt;&#xA;&lt;h2 id=&#34;stored-procedures--the-vertical-scaling-trap&#34;&gt;Stored procedures — the vertical-scaling trap&#xA;&lt;/h2&gt;&lt;p&gt;Stored procedures move application logic into the database process. They&amp;rsquo;re the tool most directly opposed to the &amp;ldquo;database as storage&amp;rdquo; position, and the one with the clearest scaling argument against them. Evaluated on the four axes, stored procedures fail most of them for general business logic.&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;Scope&lt;/strong&gt;: within one database, fine. Across services, impossible — which is part of why Spanner and DynamoDB don&amp;rsquo;t support them.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Cadence&lt;/strong&gt;: schema-migration speed. A product rule that needs a hotfix takes a migration.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Cost&lt;/strong&gt;: the procedure body runs on the primary&amp;rsquo;s CPU. Every call is competing with every query for the same scarce resource. The application tier could run the same logic on a pod that scales horizontally.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Write-path count&lt;/strong&gt;: strongest here — if the procedure is the only way to perform the operation, every write path is covered.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;The narrow case for stored procedures is the intersection of those trade-offs: operations that must be atomic, must cover every write path, and would be prohibitively expensive to run row-by-row over the network. Bulk data operations that are genuinely row-by-row expensive, security boundaries where the application is explicitly not trusted with direct table access, and legacy systems where procedures are the system of record.&lt;/p&gt;&#xA;&lt;p&gt;Outside those cases, stored procedures trade a scaling-ceiling problem and a deployment-cadence problem for centralization that a shared application library provides at lower cost. The argument &amp;ldquo;a stored procedure prevents the application from drifting&amp;rdquo; is real — and the same argument applies to a validation library, without the scaling or deployment penalty.&lt;/p&gt;&#xA;&lt;h2 id=&#34;views--the-quietly-useful-option&#34;&gt;Views — the quietly useful option&#xA;&lt;/h2&gt;&lt;p&gt;Views don&amp;rsquo;t enforce writes but they do shape reads, and shaping reads affects correctness in practice: a view that filters soft-deleted rows means every consumer sees the same definition of &amp;ldquo;active&amp;rdquo;. Updatable views can also be a migration-compatibility tool.&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;&#xA;&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt;1&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;2&#xA;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#xA;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-sql&#34; data-lang=&#34;sql&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;CREATE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;VIEW&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;active_orders&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;AS&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;SELECT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;*&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;FROM&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;orders&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;WHERE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;deleted_at&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;IS&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NULL&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#xA;&lt;/div&gt;&#xA;&lt;/div&gt;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;Scope&lt;/strong&gt;: within-service.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Cadence&lt;/strong&gt;: view bodies change as often as the underlying queries; fine either way.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Cost&lt;/strong&gt;: the planner expands views at query time — complex views can hide expensive plans from the caller.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Write-path count&lt;/strong&gt;: read-time only. Doesn&amp;rsquo;t help with integrity.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;Views are underused for their cheap benefits (canonical join shapes, soft-delete filtering, migration shims) and overused when they become a layer of logic the calling code can&amp;rsquo;t see. Materialized views are a separate topic — they add refresh-cadence questions that the live-query tools don&amp;rsquo;t.&lt;/p&gt;&#xA;&lt;h2 id=&#34;derived-columns-and-counter-caches--implicit-logic&#34;&gt;Derived columns and counter caches — implicit logic&#xA;&lt;/h2&gt;&lt;p&gt;Comment counts, follower counts, status summaries, running totals. Every one of these encodes business logic; the question is which mechanism maintains it.&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;&#xA;&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt;1&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;2&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;3&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;4&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;5&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;6&#xA;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#xA;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-sql&#34; data-lang=&#34;sql&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;CREATE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;TABLE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;posts&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;id&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;BIGINT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;PRIMARY&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;KEY&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;author_id&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;BIGINT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NOT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NULL&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;REFERENCES&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;users&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;id&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;),&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;comment_count&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;INT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NOT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NULL&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;DEFAULT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;mi&#34;&gt;0&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;last_comment_at&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;TIMESTAMPTZ&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;p&#34;&gt;);&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#xA;&lt;/div&gt;&#xA;&lt;/div&gt;&lt;p&gt;Evaluated through the four-axis lens:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;Application code maintains it.&lt;/strong&gt; Cadence: fast (application code). Cost: zero on the DB, per-write work on the app tier. Write-path count: fails if any writer skips the library. Scope: fine within the service.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Materialized view or batch job.&lt;/strong&gt; Cadence: decoupled. Cost: the refresh window. Write-path count: covers everything, but the value is stale between refreshes. Scope: within-service.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Read-time aggregation.&lt;/strong&gt; Cadence: irrelevant. Cost: per-read, can be expensive on feed-style queries. Write-path count: always correct. Scope: within-service.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Separate counter service with async events.&lt;/strong&gt; Cadence: fast. Cost: extra infrastructure, delivery semantics to reason about. Write-path count: covers everything if every writer publishes the event. Scope: any.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;A trigger is conspicuously absent from that list on purpose. Counter-cache triggers are the canonical example of hidden logic causing a contention problem the application team can&amp;rsquo;t see: every concurrent comment insert serializes on the parent post&amp;rsquo;s row lock, and the debugging path goes straight through PL/pgSQL the service engineers didn&amp;rsquo;t write. The four-axis analysis points instead at the library-maintained counter when there&amp;rsquo;s one writer, the background rollup when reads are hot, and a separate counter service at scale or across boundaries.&lt;/p&gt;&#xA;&lt;h2 id=&#34;the-library-pattern-done-seriously&#34;&gt;The library pattern, done seriously&#xA;&lt;/h2&gt;&lt;p&gt;The natural consequence of &amp;ldquo;narrow database, logic in application&amp;rdquo; is that the application layer&amp;rsquo;s logic has to be &lt;em&gt;reusable&lt;/em&gt;. A validation that only lives in one service&amp;rsquo;s Rails app isn&amp;rsquo;t a library — it&amp;rsquo;s service code. A library that every writer imports is the actual mechanism.&lt;/p&gt;&#xA;&lt;p&gt;The shapes that show up in practice:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;Monolith, one language.&lt;/strong&gt; A package inside the codebase, imported by every write path. Works well. Admin tools and background jobs depend on the same package as the web request path. Backfill scripts should depend on it too — in practice this is where discipline breaks down.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Microservices, one language.&lt;/strong&gt; A shared library published as a package. Every service depends on the same version, or accepts that a rollout takes a deploy cycle across services. Version skew is the operational tax.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Polyglot services.&lt;/strong&gt; A shared library doesn&amp;rsquo;t exist — validation gets reimplemented per service, or pushed into a validation service that every caller hits over RPC. The RPC option is real and works; it turns &amp;ldquo;shared library&amp;rdquo; into &amp;ldquo;shared service&amp;rdquo; with the same logical role.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Schema-first code generation.&lt;/strong&gt; Tools like &lt;a class=&#34;link&#34; href=&#34;https://sqlc.dev/&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &gt;sqlc&lt;/a&gt; and &lt;a class=&#34;link&#34; href=&#34;https://www.jooq.org/&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &gt;jOOQ&lt;/a&gt; generate typed client code from the schema, which gives a narrow kind of library reuse — type safety and query shapes — without attempting to encode business logic. For logic itself, schemas aren&amp;rsquo;t enough; the library is separate.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;The discipline that makes this work: the library is the only write path, and if it isn&amp;rsquo;t, the database&amp;rsquo;s declarative constraints are the backup. The two pieces reinforce each other. The library holds the full rule set, fast and rich and horizontal-scale. The schema holds the small subset that the database can enforce cheaply and that every writer, library or not, has to pass through.&lt;/p&gt;&#xA;&lt;h2 id=&#34;the-duplication-trap&#34;&gt;The duplication trap&#xA;&lt;/h2&gt;&lt;p&gt;The most common failure mode isn&amp;rsquo;t picking the wrong layer. It&amp;rsquo;s picking both without deciding which is authoritative.&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;Application validator: &lt;code&gt;email&lt;/code&gt; must match regex A.&lt;/li&gt;&#xA;&lt;li&gt;Database &lt;code&gt;CHECK&lt;/code&gt;: &lt;code&gt;email&lt;/code&gt; must match regex B.&lt;/li&gt;&#xA;&lt;li&gt;Over the years, one gets updated (for GDPR, for internationalization); the other doesn&amp;rsquo;t.&lt;/li&gt;&#xA;&lt;li&gt;Legacy rows exist that pass the old version but not the new one.&lt;/li&gt;&#xA;&lt;li&gt;A migration that tries to tighten the &lt;code&gt;CHECK&lt;/code&gt; fails on legacy rows the application thought were fine.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;The pattern repeats with status enums, numeric ranges, referential rules, and soft-delete semantics. Two versions of the truth stay in sync as long as someone is actively keeping them in sync, and then they don&amp;rsquo;t.&lt;/p&gt;&#xA;&lt;p&gt;The useful framing: pick one layer as authoritative and name the other as a UX mirror or a safety net. The authoritative layer is the one that runs when the other doesn&amp;rsquo;t — which, for correctness invariants where write paths multiply, still points at the database for the narrow declarative subset.&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;&#xA;&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt;1&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;2&#xA;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#xA;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-sql&#34; data-lang=&#34;sql&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;c1&#34;&gt;-- authoritative: the declarative CHECK&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;CHECK&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;status&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;IN&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;s1&#34;&gt;&amp;#39;pending&amp;#39;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s1&#34;&gt;&amp;#39;active&amp;#39;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s1&#34;&gt;&amp;#39;closed&amp;#39;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;))&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#xA;&lt;/div&gt;&#xA;&lt;/div&gt;&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;&#xA;&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt;1&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;2&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;3&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;4&#xA;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#xA;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-python&#34; data-lang=&#34;python&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;c1&#34;&gt;# mirror in the library: better errors, fast-fail before the round trip&lt;/span&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;def&lt;/span&gt; &lt;span class=&#34;nf&#34;&gt;validate_status&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;value&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;):&lt;/span&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;    &lt;span class=&#34;k&#34;&gt;if&lt;/span&gt; &lt;span class=&#34;n&#34;&gt;value&lt;/span&gt; &lt;span class=&#34;ow&#34;&gt;not&lt;/span&gt; &lt;span class=&#34;ow&#34;&gt;in&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;s2&#34;&gt;&amp;#34;pending&amp;#34;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt; &lt;span class=&#34;s2&#34;&gt;&amp;#34;active&amp;#34;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt; &lt;span class=&#34;s2&#34;&gt;&amp;#34;closed&amp;#34;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;):&lt;/span&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;        &lt;span class=&#34;k&#34;&gt;raise&lt;/span&gt; &lt;span class=&#34;n&#34;&gt;ValidationError&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;s2&#34;&gt;&amp;#34;Status must be pending, active, or closed.&amp;#34;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#xA;&lt;/div&gt;&#xA;&lt;/div&gt;&lt;p&gt;If the library and the schema disagree, the schema wins and the write fails. The failure is loud, traceable, and tells you the drift exists — as opposed to silent corruption when neither layer enforces a rule.&lt;/p&gt;&#xA;&lt;h2 id=&#34;trade-offs&#34;&gt;Trade-offs&#xA;&lt;/h2&gt;&lt;p&gt;Every position in this post has counter-arguments, and they&amp;rsquo;re real.&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;Declarative database constraints lock you into SQL semantics.&lt;/strong&gt; A &lt;code&gt;CHECK&lt;/code&gt; constraint doesn&amp;rsquo;t survive a migration to DynamoDB or Spanner without rework. Teams building for a future migration accept less database-side logic in exchange for portability. The trade is real; the frequency of actual cross-engine migrations is lower than the frequency of discussions about them.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Schema changes are slow enough that even &amp;ldquo;simple&amp;rdquo; constraints are friction.&lt;/strong&gt; Adding a &lt;code&gt;CHECK&lt;/code&gt; to a 500M-row table is a migration project. For teams that ship schema changes weekly, every constraint is a cost, and sometimes the cheaper answer is to accept looser database-side invariants and stricter application-side ones.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Application-side validation is easier to test, version, and roll back.&lt;/strong&gt; A library&amp;rsquo;s tests run in milliseconds; a constraint&amp;rsquo;s tests need a real database. Teams with weak integration-testing infrastructure end up under-testing database-side rules.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Horizontal-scaling arithmetic isn&amp;rsquo;t universal.&lt;/strong&gt; For services that run on a single database at moderate load, the &amp;ldquo;vertical scaling ceiling&amp;rdquo; argument is an abstraction — the primary has plenty of headroom and the scaling argument is theoretical. The argument matters more as traffic grows.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Shopify&amp;rsquo;s position is internally consistent.&lt;/strong&gt; No database-level foreign keys, all integrity in models, sharded storage — it works because every write path goes through Rails and because the operational investment in model-layer integrity is serious. A smaller team without that investment can&amp;rsquo;t safely adopt the same pattern; the constraints in the database are what a smaller team can afford.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Stored procedures aren&amp;rsquo;t universally bad.&lt;/strong&gt; The Yugabyte post is right that in a single-service OLTP context, procedures can centralize logic effectively. The scaling argument is real but not always the binding constraint. Teams with deep SQL skills and disciplined version-control-for-procedures can extract more value than the &amp;ldquo;avoid them&amp;rdquo; position suggests.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;The honest framing: the balance described above is the one that holds across the most common cases. Specific cases have specific answers. The failure mode isn&amp;rsquo;t picking a different point on the axis — it&amp;rsquo;s not picking at all.&lt;/p&gt;&#xA;&lt;h2 id=&#34;a-rule-by-rule-framework&#34;&gt;A rule-by-rule framework&#xA;&lt;/h2&gt;&lt;p&gt;Instead of a blanket policy, a set of questions that point at the right layer per rule.&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;Does the rule cross service boundaries?&lt;/strong&gt; If yes, application library or orchestration service. The database can&amp;rsquo;t help.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Would violation corrupt data?&lt;/strong&gt; If yes, the database should enforce it as a declarative constraint, because every write path has to be covered.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Is the rule a derived value with a stable formula?&lt;/strong&gt; Generated column. Cheap, covers every writer, zero sync code.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Is the rule a derived value with a changing formula or external inputs?&lt;/strong&gt; Application library.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Does the rule depend on anything outside the row — request context, external services, feature flags?&lt;/strong&gt; Application library.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Does the rule change more often than quarterly?&lt;/strong&gt; Application library.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Is the rule a cross-cutting concern every write path needs (timestamps, audit logs)?&lt;/strong&gt; Application library that every writer imports — not a trigger. The trigger hides the logic; the library makes it visible to the reader of the code that caused the write.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Does the rule involve non-trivial computation or touch multiple rows per call?&lt;/strong&gt; Application library. Database CPU is the scarce tier.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Is there more than one write path?&lt;/strong&gt; The library alone isn&amp;rsquo;t enough; declarative constraints in the schema are the backup.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;The questions don&amp;rsquo;t eliminate judgment — several rules will land on edges — but they make the trade-offs visible and keep decisions from being driven by &amp;ldquo;the author was working in this layer when the rule came up.&amp;rdquo;&lt;/p&gt;&#xA;&lt;h2 id=&#34;the-bigger-picture&#34;&gt;The bigger picture&#xA;&lt;/h2&gt;&lt;p&gt;The microservices canon and modern cloud databases have already settled the cross-service question: the database is storage, logic lives in services and shared libraries. That&amp;rsquo;s not a slogan, it&amp;rsquo;s the direction Spanner, DynamoDB, Cassandra, and the pattern literature all point. Within a service, the question is softer — the database can enforce things the application can&amp;rsquo;t, and a narrow set of declarative constraints costs almost nothing and covers every writer — but the direction is the same. Keep the database lean. Put the full rule set in a library the application owns. Let the schema carry the small subset that catches the writes the library missed.&lt;/p&gt;&#xA;&lt;p&gt;The balance holds because the database is a specific kind of resource in a larger system. It deploys on schema cadence, scales vertically, and sits at the convergence point of every write path. Each of those properties points to a different use: schema cadence favors rules that don&amp;rsquo;t change; vertical scaling penalizes computation per write; convergence favors correctness checks that every writer has to pass. Declarative constraints for stable, per-row correctness invariants are the intersection of those — cheap, covers every path, doesn&amp;rsquo;t change. Everything outside that intersection belongs closer to the tier that can actually scale with the workload.&lt;/p&gt;&#xA;&lt;p&gt;&amp;ldquo;Dumb database&amp;rdquo; isn&amp;rsquo;t the right slogan — it&amp;rsquo;s too strong. &amp;ldquo;Narrow database, with declarative constraints where they pay for themselves&amp;rdquo; is closer to what works. Across services: unanimously narrow. Within a service: narrow on purpose, with a small set of database-side guards that catch what the library doesn&amp;rsquo;t — not because the library is untrusted, but because every system accumulates writers the library&amp;rsquo;s author didn&amp;rsquo;t plan for, and the schema is the only layer that sees them all.&lt;/p&gt;&#xA;</description>
        </item></channel>
</rss>
