Featured image of post Where Business Logic Lives - Database vs. Application

Where Business Logic Lives - Database vs. Application

A counter-cache trigger fires on every comment insert and serializes every concurrent write on the parent post's row lock. A CHECK constraint and an application validator drift apart over five years until a migration tightening the constraint fails on 4,000 legacy rows. Where each rule belongs comes down to scope, cadence, cost, and how many things write to the schema.

TL;DR
Keep the database narrow: NOT NULL, UNIQUE, FK within a service, simple CHECK for per-row invariants, generated columns for stable derived values. Put everything else (orchestration, computation, rules that change weekly, anything crossing services) in an application-layer library every writer uses. “Dumb database” is half right: dumb across service boundaries, narrowly smart within one.

amount >= 0 lives in three places. A CHECK on the column, a Pydantic validator in the API model, a guard in the order-creation service. Added in different quarters by different teams. Out of sync since GDPR forced a change to the validator that nobody propagated to the constraint. The migration tightening the CHECK to match fails on 4,000 rows the application thought were fine.

This is the default state of any rule about valid data, eventually. It lives in more than one place. The places drift. The reflex answer, “both layers for safety,” is what produced the drift in the first place; “application-only because we have microservices” is the same answer applied to a different fashion cycle. Neither is a decision, both are defaults. The useful question is what each layer can enforce, what it costs, and how often the rule will change. Four axes do the work: scope, cadence, cost, and write-path count.

The short history of the “dumb database” position

The microservices canon and the cloud databases built to support it have already answered one half of this question.

Chris Richardson’s database-per-service pattern rules out cross-service foreign keys as a design choice: each service owns its schema and no one else touches it. Fowler and Lewis’s “Microservices” article coined “smart endpoints and dumb pipes” and “decentralized data management”. Neither the middleware nor a shared database holds cross-service logic. Fowler calls the alternative, integration through a shared database, the canonical encapsulation breach. Vaughn Vernon’s DDD work puts the consistency boundary at the aggregate, enforced in process, not in the DBMS.

The storage layer follows suit. Google Spanner does not support user-defined stored procedures or triggers; its docs explicitly say that on migration, “business logic implemented by database-level stored procedures and triggers must be moved into the application.” DynamoDB has no CHECK, no foreign keys, no triggers; integrity is a per-item conditional write. Cassandra, Bigtable, and Uber’s Schemaless are the same story. Facebook’s TAO keeps the social graph’s integrity inside TAO itself; the underlying MySQL shards don’t enforce it. Shopify, even inside a Rails monolith, doesn’t enforce relationships at the database layer; foreign keys are maintained only in the model code, a choice driven by their sharding and cell architecture.

That’s the position the last fifteen years of large-scale engineering has converged on, and it’s right in the scope it applies to. Across service boundaries, the database physically can’t enforce most cross-cutting rules, the dominant cloud storage engines won’t host procs or triggers, and the pattern literature has codified the split.

The mistake is generalizing from this to “the database should be dumb, period.” That collapses two different debates into one slogan.

Where the position is strong and where it isn’t

The near-unanimous consensus is about cross-service integrity: FK between services, triggers as integration glue, stored procs as the coordination layer. There the answer is genuinely settled. Application-layer, usually in a shared library, sometimes in an orchestration service.

The within-service question is different. Inside a single service’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’t argue against NOT NULL, CHECK, or UNIQUE inside that boundary. Shopify’s position is an outlier driven by sharding operations, not ideology. Yugabyte goes further and defends stored procedures and triggers inside a service boundary.

So the real framing: the “dumb database” position is unanimous across service boundaries and contested within them. The rest of this post is about where the line actually sits within a service. The honest answer is still “mostly keep the database lean, but not empty,” for reasons that have more to do with deployment cadence and scaling economics than with purity.

The four axes that actually decide the split

The rule-by-rule question is a balance across four properties of the system, not a preference between layers.

1. Scope: does this rule cross service boundaries?

If the rule spans services, the database can’t enforce it. A foreign key into another service’s database doesn’t exist. A trigger that writes to tables owned by another team isn’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).

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.

2. Cadence: how often does this rule change?

Application code deploys in minutes. Schema migrations deploy on a migration window, with expand-and-contract dances, NOT VALID + VALIDATE phases, and careful ordering across rolling deploys. A rule that lives in the database inherits the database’s deployment cadence.

That’s fine for rules that change annually or never: “email column is not null”, “amount is non-negative”, “status is one of four values for the life of the product”. It’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 CHECK constraint or a stored procedure for a rule that’s going to change again next quarter adds up to “this probably shouldn’t have been in the database in the first place.”

3. Cost: where can this rule run cheapest?

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’t have slack for an additional stored procedure body to run on every write.

For a simple CHECK (amount >= 0), 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.

Application code, by contrast, has near-free horizontal scale. Adding a pod is cheap. Adding database CPU is vertical-scaling dollars until you’ve run out of instance sizes, then it’s a sharding project.

The database is a vertical-scaling tier
Moving computation into the database moves it toward the scaling ceiling. Declarative constraints (CHECK, FK, UNIQUE) 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’s hardest to scale. The “app can do this magnitudes faster” intuition is right when “faster” 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.

4. Write-path count: how many things write to this schema?

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.

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’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.

Two writers isn’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.

The balance that holds in practice

The four axes point at a consistent split. Keep the database narrow and declarative. Put everything else in application code, ideally in a library every writer depends on.

The narrow set the database earns its keep on:

  • NOT NULL, UNIQUE, FOREIGN KEY within a service’s private schema.
  • Simple CHECK constraints for per-row invariants: ranges, regex on identifiers, enum membership.
  • Generated columns for derived values that are deterministic, stable, and cheap to compute.
  • Indexes the application needs for performance (not business logic, but a reminder they belong in the schema, not in code).

These are declarative, near-zero CPU cost per write, cover every write path, and change rarely enough that the schema’s deployment cadence isn’t a problem. Foreign keys in particular are the canonical within-service example. A post on their own goes deeper on why application-layer referential integrity consistently loses to database-enforced FKs over time, and that argument is this whole post’s framework applied to one specific constraint.

What stays in application code:

  • Orchestration across multiple statements, services, or external calls.
  • Rules that depend on request context, caller identity, time-of-day, or anything outside the row.
  • Rules that change with product experiments.
  • Rules that span services.
  • Computation that would cost measurable database CPU per call.
  • Derived values that involve complex business logic or are likely to change.

If there’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.

The library as the primary, the schema as the safety net
The pattern that works in practice: a validation library (or a rich domain model) owns the full rule set, including validation messages, business logic, cross-field checks, everything the UI and API need. The schema carries only the declarative subset the database can enforce cheaply: NOT NULL, CHECK, UNIQUE, FK. When the library’s rules diverge from the schema’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.

CHECK constraints, the cheap, defensible middle ground

Declarative CHECK constraints are the strongest example of database-side logic that justifies itself on every axis.

1
2
3
4
5
6
7
8
9
CREATE TABLE orders (
    id BIGINT PRIMARY KEY,
    user_id BIGINT NOT NULL REFERENCES users(id),
    amount_cents BIGINT NOT NULL CHECK (amount_cents >= 0),
    currency CHAR(3) NOT NULL CHECK (currency ~ '^[A-Z]{3}$'),
    status TEXT NOT NULL CHECK (status IN ('pending', 'paid', 'shipped', 'refunded')),
    placed_at TIMESTAMPTZ NOT NULL,
    shipped_at TIMESTAMPTZ CHECK (shipped_at IS NULL OR shipped_at >= placed_at)
);

Scope is within the service’s schema, applicable. Cadence is annual or never; adding a new status value is a planned migration, not a product-experiment iteration. Cost is near zero, since the planner evaluates the expression once per write and for the operators shown it’s nanoseconds. Write-path count covers every path, including the backfill job someone writes next year in a different language.

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 CHECK to a large existing table is a migration project (MySQL rewrites the table; PostgreSQL needs NOT VALID then VALIDATE CONSTRAINT to avoid long locks). Both are known problems with known workarounds.

The common pattern that holds up: application library owns the error message and UX, the database owns the enforcement. The library’s check is a fast-path for better errors; the constraint is the gate.

Generated columns, the most underused declarative tool

Generated columns produce a derived value from other columns in the same row. MySQL since 5.7, PostgreSQL since 12. Indexable. Can’t be written to. Consistency guaranteed by the engine.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
CREATE TABLE line_items (
    id BIGINT PRIMARY KEY,
    order_id BIGINT NOT NULL REFERENCES orders(id),
    unit_price_cents BIGINT NOT NULL CHECK (unit_price_cents >= 0),
    quantity INT NOT NULL CHECK (quantity > 0),
    total_cents BIGINT GENERATED ALWAYS AS (unit_price_cents * quantity) STORED
);

CREATE TABLE users (
    id BIGINT PRIMARY KEY,
    email TEXT NOT NULL,
    email_normalized TEXT GENERATED ALWAYS AS (LOWER(email)) STORED,
    UNIQUE (email_normalized)
);

On the four axes: scope is within-service, cadence is stable (the formula is an identity, not a business rule), cost is negligible (pure arithmetic or string operations), write-path count covers everything because every writer gets the same result automatically. Generated columns are the cleanest way to handle derived values that would otherwise be maintained by discipline.

The cost: the derivation has to be stable. Changing email_normalized = LOWER(email) to add Unicode normalization is a migration. If the formula is an active business rule, it’s the wrong tool.

Triggers, for schema migrations only

Triggers run procedural code on insert, update, or delete. That’s exactly what makes them wrong for implementation logic. A trigger mutates rows the caller didn’t ask to change, fires cascades the caller didn’t initiate, and makes “this update touches one column” a lie. The caller’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.

The usual defenses (updated_at maintenance, audit logging, soft-delete cascades, counter caches) are all better handled in application code.

updated_at belongs in the ORM’s model callback, the shared write library, or a middleware that sets it on every persist. Every writer already goes through that path, and 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.

Audit logs need application context: the user ID, the request ID, the reason, the session, the tenant. A trigger can’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 why the change is happening.

Soft-delete cascades are business rules. Which child rows get deleted when a parent is soft-deleted, in what order, with what side effects, is a product decision, not a storage concern. Orchestrate it in the application.

Counter caches 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.

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.

The debugging cost is the real cost
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 “the code says X, the database did Y” incidents. That’s not a tooling problem. It’s a design choice that can be avoided by not writing triggers as implementation.

This gap widens when an ORM sits between the application and the database. ORMs model what they created (columns and relations) and don’t reflect triggers, CHECK constraints, or generated columns in the model class. A trigger that mutates a row after insert produces data the ORM didn’t know would be there, and the in-memory object diverges from the persisted row until someone thinks to reload. The ORM coupling post covers this failure mode in more depth; triggers are one of the specific shortfalls that show up as “the model says one thing, the database did another.”

The legitimate case: schema migrations

The one place triggers earn their keep is time-bounded, explicit migration work. 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.

This is trigger-as-scaffolding. A temporary mechanism that bridges a specific transition, with a clear removal criterion. It doesn’t hide business logic; it handles transitional compatibility between two versions of a schema while the application rolls forward.

The most common real-world instance of this pattern in MySQL is Percona’s pt-online-schema-change: it creates a shadow table with the target schema, installs INSERT/UPDATE/DELETE 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’s duration and nothing longer. In PostgreSQL, pgroll 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.

Worth noting the counter-example. GitHub’s gh-ost 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’t earn their keep.

Everything outside that narrow case (cross-cutting concerns, derived values, audit logs, product rules) belongs in application code where it’s visible, testable, and traceable from the same stack trace as the logic that caused the write.

How companies end up with triggers anyway

A large share of production databases carrying heavy trigger logic didn’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’s store. A second team needs the same data and connects directly because it’s easier than building an API. A data-warehouse ETL starts writing back aggregates. An analytics job needs a “last seen” column updated. A partner integration gets a read-write user “just for this quarter.” 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.

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 “every writer” now includes a Java batch job, a Go analytics worker, a legacy PHP admin tool, a vendor ETL, and a spreadsheet someone’s been running for years. The company doesn’t know where all the calls are coming from, so moving rules into an API layer isn’t an option. There’s no API layer every caller can be forced through.

The database, meanwhile, sees every writer. That’s how a team ends up with a trigger enforcing a rule that should have been in application code. The trigger is the only remaining place. It’s a symptom of losing the boundary, not a design choice made on its merits.

The real lesson is 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 “who is allowed to write to this schema” 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’re multi-quarter projects, and the trigger layer usually stays in place throughout because it’s doing the job nothing else is available to do.

Stored procedures, the vertical-scaling trap

Stored procedures move application logic into the database process. They’re the tool most directly opposed to the “database as storage” position, and the one with the clearest scaling argument against them. On the four axes, stored procedures fail most of them for general business logic.

Scope is within one database. Across services, impossible (which is part of why Spanner and DynamoDB don’t support them). Cadence is schema-migration speed; a product rule that needs a hotfix takes a migration. Cost is the procedure body running on the primary’s CPU, competing with every query for the same scarce resource, when the application tier could run the same logic on a pod that scales horizontally. Write-path count is the one axis where procedures are strongest: if the procedure is the only way to perform the operation, every write path is covered.

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. Legacy systems where procedures are the system of record.

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 that “a stored procedure prevents the application from drifting” is real, and the same argument applies to a validation library without the scaling or deployment penalty.

Views, the quietly useful option

Views don’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 “active”. Updatable views can also be a migration-compatibility tool.

1
2
CREATE VIEW active_orders AS
SELECT * FROM orders WHERE deleted_at IS NULL;

Scope is within-service. Cadence is fine either way; view bodies change as often as the underlying queries. Cost is the planner expanding views at query time, and complex views can hide expensive plans from the caller. Write-path count is read-time only, so views don’t help with integrity.

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’t see. Materialized views are a separate topic; they add refresh-cadence questions the live-query tools don’t.

Derived columns and counter caches, implicit logic

Comment counts, follower counts, status summaries, running totals. Every one of these encodes business logic; the question is which mechanism maintains it.

1
2
3
4
5
6
CREATE TABLE posts (
    id BIGINT PRIMARY KEY,
    author_id BIGINT NOT NULL REFERENCES users(id),
    comment_count INT NOT NULL DEFAULT 0,
    last_comment_at TIMESTAMPTZ
);

Through the four-axis lens, four mechanisms:

  • Application code maintains it. Cadence is fast. Cost is zero on the DB, per-write work on the app tier. Write-path count fails if any writer skips the library. Scope is fine within the service.
  • Materialized view or batch job. Cadence is decoupled from the write. Cost is the refresh window. Write-path count covers everything, but the value is stale between refreshes. Scope is within-service.
  • Read-time aggregation. Cadence is irrelevant. Cost is per-read and can be expensive on feed-style queries. Write-path count is always correct. Scope is within-service.
  • Separate counter service with async events. Cadence is fast. Cost is extra infrastructure and delivery semantics to reason about. Write-path count covers everything if every writer publishes the event. Scope is any.

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’t see: every concurrent comment insert serializes on the parent post’s row lock, and the debugging path goes straight through PL/pgSQL the service engineers didn’t write. The four-axis analysis points instead at the library-maintained counter when there’s one writer, the background rollup when reads are hot, and a separate counter service at scale or across boundaries.

The library pattern, done seriously

The natural consequence of “narrow database, logic in application” is that the application layer’s logic has to be reusable. A validation that only lives in one service’s Rails app isn’t a library, it’s service code. A library every writer imports is the actual mechanism.

Four shapes show up in practice:

  • Monolith, one language. 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.
  • Microservices, one language. 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.
  • Polyglot services. A shared library doesn’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 “shared library” into “shared service” with the same logical role.
  • Schema-first code generation. Tools like sqlc and jOOQ 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’t enough; the library is separate.

The discipline that makes this work: the library is the only write path, and if it isn’t, the database’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 the database can enforce cheaply and that every writer, library or not, has to pass through.

The duplication trap

The most common failure mode isn’t picking the wrong layer. It’s picking both without deciding which is authoritative.

  • Application validator: email must match regex A.
  • Database CHECK: email must match regex B.
  • Over the years, one gets updated (for GDPR, for internationalization); the other doesn’t.
  • Legacy rows exist that pass the old version but not the new one.
  • A migration that tries to tighten the CHECK fails on legacy rows the application thought were fine.

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’t.

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’t, which, for correctness invariants where write paths multiply, still points at the database for the narrow declarative subset.

1
2
-- authoritative: the declarative CHECK
CHECK (status IN ('pending', 'active', 'closed'))
1
2
3
4
# mirror in the library: better errors, fast-fail before the round trip
def validate_status(value):
    if value not in ("pending", "active", "closed"):
        raise ValidationError("Status must be pending, active, or closed.")

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, instead of the silent corruption you get when neither layer enforces a rule.

Rules the assistant can see

The choice of where to put a rule is, among other things, a choice about which readers can see it. An AI assistant writing SQL or application code against the schema reads the catalog (column types, constraints, FKs, CHECK definitions) and whatever source files the prompt happens to include. Declarative rules show up in information_schema and pg_constraint. The assistant can reason about them without being pointed at additional files. A CHECK (status IN ('pending', 'active', 'closed')) is visible to any schema-reading tool on day one.

Rules living in triggers, stored procedures, ORM callbacks, or a shared Python validation library don’t surface when the same tool reads the catalog. The write path enforces them at runtime; the schema doesn’t describe them. A model generating an INSERT statement against a table whose uniqueness is enforced only by a before-insert trigger will produce a query that looks correct and violates an invariant the catalog never mentioned. This doesn’t change the conclusion that most logic belongs in the application, but it does tip the math, at the margin, toward the narrow set of correctness invariants where declarative constraints pay double: they enforce on every write path, and they’re the only form of the rule a schema-reading assistant sees for free.

Trade-offs

Every position in this post has counter-arguments, and they’re real.

  • Declarative database constraints lock you into SQL semantics. A CHECK constraint doesn’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.
  • Schema changes are slow enough that even “simple” constraints are friction. Adding a CHECK to a 500M-row table is a migration project. For teams shipping 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.
  • Application-side validation is easier to test, version, and roll back. A library’s tests run in milliseconds; a constraint’s tests need a real database. Teams with weak integration-testing infrastructure end up under-testing database-side rules.
  • Horizontal-scaling arithmetic isn’t universal. For services running on a single database at moderate load, the “vertical scaling ceiling” argument is an abstraction. The primary has plenty of headroom and the scaling argument is theoretical. The argument matters more as traffic grows.
  • Shopify’s position is internally consistent. 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’t safely adopt the same pattern; the constraints in the database are what a smaller team can afford.
  • Stored procedures aren’t universally bad. 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 “avoid them” position suggests.

The balance described above is what holds across the most common cases. Specific cases have specific answers. The failure mode is rarely picking the wrong point on the axis. It’s not picking at all.

A rule-by-rule framework

Instead of a blanket policy, a set of questions that point at the right layer per rule.

  • Does the rule cross service boundaries? If yes, application library or orchestration service. The database can’t help.
  • Would violation corrupt data? If yes, the database should enforce it as a declarative constraint, because every write path has to be covered.
  • Is the rule a derived value with a stable formula? Generated column. Cheap, covers every writer, zero sync code.
  • Is the rule a derived value with a changing formula or external inputs? Application library.
  • Does the rule depend on anything outside the row (request context, external services, feature flags)? Application library.
  • Does the rule change more often than quarterly? Application library.
  • Is the rule a cross-cutting concern every write path needs (timestamps, audit logs)? 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.
  • Does the rule involve non-trivial computation or touch multiple rows per call? Application library. Database CPU is the scarce tier.
  • Is there more than one write path? The library alone isn’t enough; declarative constraints in the schema are the backup.

The questions don’t eliminate judgment (several rules will land on edges) but they make the trade-offs visible and keep decisions from being driven by which layer the author was working in when the rule came up.

The bigger picture

Across services, the database is storage and logic lives in services and shared libraries. That’s the direction Spanner, DynamoDB, Cassandra, and the pattern literature all point, and the cross-service question is genuinely settled. Within a service it’s softer. The database can enforce things the application can’t, a narrow set of declarative constraints costs almost nothing, and the schema is the only layer that sees every writer the library’s author didn’t plan for. 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 (which is more writes than anyone planning the system thought there would be).

SELECT insights FROM experience WHERE downtime = 0; -- Ruslan Tolkachev