Patterns
Patterns every service and interface follows.
Adapter rule
MCP tool handlers, REST handlers, CLI scripts, and webhook handlers are adapters — they translate inputs, call a service method, and return the result. No business logic in them.
// ✅ correct
server.registerTool('add_block_to_page', schema,
async (input) => PageService.addBlock({
pageId: input.page_id,
block: input.block,
position: input.position,
tenantId: input.tenant_id,
})
)
// ❌ wrong — same logic will end up duplicated in REST and CLI
server.registerTool('add_block_to_page', schema,
async (input) => {
const payload = await getPayload({ config })
const page = await payload.findByID({ collection: 'pages', id: input.page_id })
// ...
}
)Tenant scoping
The service layer takes tenantId as an explicit argument. It does not resolve “which tenants can this user access?” — that belongs to the Payload RBAC layer.
// ✅
await PageService.addBlock({ pageId, block, tenantId, user })
// ❌ — service should not resolve tenant from user
await PageService.addBlock({ pageId, block, user }) // user.tenants[0]?Local API access control
When calling Payload Local API with a user, always set overrideAccess: false:
// ✅ enforces user permissions
await payload.find({ collection: 'pages', user, overrideAccess: false })
// ❌ access control bypassed — runs with admin privileges
await payload.find({ collection: 'pages', user })For intentional admin-level operations (scripts, seed data), omit user entirely — overrideAccess defaults to true with no user.
Draft / publish
Agents create content as drafts unless explicitly told to publish:
// Safe default for agent-generated content
await payload.create({
collection: 'pages',
data: { title: 'Doctor Landing Page', layout: [...] },
draft: true,
})
// Explicit publish
await payload.update({
collection: 'pages',
id: pageId,
data: { _status: 'published' },
})Context flags in hooks
When a service performs an operation that should skip normal hook side-effects (e.g. an agent batch update that shouldn’t fire individual notifications):
await payload.update({
collection: 'pages',
id: pageId,
data: { ... },
context: { agentAction: true, skipNotification: true },
req,
})
// In the hook
afterChange: [
async ({ doc, req, context }) => {
if (context.skipNotification) return
await sendNotification(doc)
}
]Transaction safety in hooks
Always pass req to nested Payload operations inside hooks. Missing req breaks atomicity:
// ✅ atomic — same transaction
afterChange: [async ({ doc, req }) => {
await req.payload.create({ collection: 'audit-log', data: { docId: doc.id }, req })
}]
// ❌ data corruption risk — separate transaction
afterChange: [async ({ doc, req }) => {
await req.payload.create({ collection: 'audit-log', data: { docId: doc.id } })
// ↑ missing req
}]Hook loop prevention
Operations inside hooks can re-trigger the same hook. Use a context flag:
afterChange: [
async ({ doc, req, context }) => {
if (context.skipHooks) return
await req.payload.update({
collection: 'posts',
id: doc.id,
data: { processedAt: new Date() },
context: { skipHooks: true },
req,
})
}
]Two operating modes
Human-on-demand (Claude Code / Cursor)
"Build me a landing page for an AI image generator that sells credit packs"
"Add a pricing section using our existing Stripe products"
"Create a new tenant for client Acme with their domain and branding"Autonomous agent (scheduled / event-driven)
Every Monday → generate + publish weekly blog post from trending topics
On lead signup → update lead status, trigger welcome email sequence
On Stripe event → price changed? update pricing block copy automatically
Nightly → check for expired promos, flag or auto-fixBoth modes call the same service layer. The scheduling/trigger infrastructure (cron, webhooks, MCP) is just an adapter on top.