Skip to content

Extensible Entity Attributes: a TEXT Column Holding JSON

Adityo Guni Waluyo

Adding a TEXT attributes column for per-module dynamic attributes, with validation moved into the application.

TL;DR

Instead of adding nullable columns for each module, we added a single TEXT attributes field to handle flexible data. Since MySQL TEXT doesn't validate JSON, the app enforces rules like paired labels and no duplicates before saving. It's a lightweight escape hatch that avoids schema churn and only gets promoted to a real column when filtering is needed.

The report from the tourism module landed first: they need a luas_lahan field. Before that one was even done, the cultural-arts module asked for a different set of attributes entirely. If I answered every request with a new column and a new migration, the entities table would turn into a garden of mostly-NULL columns that never get used across modules.

My JSON-TEXT Guess Was Wrong

Migration 000057 adds entities.attributes as TEXT NULL. In the migration comment I called it the JSON-TEXT pattern, and that name fooled me for a moment. I half expected MySQL to validate the contents the way a native JSON type would: broken documents rejected on insert.

It doesn't. This is plain TEXT. The database doesn't care whether the content is valid JSON or a pile of random braces, as long as it fits. Our production still runs MySQL 5.7, and until I checked the docs I didn't realize how wide that gap is. The native JSON type is a different story, and the distinction matters: the 5.7 manual lists automatic validation as its first advantage, and invalid documents get an error straight from the database [1].

The second consequence of the texture: since this isn't a JSON column, the JSON type's indexing restriction doesn't apply to us either. Worth noting anyway for the day we switch to native JSON: a JSON column can't be indexed directly; an index means building a generated column that extracts a scalar from the document [1].

Validation Moves Into the Application

With the database hands-off, validation has to live in the app. The CMS renders an Additional Attributes panel of label-value rows with two firm rules: a half-filled pair is rejected (label and value must be filled together), and duplicate labels are rejected before the payload leaves the browser. Let either slip through and the public side receives leaked data of an unclear shape.

The rest is mechanical. Non-string values get stringified for consistency, empty rows are dropped, and the survivors are assembled with Object.fromEntries() [2]. That helper earns its keep: an array of key-value pairs becomes a JavaScript object in one line, no typo-prone manual loop.

The Fourth Copy of the Pattern

On the Go side the change is minimal. The DTO takes json.RawMessage, the model carries a sql.NullString, and INSERT and UPDATE grow one placeholder. Go never unmarshals anything; the text lands in the column as-is. The OpenAPI contract follows for free since the schema just gains one field.

This is really the fourth copy of the same pattern in the repo. operating_hours, social_media, and amenities already worked this way for per-entity dynamic data. The attributes column just lifts the pattern into a general bucket for every module, including ones whose attribute shapes nobody has thought about yet.

The alternative I rejected: a generic entity-attribute-value table, one row per pair. It sounds great at first because every attribute becomes queryable. The cost is real, though: rendering the form means pivoting rows back into an object, saving means diffing old rows against new ones, and every list query that used to be a single SELECT grows a join per attribute. For attributes that spend 90% of their life being rendered on a detail page, that complexity never pays for itself. One TEXT column plus editor-side validation is the more honest choice at our scale.

My position stands: a JSON-holding TEXT column is an honest escape hatch on MySQL 5.7. As long as we carry the validation in the application, it beats planting dozens of nullable columns that mostly go unused. If we ever move to MySQL 8 and want database-side validation, the column migrates to native JSON. For now, every new attribute request from a department finishes without touching the schema.

One contract I keep from the start: this column only serves simple read-write needs. The moment an attribute needs serious list filtering or joins, that's the alarm to promote it into a real column via a migration. With that boundary drawn early, the flexible bucket and the structured schema don't fight over territory.

Sources

Related articles