JSON column + CustomFieldDefinition — per-entity, per-merchant, unlimited, typed, indexable, offline-ready
Cada merchant quiere "una columna más" en Product, Customer o Sale. Una farmacia necesita ExpirationDate, NDC, DEASchedule. Una ferretería necesita ThreadSize, Material, WarrantyMonths. RMS/RMH resolvió esto con columnas pre-alocadas + tabla de labels — funcional pero con techo duro de 3 o 15 campos por entidad. Qiiub elige el patrón moderno (Shopify Metafields, Square Custom Attributes, NetSuite): una columna JSON por entidad con los valores + una tabla CustomFieldDefinition por merchant que describe key, tipo, validación, y si es searchable.
Lo searchable se resuelve con computed columns persistidos (SQL Server) o generated columns stored (SQLite), ambas con índices. Performance queda indistinguible de columnas reales. Un POS terminal ejecuta posupdate.exe para aplicar ALTER TABLE aditivos cuando un merchant agrega un nuevo campo searchable — alineado con la política additive-only existente.
Decisión cerrada en product-module-decisions.md #09, research completo en research-custom-fields-extensibility.md, diseño en config-extensibility.md §4.
Cuatro restricciones simultáneas que descartan los approaches obvios:
| Approach | Pros | Contras | Veredicto |
|---|---|---|---|
Wide Table (RMH/RMS)CustomText1-5, CustomDate1-5, CustomNumber1-5 + CustomCaption |
Reads rapidísimos · Validación implícita por tipo de columna | Techo duro (3 o 15 campos) · Wasted space en NULLs · Label indirection via Style int enum |
✗ Limitado |
| EAV Tabla (EntityId, AttributeId, Value) |
Unlimited · Zero schema change | JOIN/PIVOT en cada read · Multi-row writes · Offline payload explota | ✗ Lento en POS |
| JSON column + Definition (Qiiub elegido) | Unlimited · Single read · Indexable vía computed columns · Offline compatible · Pattern de Shopify/NetSuite | Writes reescriben blob completo (irrelevante a volumen POS) · Requiere SQL 2022+/SQLite 3.38+ | ✓ Cumple las 4 restricciones |
Cada entidad extendible (Product, Customer, Sale, Employee) gana una columna CustomFields nvarchar(max) DEFAULT '{}' con CHECK (ISJSON(CustomFields) = 1). Una tabla nueva CustomFieldDefinition describe, per-merchant, qué campos existen para cada EntityType. Los campos marcados IsSearchable = true materializan un computed/generated column indexable.
FieldKey en CustomFieldDefinition es el key usado dentro del JSON. La definición describe el shape; la columna JSON contiene los valores. Ambos llegan inline con la entidad al POS terminal.
json typeHoy: nvarchar(max) + ISJSON (compatible SQL 2022 + Azure SQL DB + SQLite). Cuando todos los environments soporten native json (SQL Server 2025 / Azure SQL DB current), migración no-breaking via ALTER COLUMN CustomFields json NOT NULL. Storage ~15% más compacto, parsing más rápido, dedicated JSON indexes (reemplazan computed columns).
Al marcar IsSearchable=true, el sistema agrega:
-- SQL Server (cloud): ALTER TABLE Product ADD CustomField_ExpirationDate AS CAST(JSON_VALUE(CustomFields, '$.expiration_date') AS date) PERSISTED; CREATE NONCLUSTERED INDEX IX_Product_CF_ExpirationDate ON Product(MerchantId, CustomField_ExpirationDate) WHERE CustomField_ExpirationDate IS NOT NULL; -- SQLite (POS terminal — applied by posupdate.exe): ALTER TABLE Product ADD COLUMN CustomField_ExpirationDate AS (json_extract(CustomFields, '$.expiration_date')) STORED; CREATE INDEX IX_Product_CF_ExpirationDate ON Product(CustomField_ExpirationDate);
SELECT * FROM Product WHERE MerchantId = @m AND Sku = @sku; -- 1 row read, CustomFields JSON inline, <1ms
-- SQL Server SELECT Name, JSON_VALUE(CustomFields, '$.ndc_number') AS Ndc, JSON_VALUE(CustomFields, '$.expiration_date') AS Expiration FROM Product WHERE Id = @id; -- SQLite (POS terminal) SELECT Name, json_extract(CustomFields, '$.ndc_number') AS Ndc, json_extract(CustomFields, '$.expiration_date') AS Expiration FROM Product WHERE Id = @id;
-- Productos que expiran este mes — usa el index SELECT Id, Name, CustomField_ExpirationDate FROM Product WHERE MerchantId = @m AND CustomField_ExpirationDate BETWEEN @start AND @end ORDER BY CustomField_ExpirationDate; -- Performance idéntica a una columna real indexed
UPDATE Product SET CustomFields = JSON_MODIFY(CustomFields, '$.expiration_date', '2027-12-31') WHERE MerchantId = @m AND CategoryId = @rxCategoryId;
-- CustomFieldDefinition rows (seeded por módulo pharmacy.rx): { EntityType: "Product", FieldKey: "ndc_number", DataType: "string", IsRequired: true, Module: "pharmacy" } { EntityType: "Product", FieldKey: "expiration_date", DataType: "date", IsSearchable: true, Module: "pharmacy" } { EntityType: "Product", FieldKey: "lot_number", DataType: "string", Module: "pharmacy" } { EntityType: "Product", FieldKey: "dea_schedule", DataType: "select", Options: '["CII","CIII","CIV","CV"]', Module: "pharmacy.controlled" } -- Product row con estos custom fields: { "Name": "Oxycodone 5mg", "CustomFields": { "ndc_number": "12345-678-90", "expiration_date": "2027-06-15", "lot_number": "LOT-A7X-2026", "dea_schedule": "CII" } }
-- CustomFieldDefinition rows: { EntityType: "Product", FieldKey: "thread_size", DataType: "string", IsSearchable: true } { EntityType: "Product", FieldKey: "pipe_schedule", DataType: "select", Options: '["SCH 40","SCH 80","SCH 160"]' } { EntityType: "Product", FieldKey: "material", DataType: "select", Options: '["PVC","Acero","Cobre","Bronce"]' } { EntityType: "Product", FieldKey: "warranty_months", DataType: "number" } -- Product row: { "Name": "Tubo PVC 1/2" x 10ft", "CustomFields": { "thread_size": "1/2-NPT", "pipe_schedule": "SCH 40", "material": "PVC", "warranty_months": 24 } }
-- CustomFieldDefinition rows: { EntityType: "Product", FieldKey: "batch_number", DataType: "string" } { EntityType: "Product", FieldKey: "origin_country", DataType: "string" } { EntityType: "Product", FieldKey: "organic_certified", DataType: "boolean" } { EntityType: "Product", FieldKey: "allergen_warnings", DataType: "string" } -- Product row: { "Name": "Cereal Integral 500g", "CustomFields": { "batch_number": "B-2026-0421-A", "origin_country": "PR", "organic_certified": true, "allergen_warnings": "Gluten, Lácteos" } }
El diseño funciona idénticamente en cloud y en cada POS terminal. Las funciones JSON difieren por nombre pero la semántica es la misma.
| Operación | SQL Server (cloud) | SQLite (POS terminal) |
|---|---|---|
| Extract value | JSON_VALUE(CustomFields, '$.x') | json_extract(CustomFields, '$.x') |
| Validate JSON | ISJSON(CustomFields) = 1 | json_valid(CustomFields) |
| Persisted computed column | AS JSON_VALUE(...) PERSISTED | AS json_extract(...) STORED |
| Check constraint | CHECK (ISJSON(...) = 1) | CHECK (json_valid(...)) |
| Modify JSON field | JSON_MODIFY(...) | json_set(...) |
CustomFieldDefinition rows bajan como cualquier otra config (watermark por UpdatedAtUtc). Product.CustomFields JSON viaja inline con el Product row, sin payload adicional.CustomFieldDefinition(IsSearchable=true) nuevo, posupdate.exe aplica ALTER TABLE ... ADD ... STORED + CREATE INDEX. Additive-only, alineado con §5.4 del design doc.json_extract, búsqueda usa el generated column.| Plataforma | Modelo | Definitions | Tipos | Searchable |
|---|---|---|---|---|
| Shopify Metafields (gold standard) | JSON inline + definition | ✓ MetafieldDefinition | ~20 base + arrays | ✓ |
| Square | EAV + definition | ✓ CustomAttributeDefinition | 4 base | ✓ |
| BigCommerce | Híbrido (custom fields + metafields) | Parcial | Loose | Parcial |
| Clover | Propiedades key-value sueltas | ✗ | Loose | Parcial |
| Lightspeed | Wide table with labels | Labels per merchant | 4 base | ✓ |
| NetSuite | Híbrido + formula engine | ✓ Rich | Extensive + formulas | ✓ |
| RMS/RMH | Wide table + CustomCaption | Global captions | 3 (text/num/date) | ✓ |
| QIIUB (propuesto) | JSON column + definition | ✓ CustomFieldDefinition | 5 (string/number/date/bool/select) | ✓ via computed columns |
| RMS/RMH | Qiiub | Notas |
|---|---|---|
Item.SubDescription1/2/3 (3 text slots) |
Product.CustomFields con keys sub_description_1/2/3 + CustomFieldDefinition rows |
Migration: convertir 3 columnas a 3 keys en JSON |
Customer.CustomText1-5 + CustomNumber1-5 + CustomDate1-5 (15 slots) |
Customer.CustomFields con keys configurables + CustomFieldDefinition rows (DataType según slot type) |
Límite artificial de 15 eliminado |
Supplier.CustomText1-5 + CustomNumber1-5 + CustomDate1-5 |
Supplier.CustomFields (si se agrega al módulo) |
Misma estrategia |
CustomCaption (ID, HQID, Style int, Caption nvarchar) |
CustomFieldDefinition (FieldKey, FieldName, DataType, IsRequired, IsSearchable, Options, ...) |
Migración: cada CustomCaption row → 1 CustomFieldDefinition con FieldKey derivado del Style + FieldName = Caption |
| # | Enhancement | Use case |
|---|---|---|
| 1 | Multi-value fields (arrays) | Tags, color swatches, certificaciones múltiples |
| 2 | Field versioning / rename | Renombrar FieldKey sin perder data |
| 3 | Validation rules avanzadas | MinValue/MaxValue, regex, date ranges |
| 4 | Display grouping | Agrupar "Regulatory" / "Marketing" / "Technical" |
| 5 | Field-level access control | Solo Managers pueden ver cost notes |
| 6 | Computed / derived fields | profit_margin = formula(price, cost) |
| 7 | Category-scoped definitions | Todos los Rx products requieren NDC |
| 8 | Vertical field templates | Activar pharmacy auto-crea NDC/DEA/Expiration |
| 9 | Native JSON data type migration | ~15% storage reduction en SQL Server 2025 |
| 10 | Configurable searchable limit | Merchant con muchos filter-on fields |
| 11 | Bilingual labels (opt-in) | Merchants bilingües quieren ambos idiomas |
Detalle completo con prerequisites en docs/research/research-custom-fields-extensibility.md §8.
docs/research/research-custom-fields-extensibility.md — research doc completo con industry surveydocs/design/config-extensibility.md §4 — design doc oficialdocs/design/offline-sync-strategy.md — cómo CustomFields y CustomFieldDefinition viajan al POSdocs/research/research-sqlite-capacity.md — validación SQLite para POS terminalsC:\temp\.claude\qiiub\product-module-decisions.md #09 — decision logsrc/QIIUB.Domain/Merchants/CustomFieldDefinition.cs — entity parcial (pendiente completar)