DEV Community

Cover image for Keep the User Model Stable (and Let Everything Else Change)
Michael Interface
Michael Interface

Posted on • Originally published at backendops.hashnode.dev

Keep the User Model Stable (and Let Everything Else Change)

I treat the User model as one of the few tables in a startup backend that behaves like a public API.

It’s referenced by auth, permissions, analytics, billing — almost everything. Once real users exist, changing User stops being a local refactor and starts being risky.

Over time, I noticed a pattern: we keep attaching fast‑changing business logic to this very stable model.

This post is about a small decision I now make almost by default: keeping the User model stable, and moving volatile requirements into separate models, even when they’re one‑to‑one with User.


The setup: a “simple” quota

A common requirement in startup products is limiting what a user can do.

For example:

  • how many surveys a user can send
  • how many invites per month
  • how many exports per day

The obvious and straight forward implementation looks like this:

-- Naive approach: putting volatile quota fields on the users table
ALTER TABLE users
ADD COLUMN max_surveys_allowed INTEGER DEFAULT 0,
ADD COLUMN surveys_sent_count INTEGER DEFAULT 0;
Enter fullscreen mode Exit fullscreen mode

Two integers. No joins. Easy.


What actually changes in practice

Quota logic changes fast — and not just in small ways.
What starts as a simple per‑user limit usually turns into:

  • limits per company or per plan
  • monthly resets instead of lifetime counts
  • one‑off exceptions for important customers

That’s several structural changes within the first 6–12 months.

The problem is that this volatile logic often lives on the User table — the same table that’s queried on every authenticated request and depended on by auth and permissions.

So a fast‑changing feature ends up forcing risky migrations and updates on one of the most sensitive parts of the system - for example:

  • locking user rows to update counters on a table that’s read on every request, increasing contention under load
  • having to audit and coordinate deploys because many unrelated queries assume a specific User table shape

A small but deliberate separation

Instead of extending User, I now model quotas as a separate table with a strict one‑to‑one relationship:

CREATE TABLE user_survey_quotas (
    user_id BIGINT PRIMARY KEY REFERENCES users(id),
    max_surveys_allowed INTEGER NOT NULL,
    surveys_sent_count INTEGER NOT NULL
);
Enter fullscreen mode Exit fullscreen mode

At first glance this looks like additional complexity with extrea table and a new join operation down the line.

In practice, it buys a lot.


Why this holds up better over time

1. Different rates of change

In products I’ve worked on, quota‑related models changed multiple times per year.

The User model, once things stabilized, changed maybe once or twice a year, and often only additively.

Separating them means:

  • quota changes don’t trigger risky User migrations
  • rollbacks are easier
  • experiments are easier to delete

2. Optional complexity

Not every user needs quotas.
With a separate model, it’s trivial to say:

  • only some users have limits
  • only paid users get a row
  • enterprise users override defaults

Trying to encode that with nullable fields on User gets messy fast.


3. Concurrency and correctness

Quota updates are counters.
They usually require:

  • transactions
  • row‑level locks
  • careful increments

Having a dedicated table makes it much clearer what needs to be locked and updated:

-- Safer approach: update quota in its own table with proper locking
BEGIN;

SELECT surveys_sent_count, max_surveys_allowed
FROM user_survey_quotas
WHERE user_id = :user_id
FOR UPDATE;

-- application-side check:
-- if surveys_sent_count >= max_surveys_allowed → reject

UPDATE user_survey_quotas
SET surveys_sent_count = surveys_sent_count + 1
WHERE user_id = :user_id;

COMMIT;
Enter fullscreen mode Exit fullscreen mode

That logic is much harder to reason about when it’s mixed into a large User model with unrelated concerns.


4. Future changes are cheaper

In one product I worked on, quota logic went from:

  • lifetime limits → monthly resets → plan‑based limits → per‑company overrides

Because it lived outside User, we could rewrite it without touching auth, permissions, or existing queries.
That separation paid for itself very quickly.


Closing thought

My take is simple: I try to keep the User model boring and stable, and push fast‑changing business rules into models that are cheap to evolve or throw away.

Yes, adding a one‑to‑one table is slightly more work up front. But in startup backends, the real cost isn’t joins or extra tables — it’s undoing early decisions once real usage and real business constraints show up.

This approach has paid off for me in multiple projects, but I’m curious how others handle this tradeoff. Do you keep volatile logic off User as well, or have you found cases where it’s not worth the indirection?

Top comments (0)