Extend core modules using the Universal Module Extension System (UMES). Use when adding columns/fields/filters to existing tables/forms, enriching API responses, intercepting API routes, blocking/validating mutations, replacing UI components, injecting menu items, or reacting to domain events. Triggers on "extend", "add column to", "add field to", "inject into", "intercept", "enrich", "hook into", "customize", "override component", "add menu item", "react to event", "block mutation", "validate before save".
Extend any core module without modifying its source code. This skill guides you through the Universal Module Extension System (UMES) — selecting the right mechanism, generating all required files, and wiring everything correctly.
For full type contracts, see references/extension-contracts.md.
Ask what the developer wants to achieve. Match to the correct mechanism(s).
| Goal | Mechanism(s) Required | Section |
|---|---|---|
| Add data to another module's API response | Response Enricher | §2 |
| Add a field to another module's form | Response Enricher + Field Widget + injection-table (Triad) | §12 |
| Add a column to another module's table | Response Enricher + Column Widget + injection-table (Triad) | §12 |
| Add a filter to another module's table | Filter Widget + injection-table + API Interceptor (for server filters) | §5 + §8 |
| Add row/bulk actions to another module's table | Row Action / Bulk Action Widget + injection-table | §6 |
| Add a menu item to sidebar/topbar | Menu Item Widget + injection-table | §7 |
| Validate/block a request before it reaches an API route | API Interceptor (before hook) | §8 |
| Transform/enrich an API response after it returns | API Interceptor (after hook) or Response Enricher | §8 or §2 |
| Block/validate mutations before entity persistence | Mutation Guard | §9 |
| Replace or wrap a UI component | Component Replacement | §10 |
| React to domain events (after entity create/update/delete) | Event Subscriber | §11 |
| Add a tab/section to a detail page | Widget Injection (tab kind) + injection-table | §6 |
When multiple mechanisms are needed (e.g., "add a column"), follow the Triad Pattern (§12) which wires enricher → widget → injection-table as a coordinated set.
Purpose: Add computed fields to another module's API response. Fields are namespaced under _<yourModule> to avoid collisions.
File: src/modules/<your-module>/data/enrichers.ts
import type { ResponseEnricher, EnricherContext } from '@open-mercato/shared/lib/crud/response-enricher'
const enricher: ResponseEnricher = {
id: '<your-module>.<enricher-name>',
targetEntity: '<target-module>.<entity>', // e.g., 'customers.person'
priority: 50,
timeout: 2000,
fallback: { _<your-module>: {} },
async enrichOne(record, context: EnricherContext) {
const em = context.em as EntityManager
// Fetch your data for this single record
const data = await em.findOne(YourEntity, {
foreignId: record.id,
organizationId: context.organizationId,
})
return {
...record,
_<your-module>: {
fieldName: data?.value ?? null,
},
}
},
// REQUIRED for list endpoints — prevents N+1 queries
async enrichMany(records, context: EnricherContext) {
const em = context.em as EntityManager
const ids = records.map(r => r.id)
// Single batch query for ALL records
const items = await em.find(YourEntity, {
foreignId: { $in: ids },
organizationId: context.organizationId,
})
const byForeignId = new Map(items.map(i => [i.foreignId, i]))
return records.map(r => ({
...r,
_<your-module>: {
fieldName: byForeignId.get(r.id)?.value ?? null,
},
}))
},
}
export const enrichers = [enricher]
enrichMany — without it, list endpoints cause N+1 queries_<your-module> prefix$in) in enrichMany, never per-record lookupscritical: false (default) so enricher failures don't break the target APItimeout to prevent slow external calls from blocking responsesfallback to provide safe defaults when enricher times outinterface EnricherContext {
organizationId: string // Current tenant org
tenantId: string // Current tenant
userId: string // Authenticated user
em: EntityManager // Read-only database access
container: AwilixContainer // DI container
requestedFields?: string[] // Sparse fieldset request
userFeatures?: string[] // User's ACL features
}
Purpose: Add an editable field to another module's CrudForm.
File: src/modules/<your-module>/widgets/injection/<widget-name>/widget.ts
import type { InjectionFieldWidget } from '@open-mercato/shared/modules/widgets'
import { readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
const widget: InjectionFieldWidget = {
metadata: { id: '<your-module>.injection.<field-name>', priority: 50 },
fields: [
{
id: '_<your-module>.<fieldName>', // Matches enricher namespace
label: '<your-module>.fields.<fieldName>', // i18n key
type: 'select', // text | textarea | number | select | checkbox | date | custom
group: 'details', // Target group in CrudForm
options: [
{ value: 'option1', label: '<your-module>.options.option1' },
{ value: 'option2', label: '<your-module>.options.option2' },
],
},
],
eventHandlers: {
onSave: async (data, context) => {
const resourceId = (context as Record<string, unknown>).resourceId as string
const value = (data as Record<string, unknown>)['_<your-module>.<fieldName>']
// Upsert pattern — idempotent save
const existing = await readApiResultOrThrow<{ items: Array<{ id: string }> }>(
`/api/<your-module>/resource?foreignId=${resourceId}`,
)
if (existing?.items?.[0]?.id) {
await readApiResultOrThrow(`/api/<your-module>/resource`, {
method: 'PUT',
body: JSON.stringify({ id: existing.items[0].id, foreignId: resourceId, value }),
})
} else {
await readApiResultOrThrow(`/api/<your-module>/resource`, {
method: 'POST',
body: JSON.stringify({ foreignId: resourceId, value }),
})
}
},
},
}
export default widget
id MUST match the enricher namespace path (e.g., _example.priority)onSave endpoints MUST be idempotent (use upsert pattern)onSave fires BEFORE the core form save — design for partial failurelabel and option labels — never hardcode stringsPurpose: Add a column to another module's DataTable.
File: src/modules/<your-module>/widgets/injection/<widget-name>/widget.ts
import type { InjectionColumnWidget } from '@open-mercato/shared/modules/widgets'
const widget: InjectionColumnWidget = {
metadata: { id: '<your-module>.injection.<column-name>', priority: 40 },
columns: [
{
id: '<your-module>_<fieldName>',
header: '<your-module>.columns.<fieldName>', // i18n key
accessorKey: '_<your-module>.<fieldName>', // Path to enriched data
sortable: false, // MUST be false for enriched-only fields
cell: ({ getValue }) => {
const value = getValue()
return typeof value === 'string' ? value : '—'
},
},
],
}
export default widget
accessorKey MUST point to enriched field path (e.g., _example.priority)sortable MUST be false for enriched-only fields (not in database index)Purpose: Add a filter control to another module's DataTable filter bar.
File: src/modules/<your-module>/widgets/injection/<widget-name>/widget.ts
import type { InjectionFilterWidget } from '@open-mercato/shared/modules/widgets'
const widget: InjectionFilterWidget = {
metadata: { id: '<your-module>.injection.<filter-name>', priority: 35 },
filters: [
{
id: '<your-module><FilterName>',
label: '<your-module>.filters.<filterName>', // i18n key
type: 'select', // select | text | date | dateRange | boolean
strategy: 'server', // 'server' = sent as query param, 'client' = filtered locally
queryParam: '<your-module><FilterName>',
options: [
{ value: 'value1', label: '<your-module>.options.value1' },
{ value: 'value2', label: '<your-module>.options.value2' },
],
},
],
}
export default widget
When strategy: 'server', the filter value is sent as a query parameter. You need an API Interceptor to process it:
// api/interceptors.ts
const filterInterceptor: ApiInterceptor = {
id: '<your-module>.filter-by-<filterName>',
targetRoute: '<target-module>/<entities>', // e.g., 'customers/people'
methods: ['GET'],
priority: 50,
async before(request, context) {
const filterValue = request.query?.['<your-module><FilterName>']
if (!filterValue) return { ok: true }
// Query your data to find matching target IDs
const em = context.em as EntityManager
const matches = await em.find(YourEntity, {
fieldName: filterValue,
organizationId: context.organizationId,
})
const matchingIds = matches.map(m => m.foreignId)
if (matchingIds.length === 0) {
return { ok: true, query: { ...request.query, ids: 'NONE' } }
}
// Narrow results by rewriting the ids query parameter
const existingIds = request.query?.ids as string | undefined
const narrowedIds = existingIds
? matchingIds.filter(id => existingIds.split(',').includes(id))
: matchingIds
return { ok: true, query: { ...request.query, ids: narrowedIds.join(',') } }
},
}
queryParamids query narrowing over post-filtering response arraysids: 'NONE' to return empty results when no matches foundPurpose: Add context menu actions or bulk operations to another module's DataTable.
File: src/modules/<your-module>/widgets/injection/<widget-name>/widget.ts
import type { InjectionRowActionWidget } from '@open-mercato/shared/modules/widgets'
import { InjectionPosition } from '@open-mercato/shared/modules/widgets/injection-position'
const widget: InjectionRowActionWidget = {
metadata: { id: '<your-module>.injection.<action-name>', priority: 30 },
rowActions: [
{
id: '<your-module>.<entity>.<action>',
label: '<your-module>.actions.<actionName>', // i18n key
icon: 'CheckSquare', // Lucide icon name
features: ['<your-module>.<action>'], // ACL gating
placement: { position: InjectionPosition.After, relativeTo: 'edit' },
onSelect: (row, context) => {
const id = (row as Record<string, unknown>).id as string
const navigate = (context as { navigate?: (path: string) => void }).navigate
navigate?.(`/backend/<your-module>/resource/${id}`)
},
},
],
}
export default widget
import type { InjectionBulkActionWidget } from '@open-mercato/shared/modules/widgets'
const widget: InjectionBulkActionWidget = {
metadata: { id: '<your-module>.injection.bulk-<action-name>', priority: 30 },
bulkActions: [
{
id: '<your-module>.bulk.<action>',
label: '<your-module>.actions.bulk<ActionName>',
features: ['<your-module>.<action>'],
onExecute: async (selectedRows, context) => {
const ids = selectedRows.map(r => (r as Record<string, unknown>).id)
await readApiResultOrThrow(`/api/<your-module>/bulk-action`, {
method: 'POST',
body: JSON.stringify({ targetIds: ids }),
})
;(context as { refresh?: () => void }).refresh?.()
},
},
],
}
export default widget
import type { InjectionWidget } from '@open-mercato/shared/modules/widgets'
const widget: InjectionWidget = {
metadata: { id: '<your-module>.injection.<tab-name>', priority: 40 },
component: () => import('./widget.client'),
}
export default widget
Then create the client component at widget.client.tsx:
'use client'
import { useT } from '@open-mercato/shared/lib/i18n/context'
export default function MyTabContent({ context }: { context: Record<string, unknown> }) {
const t = useT()
const resourceId = context.resourceId as string
// Fetch and display your data
return <div>...</div>
}
InjectionPosition for relative placement — never hardcode positionsfeatures for ACL-gated actionsid must be stable for integration testingonExecute should call refresh() after mutationPurpose: Add items to sidebar, topbar, or profile dropdown.
File: src/modules/<your-module>/widgets/injection/<widget-name>/widget.ts
import type { InjectionMenuItemWidget } from '@open-mercato/shared/modules/widgets'
import { InjectionPosition } from '@open-mercato/shared/modules/widgets/injection-position'
const widget: InjectionMenuItemWidget = {
metadata: { id: '<your-module>.injection.menus' },
menuItems: [
{
id: '<your-module>-<page>-link',
labelKey: '<your-module>.menu.<pageName>', // i18n key
label: 'Fallback Label', // Fallback if i18n missing
icon: 'LayoutDashboard', // Lucide icon name
href: '/backend/<your-module>',
features: ['<your-module>.view'], // ACL gating
groupId: '<your-module>.nav.group',
groupLabelKey: '<your-module>.nav.group',
placement: { position: InjectionPosition.Last },
},
],
}
export default widget
| Spot ID | Location |
|---|---|
menu:sidebar:main | Main sidebar navigation |
menu:sidebar:settings | Settings sidebar |
menu:sidebar:profile | Profile sidebar |
menu:topbar:profile-dropdown | User profile dropdown |
menu:topbar:actions | Top bar action area |
labelKey (i18n) instead of label whenever possiblefeatures for permission-gated itemsgroupId + groupLabelKey to group related menu itemsid must be stable for integration testsPurpose: Hook into API routes to validate, transform, or enrich requests/responses without modifying the route.
File: src/modules/<your-module>/api/interceptors.ts
import type { ApiInterceptor } from '@open-mercato/shared/lib/crud/api-interceptor'
const interceptors: ApiInterceptor[] = [
{
id: '<your-module>.validate-<action>',
targetRoute: '<target-module>/<entities>', // e.g., 'customers/people'
methods: ['POST', 'PUT'],
priority: 50, // Lower = earlier execution
timeoutMs: 5000,
async before(request, context) {
// Validate request
const value = request.body?.someField
if (!value) {
return { ok: false, statusCode: 422, message: 'someField is required' }
}
// Optionally rewrite body or query
return { ok: true, body: { ...request.body, normalizedField: String(value).trim() } }
},
async after(request, response, context) {
// Optionally enrich response
return {
merge: {
_<your-module>: { processedAt: Date.now() },
},
}
},
},
]
export { interceptors }
before hook: return { ok: false, message } to reject — never throw errorsafter hook: use merge to add fields, replace to swap entire response bodytargetRoute over wildcards (*) — wildcards match too broadlyquery.ids (comma-separated UUIDs) — never post-filter response arraysfeatures for permission-gated interceptorsPurpose: Block or validate mutations at the entity level before database persistence. Runs after interceptors and before ORM flush.
File: src/modules/<your-module>/data/guards.ts
import type { MutationGuard, MutationGuardInput, MutationGuardResult } from '@open-mercato/shared/lib/crud/mutation-guard-registry'
const guard: MutationGuard = {
id: '<your-module>.<guard-name>',
targetEntity: '<target-module>.<entity>', // or '*' for all entities
operations: ['create', 'update'], // create | update | delete
priority: 50, // Lower = earlier execution
async validate(input: MutationGuardInput): Promise<MutationGuardResult> {
// input.resourceId is null for create operations
// input.mutationPayload contains the data being saved
if (someConditionFails) {
return {
ok: false,
status: 422,
message: 'Validation failed: reason',
}
}
// Optionally transform payload
return {
ok: true,
modifiedPayload: { ...input.mutationPayload, normalizedField: 'value' },
shouldRunAfterSuccess: true,
metadata: { originalValue: input.mutationPayload?.field },
}
},
async afterSuccess(input) {
// Runs after successful mutation — for cleanup, cache invalidation, logging
// input.metadata contains what you passed from validate()
},
}
export const guards = [guard]
resourceId is null for create operations — handle this casemodifiedPayload — never mutate input.mutationPayload in placetargetEntity: '*' run on EVERY entity mutation — use sparinglyafterSuccess only runs when shouldRunAfterSuccess: true in the validate result{ ok: false, message } — never throwPurpose: Replace, wrap, or transform props of registered UI components without forking source code.
File: src/modules/<your-module>/widgets/components.ts
import React from 'react'
import type { ComponentOverride } from '@open-mercato/shared/modules/widgets/component-registry'
import { ComponentReplacementHandles } from '@open-mercato/shared/modules/widgets/component-registry'
export const componentOverrides: ComponentOverride[] = [
// Mode 1: Wrapper — decorate existing component (safest)
{
target: { componentId: ComponentReplacementHandles.section('ui.detail', 'NotesSection') },
priority: 50,
metadata: { module: '<your-module>' },
wrapper: (Original) => {
const Wrapped = (props: any) =>
React.createElement(
'div',
{ className: 'border border-blue-200 rounded-md p-2' },
React.createElement(Original, props),
)
Wrapped.displayName = '<YourModule>NotesWrapper'
return Wrapped
},
},
// Mode 2: Props transform — modify incoming props
{
target: { componentId: ComponentReplacementHandles.dataTable('customers.people') },
priority: 40,
metadata: { module: '<your-module>' },
propsTransform: (props: any) => ({
...props,
defaultPageSize: 25,
}),
},
// Mode 3: Replace — full component swap (highest risk)
{
target: { componentId: ComponentReplacementHandles.section('sales.order', 'ShipmentDialog') },
priority: 50,
metadata: { module: '<your-module>' },
replacement: React.lazy(() => import('./CustomShipmentDialog')),
propsSchema: ShipmentDialogPropsSchema, // Zod schema for validation
},
]
| Handle | Format | Example |
|---|---|---|
page | page:<path> | page:backend/customers/people |
dataTable | data-table:<tableId> | data-table:customers.people |
crudForm | crud-form:<entityId> | crud-form:customers.person |
section | section:<scope>.<sectionId> | section:ui.detail.NotesSection |
wrapper mode — it preserves the original component and is least likely to breakreplacement mode REQUIRES a propsSchema (Zod) for dev-mode contract validationdisplayName on wrapper components for React DevTools debuggingPurpose: React to domain events emitted by other modules (e.g., after entity creation).
File: src/modules/<your-module>/subscribers/<subscriber-name>.ts
export const metadata = {
event: 'customers.person.created', // module.entity.action (past tense)
persistent: true, // true = survives server restart (uses queue)
id: '<your-module>:on-customer-created',
}
export default async function handler(payload: Record<string, unknown>, ctx: unknown) {
const { resourceId, organizationId, tenantId } = payload as {
resourceId: string
organizationId: string
tenantId: string
}
// Perform side effects
// Examples: create related records, send notifications, sync external systems
}
For subscribers that need to run before a mutation completes and can block it:
export const metadata = {
event: 'customers.person.creating', // .creating = before event (present tense)
persistent: false,
id: '<your-module>:validate-customer-create',
sync: true, // Run synchronously in request pipeline
priority: 50, // Lower = earlier
}
export default async function handler(payload: Record<string, unknown>) {
const data = payload as { mutationPayload?: Record<string, unknown> }
if (someConditionFails(data.mutationPayload)) {
return { ok: false, status: 422, message: 'Cannot create: reason' }
}
// Optionally modify the mutation data
return { ok: true, modifiedPayload: { ...data.mutationPayload, enrichedField: 'value' } }
}
| Event | Timing | Can Block? |
|---|---|---|
module.entity.creating | Before create | Yes (sync only) |
module.entity.created | After create | No |
module.entity.updating | Before update | Yes (sync only) |
module.entity.updated | After update | No |
module.entity.deleting | Before delete | Yes (sync only) |
module.entity.deleted | After delete | No |
.created, .updated, .deleted) cannot block — they are fire-and-forget.creating, .updating, .deleting) require sync: true to block mutationspersistent: true for critical side effects that must survive restartsWhen extending another module's UI with data from your module, you need three coordinated pieces:
┌─────────────────┐ ┌──────────────────┐ ┌───────────────────┐
│ 1. ENRICHER │────▶│ 2. WIDGET │────▶│ 3. INJECTION │
│ (data/ │ │ (widgets/ │ │ TABLE │
│ enrichers.ts) │ │ injection/ │ │ (widgets/ │
│ │ │ <name>/ │ │ injection- │
│ Adds _<module> │ │ widget.ts) │ │ table.ts) │
│ fields to API │ │ │ │ │
│ response │ │ Renders the │ │ Maps widget to │
│ │ │ enriched data │ │ target spot ID │
└─────────────────┘ └──────────────────┘ └───────────────────┘
Step 1 — Enricher (data/enrichers.ts):
const enricher: ResponseEnricher = {
id: 'priorities.customer-priority',
targetEntity: 'customers.person',
priority: 50,
async enrichOne(record, context) {
const priority = await em.findOne(CustomerPriority, { customerId: record.id })
return { ...record, _priorities: { level: priority?.level ?? 'normal' } }
},
async enrichMany(records, context) {
const items = await em.find(CustomerPriority, { customerId: { $in: records.map(r => r.id) } })
const byId = new Map(items.map(i => [i.customerId, i.level]))
return records.map(r => ({ ...r, _priorities: { level: byId.get(r.id) ?? 'normal' } }))
},
}
export const enrichers = [enricher]
Step 2 — Field Widget (widgets/injection/customer-priority-field/widget.ts):
const widget: InjectionFieldWidget = {
metadata: { id: 'priorities.injection.customer-priority-field', priority: 50 },
fields: [{
id: '_priorities.level',
label: 'priorities.fields.level',
type: 'select',
group: 'details',
options: [
{ value: 'low', label: 'priorities.options.low' },
{ value: 'normal', label: 'priorities.options.normal' },
{ value: 'high', label: 'priorities.options.high' },
],
}],
eventHandlers: {
onSave: async (data, context) => {
const customerId = (context as any).resourceId
const level = (data as any)['_priorities.level']
await readApiResultOrThrow('/api/priorities/customer-priorities', {
method: 'POST',
body: JSON.stringify({ customerId, level }),
})
},
},
}
export default widget
Step 3 — Injection Table (widgets/injection-table.ts):
export const widgetInjections = {
'crud-form:customers.person:fields': {
widgetId: 'priorities.injection.customer-priority-field',
priority: 50,
},
}
Step 4 — Run yarn generate to wire everything up.
Same pattern but with Column Widget instead of Field Widget:
| Spot ID Pattern | Widget Type |
|---|---|
crud-form:<entityId>:fields | InjectionFieldWidget |
data-table:<tableId>:columns | InjectionColumnWidget |
data-table:<tableId>:row-actions | InjectionRowActionWidget |
data-table:<tableId>:bulk-actions | InjectionBulkActionWidget |
data-table:<tableId>:filters | InjectionFilterWidget |
After implementing an extension, verify all files exist:
| File | Required When |
|---|---|
data/enrichers.ts | Adding data to another module's API response |
widgets/injection/<name>/widget.ts | Adding UI elements (fields, columns, actions, menus) |
widgets/injection-table.ts | Mapping widgets to target spots |
widgets/components.ts | Replacing/wrapping UI components |
api/interceptors.ts | Intercepting API routes |
data/guards.ts | Blocking/validating mutations |
subscribers/<name>.ts | Reacting to domain events |
yarn generate — registers new enrichers, widgets, interceptors, guardsyarn dev — verify extension appears in target module UI| Pitfall | Symptom | Fix |
|---|---|---|
Missing enrichMany | Slow list pages, N+1 queries | Implement batch enrichment with $in query |
| Wrong spot ID | Widget doesn't appear | Check exact spot ID format in target module |
Missing yarn generate | Extension not discovered | Run yarn generate after adding files |
| Hardcoded strings | i18n warnings | Use labelKey / i18n keys everywhere |
Missing features | Extension visible to all users | Add ACL features array |
onSave not idempotent | Duplicate records on retry | Use upsert pattern (check-then-create-or-update) |
sortable: true on enriched column | Sort doesn't work | Set sortable: false for enriched-only fields |
| Throw in interceptor | 500 error | Return { ok: false, message } instead |
| Missing injection-table entry | Widget exists but not rendered | Add mapping in injection-table.ts |
yarn generate after adding any extension fileenrichMany when creating Response Enrichers_<your-module> prefixonSave endpoints idempotent{ ok: false, message } pattern instead of throwing errors in interceptors/guardssortable: false on columns backed by enriched data onlyafter hooks for adding data to responses