This post answers:

  • How do I add a limit without breaking existing data?
  • How do I migrate old data safely?
  • What should I test, and when?

Adding a rule for new data is easy. The hard part is the data that is already there.

Imagine an app where users type labels, and one day we decide: at most 250 characters. We add the check, ship it, done? Not quite. Every label saved before today is still stored, and some are longer.

That’s what happened to us: one 504-character label broke every page built from one form. Here’s the two-step fix we used: guard the write, then migrate the old data.

One label broke every page

Our form builder is a tool where customers build their own forms, with fields and dropdown options. It accepted text of any length. But every page built from a form checks each label against 250 characters when it loads. So one 504-character label broke all of them:

The value 'FormFieldLabel' is longer than max of 250.

The form builder saves a 504-character label without a check; reading it back checks for at most 250 characters, so every page built from this form fails to load

Guard the write

The read side (loading a page) already had the rule; the write side (saving the form) didn’t. So we put the same 250 limit on the write side, where the data comes in. The limit lives in two places, on purpose. The backend decides; the editor only helps.

Backend (the real rule): Eric Evans, in Domain-Driven Design, calls this a value object. It checks its own rules when it’s created, so an invalid value never enters the system.

AtomicValue( name('Label'), typeStr( max => 250 ) );

Label->create($longText);   # 504 characters: save refused
Label->create('Audit A');   # ok: saved

Editor (a convenience):

<input v-model="label" maxlength="250">

A test checks that the input stops at 250.

So the limit is written twice:

  • Backend check missing: bad data gets in. That was the bug.
  • Editor check missing or wrong: the user only gets “refused” on save. Nothing breaks.
  • The risk: someone changes one 250 and forgets the other. Annoying, but safe.

The editor stops typing at 250 (helps); the backend check allows at most 250 (decides) and refuses a 504-character API call, so every page loads

Migrate the old data

With the backend check, no new text that is too long can be saved. But the old data is still there. We chose to cut the too-long labels down to 250 characters, so we need a migration.

The migration is one line of SQL:

UPDATE options SET label = LEFT(label, 250) WHERE length(label) > 250;

Shipping it end to end

The cut can’t be undone, so we try it first where a mistake costs nothing.

First, tests, from small to big:

  • Unit tests: the rule alone. A 504-character label is cut to 250, a 250-character one stays as it is, and an old form with no labels at all stays unchanged.
  • Integration tests: against a real database. The draft, the published version and its history are all cut, and running the migration a second time changes nothing.

Then the migration passes three places:

  • Local: our laptops, with test data.
  • Staging: a copy of production, with the customers’ data.
  • Production: the real customers. Each customer (a tenant) has its own data.

A staircase from unit test, integration test, local laptop and staging up to production with tenants A, B and C: at the bottom a mistake costs nothing, at the top it hits every customer

It ships in two pull requests. The first one only makes the migration runnable by hand. We ran it locally, then on staging, and checked that the pages load again.

The second one makes it run automatically for every tenant when production is updated. Nobody runs it by hand on production.

Next time you add a limit

  1. Guard the write: the backend refuses new data that breaks the limit; the editor helps the user stay under it.
  2. Migrate the old data: cut what is already stored, tested from small to big before it reaches production.

Before adding a limit, look at the data you already have: how much of it is already too long?