Business Objects
A Business Object (BO) is a managed entity that wraps a view with CRUD lifecycle, permission checks, and hooks. BOs are read-only by default — you must explicitly define actions to enable writes.
BOs are not exposed to HTTP directly. Any HTTP surface goes through a Projection — an explicit whitelist of actions and columns. BOs stay importable for custom action handlers, CLI scripts, and internal use; projections are the only thing wired to registerProjection in @pgbo/fastify.
Defining a BO
import { defineBO } from '@pgbo/core/bo'
const warehouseBO = defineBO(warehouseTable, {
paramField: 'slug',
actions: {
create: {},
update: {},
delete: {},
},
})With no actions, the BO is read-only:
const readOnlyBO = defineBO(warehouseTable, {})
readOnlyBO.isReadOnly // trueBO Name
bo.name defaults to the camelCase form of the root table/view name, matching pgbo's camelCase-everywhere convention on the query side. The SQL identifier (root.name) stays snake_case.
const bo = defineBO(stockJournalTable, { paramField: 'id' })
bo.name // 'stockJournal'
bo.root.name // 'stock_journal'Override with config.name when needed:
const bo = defineBO(stockJournalTable, {
name: 'stockJournalEntries',
paramField: 'id',
})List & Route Metadata
BOs carry list-level metadata so consuming apps don't need per-BO config duplication:
const areaBO = defineBO(areaTable, {
paramField: 'id',
orderBy: 'sortOrder',
orderDir: 'asc',
cacheTags: ['area', 'navigation'],
virtualFields: [
{ key: 'childCount', kind: 'number', label: 'crud.childCount' },
],
transformItems: async (rows, locale, db) => {
// Custom post-processing after enrichCompositions
return rows
},
})orderBy/orderDir— default sort for list queriescacheTags— cache invalidation tagsvirtualFields— fields populated bytransformItems, merged intoboMeta()outputtransformItems— batch transform that runs afterenrichCompositions
searchColumns and filterColumns are derived from the view's column annotations (.searchable(), .filterable()) — not from BO config. Read them from boMeta().fields.
Actions
Standard CRUD
BO methods are fully typed based on the root table/view:
create(db, ctx, data)—dataisInferInsert<typeof table>(required columns + optional defaults/nullables, plus composition keys)update(db, ctx, data)—datarequires theparamField+ any optional column updatesdelete(db, ctx, data)—dataonly needs theparamField- Return types are
InferRow<typeof table>so callers get typed results
// Create — name is required (notNull, no default); extra keys caught at compile time
const created = await warehouseBO.create(db, ctx, {
slug: 'new-wh', name: 'New Warehouse',
})
// Update (paramField identifies the record)
const updated = await warehouseBO.update(db, ctx, {
slug: 'new-wh', name: 'Renamed',
})
// Delete
await warehouseBO.delete(db, ctx, { slug: 'new-wh' })Permission Checks
const warehouseBO = defineBO(warehouseTable, {
paramField: 'slug',
actions: {
create: {
permission: (ctx) => ctx.role === 'admin', // return false to deny
},
update: {
permission: (ctx) => {
if (ctx.role !== 'admin') return 'Admin access required' // return string for error message
return true
},
},
},
})Before/After Hooks
const warehouseBO = defineBO(warehouseTable, {
paramField: 'slug',
actions: {
create: {
before: async (ctx, data) => {
// Validate or transform data before insert
if (data.slug.includes(' ')) return 'Slug cannot contain spaces'
},
after: async (ctx, result) => {
// Side effects after successful insert
console.log('Created:', result.slug)
},
},
},
})Custom Actions
const docBO = defineBO(docTable, {
paramField: 'id',
actions: {
reverse: {
permission: (ctx) => hasPermission(ctx, 'MANAGE_STOCK'),
handler: async (ctx, data) => {
// Custom logic — framework doesn't do standard CRUD
return await reverseDocument(data.id)
},
},
},
})
await docBO.execute(db, 'reverse', ctx, { id: 123 })Compositions
Compositions define deeply owned child entities. On create, the BO inserts the parent then the children:
const warehouseBO = defineBO(warehouseTable, {
paramField: 'slug',
actions: { create: {}, update: {}, delete: {} },
compositions: {
translations: {
table: warehouseTranslationTable,
parentKey: 'warehouseSlug', // FK column on the child
},
},
})
// Create parent + children in one call
await warehouseBO.create(db, ctx, {
slug: 'main',
name: 'Main Warehouse',
translations: [
{ locale: 'en', name: 'Main Warehouse' },
{ locale: 'de', name: 'Hauptlager' },
],
})On delete, PostgreSQL FK cascades handle child cleanup automatically.
Auto-enrichment on Read
Compositions are also batch-loaded on reads via enrichCompositions():
import { enrichCompositions } from '@pgbo/core/bo'
const items = await db.from(menuGroupView).execute()
const enriched = await enrichCompositions(db, menuGroupBO, items)
// enriched[0]:
// {
// id: 1, slug: 'nav',
// translations: [{ locale: 'en', name: 'Navigation' }, ...],
// pages: [{ menuGroupId: 1, pageSlug: 'home' }, ...],
// }The function:
- Collects parent IDs from
itemsusingbo.paramField - Runs one
WHERE parent_key = ANY($1)query per composition (in parallel) - Groups results and attaches as nested arrays
- Returns new objects — does not mutate the input
- Snake_case keys in child rows are converted to camelCase
Filtered compositions — cardinality, where, merge
Compositions default to returning every matching child as an array (cardinality: 'many'). Three options change this:
const areaBO = defineBO(areaTable, {
paramField: 'id',
compositions: {
// The one translation row matching the caller's locale
translation: {
table: areaTranslationTable,
parentKey: 'areaId',
cardinality: 'one',
where: { locale: '$locale' },
},
// Current contract — filter by validity window
currentContract: {
table: contractTable,
parentKey: 'customerId',
cardinality: 'one',
where: {
validFrom: { lte: '$now' },
validTo: { gte: '$now' },
},
},
// Primary address — literal filter, no placeholder
primaryAddress: {
table: addressTable,
parentKey: 'customerId',
cardinality: 'one',
where: { isPrimary: 'yes' },
},
},
})cardinality — 'many' (default, array) or 'one' (single object or null).
where — a WHERE clause applied to the composition query. Supports the same operators as db.where() (lte, gte, ilike, any, in, etc.) plus context placeholders:
| Placeholder | Resolved to |
|---|---|
$locale | ctx.locale |
$userId | ctx.userId |
$tenantId | ctx.tenantId |
$now | new Date() |
Pass ctx via enrichCompositions(db, bo, items, { ctx }). If a placeholder references ctx data that's missing, enrichment throws — failing loud beats silently returning unfiltered results.
merge — with cardinality: 'one', lifts the matched child's fields onto the parent instead of attaching a nested object. Common for translations:
compositions: {
translation: {
table: areaTranslationTable,
parentKey: 'areaId',
cardinality: 'one',
where: { locale: '$locale' },
merge: ['name', 'description'],
},
}
// Result: { id, slug, name, description } — no nested 'translation' objectMany-to-many via link tables
A third composition shape resolves parent → link table → target entity in two batched queries. Useful for M2M patterns like product↔warehouse assignments where you want each product row to carry the list of warehouses it's assigned to, with target compositions (translations) resolved for free.
const productBO = defineBO(productTable, {
paramField: 'wku',
compositions: {
warehouses: {
linkTable: productWarehouseTable, // junction table
linkParentKey: 'wku', // FK on link → parent
linkTargetKey: 'warehouseSlug', // FK on link → target
target: warehouseBO, // BO → its compositions run
columns: ['slug', 'name'], // narrow exposed target fields
},
activeWarehouses: {
linkTable: productWarehouseTable,
linkParentKey: 'wku',
linkTargetKey: 'warehouseSlug',
target: warehouseBO,
linkWhere: { archived: { isNull: true } }, // filter on the link rows
where: { active: true }, // filter on the target rows
},
},
})On read, enrichCompositions runs three batches per parent group:
SELECT linkParentKey, linkTargetKey FROM linkTable WHERE linkParentKey = ANY($1) [AND linkWhere]SELECT * FROM target WHERE target.paramField = ANY(targetKeys) [AND where]- If
targetis a BO, run its own compositions (translations, etc.)
Attached as an array under the composition name: product.warehouses: [{ slug, name }, ...] with locale-resolved names when the target is a translated BO.
Writes are not yet supported — replace/add/remove semantics for M2M payloads are a follow-up. Passing link data in bo.create() input is currently ignored.
Nested Sub-Children
Compositions can declare their own children for multi-level loading:
const roleBO = defineBO(roleView, {
paramField: 'id',
compositions: {
fragments: {
table: roleFragmentTable,
parentKey: 'roleId',
children: {
values: {
table: roleFragmentValueTable,
parentKey: 'roleFragmentId',
},
},
},
},
})
const enriched = await enrichCompositions(db, roleBO, items)
// enriched[0]:
// {
// id: 1, slug: 'admin',
// fragments: [
// { fragmentSlug: 'MANAGE_STOCK', values: [
// { fieldSlug: 'ACTION', value: '*' },
// { fieldSlug: 'WAREHOUSE', value: '*' },
// ]},
// ],
// }enrichCompositions recursively loads each children level. Sub-children use the child table's primary key to collect IDs for the next level.
Associations
Associations are references to other entities (the referenced entity has its own lifecycle). Declare them with the FK column and, optionally, a target entity so pgbo can batch-enrich parent rows on read.
const productBO = defineBO(productTable, {
paramField: 'sku',
actions: { create: {}, update: {}, delete: {} },
associations: {
// Metadata only — FK is known, no enrichment
warehouse: { foreignKey: 'warehouseSlug' },
},
})Auto-enrichment on read — merge + prefix
Lift fields from the target onto each parent row:
const pageBO = defineBO(pageTable, {
paramField: 'id',
associations: {
area: {
foreignKey: 'areaId',
target: areaBO, // BO → its compositions (e.g. translation) run
merge: ['name'], // pull the resolved area.name
prefix: 'area', // → parent.areaName
},
},
})
// Caller-locale translation resolves automatically when the target BO
// has a composition like { locale: '$locale', merge: ['name'] }:
await enrichAssociations(db, pageBO, pages, { ctx: { locale: 'de' } })
// → each page gets .areaName = 'Navigation DE' / 'Verwaltung' / ...Attach — attach + columns
Attach the target as a nested object, optionally narrowed to specific fields:
associations: {
owner: {
foreignKey: 'ownerId',
target: userBO,
attach: 'owner',
columns: ['id', 'email'], // → parent.owner = { id, email }
},
}If the FK is null, parent.<attach> is set to null. If columns is omitted, the full target row is attached.
Target types
target: ViewDef— simple FK lookup, no target compositions runtarget: BusinessObjectDef— target's compositions run after load (so$locale-filtered translations resolve). Root can be either a view or a table.
Behaviour inside registerProjection
registerProjection from @pgbo/fastify automatically calls enrichAssociations with the request ctx for both list and detail routes. If you're using the BO programmatically, call enrichAssociations(db, bo, items, { ctx }) explicitly.
Associations with neither merge nor attach are metadata-only — no target query happens.
Value Helps
Link dropdown sources to a BO. Each entry must be a view(...).vh({ key, display }) — any other shape throws at defineBO() time.
const warehouseValueHelp = view('warehouse_vh')
.from(warehouseTable)
.columns({ slug: col('slug'), name: col('name') })
.vh({ key: 'slug', display: 'name' })
const productBO = defineBO(productTable, {
paramField: 'sku',
actions: { create: {} },
valueHelps: {
warehouse: warehouseValueHelp,
},
})See the Value Help Views section of the schema reference for the full semantics (JOINs, translated labels, auth restrictions).
Projections
A projection is the HTTP surface layered on top of a BO. The BO holds the data model and write logic; the projection declares exactly what is reachable over HTTP — which actions are whitelisted, which columns are visible, and what root-level WHERE applies to every query.
import { defineBO, defineProjection } from '@pgbo/core/bo'
const areaBO = defineBO(areaTable, {
paramField: 'id',
actions: {
create: {}, update: {}, delete: {},
rebuildCache: { handler: async () => { /* ... */ } },
internalExport: { handler: async () => { /* ... */ } },
},
})
// Public read-only surface
const areaPublic = defineProjection(areaBO, {
name: 'areaPublic',
actions: { read: true },
columns: ['id', 'slug', 'name'], // internal columns hidden
})
// Admin surface — CRUD + one custom action, but NOT internalExport
const areaAdmin = defineProjection(areaBO, {
name: 'areaAdmin',
actions: { read: true, create: true, update: true, delete: true, rebuildCache: true },
})Explicit action whitelist
Only actions listed with true produce routes. Standard keys:
| Key | Routes registered |
|---|---|
read | GET {prefix}, GET {prefix}/:param, GET /bo/{name} |
create | POST {prefix} |
update | PUT {prefix}/:param |
delete | DELETE {prefix}/:param |
<custom> | POST /bo/{name}/{custom} |
Actions not mentioned in the whitelist produce no routes. Accidentally adding an action to the BO does not expose it — it must be explicitly whitelisted on every projection.
Column narrowing
When columns is set, responses and the metadata endpoint return only those fields. Fields absent from the projection are invisible to the consumer.
Root WHERE
where applies to every list, detail, update, and delete through the projection. Rows that don't match are invisible — detail returns 404, updates/deletes on out-of-scope rows also return 404.
const activeCustomer = defineProjection(customerBO, {
name: 'activeCustomer',
actions: { read: true, update: true },
where: { status: 'ACTIVE' },
})Registering the projection with Fastify
import { registerProjection } from '@pgbo/fastify'
registerProjection(app, db, {
projection: areaPublic, // → routes under /bo/areaPublic
view: areaView,
extractContext: req => ({ app, db, locale: 'en' }),
})
registerProjection(app, db, {
projection: areaAdmin, // → routes under /bo/areaAdmin
view: areaView,
extractContext: /* ... */,
})URL layout is locked to /bo/{projection.name} (issue #44) — no per-projection prefix knob. Multiple projections over the same BO coexist by picking different names. For API versioning or tenant prefixes, use Fastify's encapsulation: app.register(routes, { prefix: '/v1' }).
Validation
defineProjection throws at definition time if:
- A whitelisted action doesn't exist on the underlying BO
- A listed column doesn't exist on the BO's root table/view
Fail-fast prevents broken projections from shipping.