Skip to content

Separating when a record is created from when it becomes valid

In three projects I froze customs invoices as HTML at issue, made coupons printed offline valid only once confirmed, and saved whiteboards as editable objects.

6 min read7 sections

I've built three systems in unrelated domains that needed the same decision: a customs invoicing system in 2020, remittance terminals in 2022 and a meeting platform in 2023.

The products, teams and stacks were different each time, but each design came down to the same question: when does this record count? The moment it was written and the moment it binds are not always the same.

Most data models don't ask. They treat a row that exists as real. That works until the moment of creation and the moment of validity drift apart, and then the model fails without raising an error.

01

When a template change rewrites old documents

Every system that produces documents eventually runs into this, and most run into it too late.

Templates change: a layout is corrected, a legal notice updated or a logo replaced. Then someone opens a document issued two years ago and sees this year's template, with this year's legal text, on a record that was signed under different terms.

No error was raised and no test went red. The document had simply changed, and the archive no longer showed what had been issued.

The same problem appears wherever creation and validity can drift apart, for example with a coupon printed with no network or a whiteboard that only exists as a screenshot.

02

Customs invoices: freeze the rendered document at issue

The customs invoicing requirement was absolute: an issued invoice must never change.

The default design is to store the invoice data and render the document when someone asks for it. That is the natural design, and here it breaks, because a document rendered on demand is a function of three things at read time: the data, the template and the rendering code. Change any of the three and every old document changes with it.

So at the moment of issue, the system renders the document and stores it as immutable HTML. Later template edits touch nothing already issued.

In Node.js and PostgreSQL the shape is roughly this:

snippet.js
JavaScript · 24 lines
async function issueInvoice(db, invoiceId) {  return db.tx(async (t) => {    const invoice = await t.one(      'SELECT * FROM invoices WHERE id = $1 FOR UPDATE',      [invoiceId],    )    if (invoice.issued_at) throw new Error('Invoice already issued')     const template = await t.one(      'SELECT * FROM invoice_templates WHERE active',    )    const html = renderInvoice(invoice, template)     await t.none(      `INSERT INTO issued_invoices (invoice_id, html, issued_at)       VALUES ($1, $2, now())`,      [invoiceId, html],    )    await t.none(      'UPDATE invoices SET issued_at = now() WHERE id = $1',      [invoiceId],    )  })}

Reading an issued invoice then means returning the stored HTML, without ever calling renderInvoice again.

This separates the definition of a document from the issued document, so the template can keep evolving while the history stays intact.

A second benefit is easy to miss: the frozen output no longer depends on the rendering code staying correct in the future. If documents were rendered on demand, a bug introduced next year in a date formatter would retroactively alter every invoice ever issued. With the output frozen, a future bug can damage only future documents.

Immutability that exists only in application code is a convention, and the next person with a migration script can break it. To make it a rule, let the database refuse the change:

query.sql
SQL · 9 lines
CREATE FUNCTION reject_change() RETURNS trigger AS $$BEGIN  RAISE EXCEPTION 'issued invoices cannot be changed';END;$$ LANGUAGE plpgsql; CREATE TRIGGER issued_invoices_immutableBEFORE UPDATE OR DELETE ON issued_invoicesFOR EACH ROW EXECUTE FUNCTION reject_change();
03

Remittance coupons: printed offline, valid only when the system says so

The remittance terminals had the opposite problem. A recipient collects cash at a filling station using a coupon code. The terminal has to keep working when the network is down, but a coupon that pays out twice is the one failure that costs real money.

If creation and validity are the same event, you're stuck. Either the terminal refuses to print without a connection and stops being useful where connectivity is worst, or it prints a coupon that's immediately payable, and you've built a way to pay out money the central system doesn't know about yet.

Splitting the two events removes the dilemma. The coupon prints but is not yet valid, and it becomes valid when the system says so: automatically when connectivity returns, or manually through an operator, who is signaled that an unactivated coupon exists.

On the terminal, in Kotlin, the shape is two states and a sweep that runs when the network comes back:

snippet.kt
Kotlin · 29 lines
enum class CouponState { PRINTED, VALID } data class Coupon(    val code: String,    val state: CouponState,    val printedAt: Instant,    val validatedAt: Instant? = null,) class CouponSync(    private val dao: CouponDao,    private val api: CouponApi,) {    // printing only records that the coupon exists    fun print(code: String): Coupon =        Coupon(code, CouponState.PRINTED, Instant.now())            .also { dao.insert(it) }     // validity comes from the system, never from the terminal alone    suspend fun validatePending() {        dao.findByState(CouponState.PRINTED).forEach { coupon ->            api.register(coupon.code)            dao.markValid(coupon.code, Instant.now())        }    }     // what the operator is shown    fun pending(): List<Coupon> = dao.findByState(CouponState.PRINTED)}

What matters in this code is that the in-between state is stored and visible. The system knows a coupon was created and not honored, so that state can't be lost without anyone noticing. A coupon that is printed but not yet valid is a known fact, with an operator attached to it.

The asymmetry decides which way to fail: better an unusable coupon than one paid out twice. An unusable coupon is an inconvenience that someone can resolve. A coupon paid out twice is money gone.

04

Meeting whiteboards: save the objects instead of a picture

The meeting platform needed the whiteboard to survive the meeting as something you could still work with. The default here is a screenshot: when the session ends, save the canvas as an image. The board then exists in the sense that there's a record of it, but only as a picture, and a picture can only be viewed.

So the whiteboard is saved as structured objects instead of a flat image, and every stroke, shape and piece of text keeps its own properties. The board can be reloaded later and edited.

With Fabric.js the difference is one method call:

a picture of the board: viewable, not editable
JavaScript · 11 lines
// a picture of the board: viewable, not editableconst image = canvas.toDataURL({ format: 'png' }) // the board itself: every object with its propertiesconst board = canvas.toJSON()await api.put(`/meetings/${meetingId}/board`, board) // next time, the same board, still editableconst saved = await api.get(`/meetings/${meetingId}/board`)await canvas.loadFromJSON(saved)canvas.requestRenderAll()
05

The same question: what the stored record is for

At first glance the invoice and the whiteboard point in opposite directions. The invoice is frozen as rendered output, and the whiteboard is not.

In both cases, though, the question was what the stored record is for. An issued invoice is for proving what was issued, so the system stores exactly what was issued and nothing is allowed to re-derive it. A whiteboard is for continuing the work, so the system stores the objects you work on instead of a picture of them. The coupon adds time: the record exists first and counts later, and the system has to know which of the two it's holding.

What usually destroys itWhat gets stored
Invoicea template changethe rendered document, frozen at issue
Coupona lost connectionthe coupon, printed but not valid until the system says so
Whiteboarda closed sessionthe objects, not a picture of them

In each of the three products, the record survived the event that usually destroys it.

06

The questions I ask now

Whenever a system produces something that someone will rely on later, I ask four things before designing the table:

  1. Can what this record shows change after it's created? If it's derived at read time from anything that evolves, such as templates, code or reference data, then yes, whether you meant it to or not.
  2. Should it? An invoice shouldn't. A whiteboard should stay editable. Neither answer is the default, and both have to be chosen.
  3. Can it exist before it counts? If yes, those are two states, and they need two representations instead of one row and a hopeful assumption.
  4. What moves it from one state to the other, and can anyone see the records in between? If a pending state is invisible, records get lost in it without anyone finding out.
07

What I'd do differently

On the customs system, freezing the issued document was correct but incomplete, because we never versioned the template itself. No issued invoice could change, but there was no record of which template version produced it, or of who changed that template and when.

The snapshot answered the urgent question and left the audit question open. The fix is small if you make it at the start: make templates append-only, like issued documents, and point each issued document at the version that produced it.

query.sql
SQL · 10 lines
CREATE TABLE invoice_template_versions (  id          bigserial PRIMARY KEY,  body        text        NOT NULL,  changed_by  text        NOT NULL,  changed_at  timestamptz NOT NULL DEFAULT now()); ALTER TABLE issued_invoices  ADD COLUMN template_version_id bigint  REFERENCES invoice_template_versions (id);

With the output frozen and the template versioned, the archive shows both what was issued and why it looks the way it does.

Where this came fromThree engagements: customs invoicing, cross-border remittance terminals and a white-label meeting platform for healthcare and finance.Read the case study →Related serviceInvoicing and customs systemsView service →Next postWhy I modeled file permissions in Neo4j instead of SQL tablesRead →

Writing

If you’re working on something similar, send us the details.

The first call takes 30 minutes, and you’re under no obligation.

Tell us about your projectcontact@microtech.consulting