Services API
Generated service classes provide business logic with four-layer access control, validation, transaction support, versioning, and search integration.
Table of Contents
- Overview
- Service Architecture
- Standard CRUD Methods
- Four-Layer Access Control
- Permission Checking
- Advanced Features
- Versioning Integration
- Secret Key Handling
- Search Integration
- Metadata Methods
- Using Services
- Best Practices
Overview
Services sit between your HTTP routes and data repositories, providing:
- Permission enforcement - All methods check user permissions before operations
- Input validation - Automatic Zod schema validation
- Ownership management - Automatic
ownerIdinjection and scoping - Versioning integration - Automatic version snapshots (if enabled)
- Secret key handling - Automatic encryption/hashing for sensitive fields
- Metadata access - Entity and field metadata for dynamic UIs
Generated Services
For each entity in your blueprint, Radish generates:
.radish/lib/datalayer/services/
├── _base.service.ts # Shared logic for all services
├── product.service.ts # Product-specific service
├── user.service.ts # User-specific service
└── ...
Import Paths
// Using @generated alias (recommended)
import { ProductService } from '@generated/datalayer/services';
import { UserService } from '@generated/datalayer/services';
// Using relative path
import { ProductService } from '.radish/lib/datalayer/services/product.service';
Service Architecture
BaseService
All entity services extend BaseService, which provides:
- Permission resolution - Resolves permissions from roles and direct grants
- Owner scoping - Automatic filtering by
ownerId - Guard utilities -
requireSignedIn(),requireRole(),requireOwnership() - Metadata methods - Generic metadata access patterns
Entity Services
Each entity service includes:
export class ProductService extends BaseService {
private repo = new ProductRepo();
private entityName = 'product';
constructor(auth: ServiceAuth) {
super(auth);
}
// CRUD methods: list, get, create, update, delete, increment
// Archive methods: archive, restore, listArchived
// Metadata methods: getEntityMetadata, getFieldsMetadata
// Custom methods: getWithMetadata, listWithMetadata
}
Telemetry Instrumentation
All CRUD methods (list, get, create, update, remove, archive, restore, count, increment) are automatically instrumented via this.instrument(). Every operation reports entity, action, duration, and success/failure to the telemetry adapter. See Telemetry for details.
ServiceAuth Context
interface ServiceAuth {
principal?: Principal; // User or API key principal
principalType: 'user' | 'api' | 'anonymous';
apiKeyId?: string; // API key ID (if using API key)
userId?: string; // Current user's ID (if authenticated)
ownerId?: string; // Effective owner ID for queries
scopes: string[]; // API key scopes
roles: string[]; // User's roles (e.g., ['USER', 'ADMIN'])
permissions: string[]; // Direct permissions (e.g., from API keys)
correlationId?: string; // Propagated from inbound request for tracing
}
Example authentication contexts:
// Authenticated user
const userAuth: ServiceAuth = {
principal: { type: 'user', id: 'user-123', roles: ['USER'] },
principalType: 'user',
userId: 'user-123',
ownerId: 'user-123',
roles: ['USER'],
scopes: [],
permissions: []
};
// Admin user
const adminAuth: ServiceAuth = {
principal: { type: 'user', id: 'admin-456', roles: ['ADMIN'] },
principalType: 'user',
userId: 'admin-456',
ownerId: 'admin-456',
roles: ['ADMIN'],
scopes: [],
permissions: []
};
// API key with specific permissions
const apiKeyAuth: ServiceAuth = {
principal: {
type: 'api',
id: 'apikey-789',
userId: 'user-123',
roles: ['API'],
permissions: ['product:view:all', 'product:create']
},
principalType: 'api',
userId: 'user-123',
apiKeyId: 'apikey-789',
ownerId: 'user-123',
roles: ['API'],
scopes: [],
permissions: ['product:view:all', 'product:create']
};
Standard CRUD Methods
list(input)
List entities with criteria filtering, pagination, and permission scoping.
list(input: unknown): Promise<{ items: Product[], nextCursor?: string }>
Permissions Required:
product:view:all- View all productsproduct:view:own- View own products only
Example:
import { ProductService } from '@generated/datalayer/services';
const service = new ProductService(ctx);
// List with filters
const result = await service.list({
status: 'ACTIVE',
categoryId: 'cat-123',
limit: 25,
cursor: 'eyJpZCI6IjEyMyJ9'
});
// Pagination with skip/offset (aliases)
const page2 = await service.list({
limit: 25,
skip: 25 // or offset: 25 — both accepted
});
// String sort shorthand (prefix '-' for descending)
const sorted = await service.list({
sort: '-createdAt' // expands to { createdAt: -1 }
});
// Object sort still works
const multiSort = await service.list({
sort: { priority: -1, createdAt: 1 }
});
// Note: limit is capped at 200 to prevent unbounded queries
console.log(result.items); // Product[]
console.log(result.nextCursor); // Next page cursor (if more results)
Date Filtering:
All entities support date range filtering via dateFrom, dateTo, and dateMonth.
By default these filter on createdAt. Use dateField to target a different field.
// Date range on createdAt (default)
const recent = await service.list({
dateFrom: '2026-01-01',
dateTo: '2026-03-31'
});
// Date range on a custom field
const marchTransactions = await service.list({
dateFrom: '2026-03-01',
dateTo: '2026-03-31',
dateField: 'date'
});
// Month shorthand — dateMonth='2026-03' expands to Mar 1 <= createdAt < Apr 1
const marchItems = await service.list({
dateMonth: '2026-03'
});
// Month shorthand on a custom field
const marchPayments = await service.list({
dateMonth: '2026-03',
dateField: 'paymentDate'
});
// Exact date match on isoDate fields also works
// (ISO strings are automatically coerced to Date objects for MongoDB matching)
const onDate = await service.list({
date: '2026-03-15T00:00:00.000Z'
});
Permission Scoping:
- Users with
product:view:allsee all products - Users with
product:view:ownonly see their own products (auto-filtered byownerId) - Users with neither permission get
FORBIDDENerror
get(id, populate?)
Get a single entity by ID with optional reference population.
get(id: string, populate?: string | string[] | { paths: string[]; depth?: number }): Promise<Product | null>
Permissions Required:
product:view:all- View any productproduct:view:own- View own product only
Example:
// Get product by ID
const product = await service.get('prod-123');
// Get with populated references
const productWithAuthor = await service.get('prod-123', 'authorId');
// Get with nested population
const productWithDetails = await service.get('prod-123', {
paths: ['authorId.departmentId', 'categoryId'],
depth: 2
});
create(input)
Create a new entity with automatic ownership and validation.
create(input: unknown): Promise<Product>
Permissions Required:
product:create
Example:
const product = await service.create({
title: 'New Product',
description: 'Product description',
price: 29.99,
status: 'DRAFT'
// ownerId automatically added from auth context
});
console.log(product.id); // Generated MongoDB ObjectId
console.log(product.ownerId); // Set to ctx.ownerId
console.log(product.createdAt); // Auto-generated timestamp
Automatic Behavior:
ownerIdinjected from auth context- Timestamps (
createdAt,updatedAt) auto-generated - Default values resolved (including macros like
{{USER_ID}}) - Secret keys processed (hashed/encrypted)
- Initial version snapshot created (if versioning enabled)
update(id, input)
Update an existing entity with permission and ownership checks.
update(id: string, input: unknown): Promise<Product | null>
Permissions Required:
product:edit:all- Edit any productproduct:edit:own- Edit own product only
Example:
const updated = await service.update('prod-123', {
title: 'Updated Title',
price: 39.99
});
if (!updated) {
console.log('Product not found');
}
Automatic Behavior:
- Permission check (edit:all or edit:own)
- Ownership verification (if edit:own)
updatedAttimestamp updated- Version snapshot created (if versioning enabled)
- Field changes tracked (if field-level versioning enabled)
delete(id) / remove(id)
Delete an entity (hard delete).
delete(id: string): Promise<{ ok: boolean } | null>
remove(id: string): Promise<{ ok: boolean } | null>
Permissions Required:
product:delete:all- Delete any productproduct:delete:own- Delete own product only
Example:
const result = await service.delete('prod-123');
if (result?.ok) {
console.log('Product deleted');
} else {
console.log('Product not found');
}
Note: delete() and remove() are aliases - both perform the same operation.
Four-Layer Access Control
Every service method checks access in this order. The first match wins.
Layer 1: RBAC → role/permission checks (in-memory, no DB)
Layer 2: Ownership → ownerId field match (no DB)
Layer 3: Scoped → relationship-based access (1 DB lookup)
Layer 4: Grants → explicit per-record ACL (1 DB lookup)
Layer 1: RBAC
Standard role-based permissions. Checked first, in-memory, no DB cost.
RBAC is checked before requireSignedIn() — this enables the ANONYMOUS role
to grant public read access without authentication.
Layer 2: Ownership
For user-owned entities, ownerId is matched against the current user. No DB cost.
Layer 3: Scoped Permissions
Access via entity relationships. Two modes:
Through-entity: "You can view subscriptions for apps you own."
{ "scope": { "field": "appId", "through": "App", "ownerField": "ownerId" } }
Direct-match (tenancy): "You can view all records in your org."
{ "scope": { "field": "orgId", "matchUserField": "orgIds" } }
Layer 4: Grants
Explicit per-record access. Requires features.grants: true.
await new GrantService(auth).create({
entity: 'Conversation', entityId: id,
grantedTo: consumerId, grantedToType: 'consumer',
permissions: ['view', 'message']
});
Anonymous Access
The ANONYMOUS role is assigned to unauthenticated requests. Add permissions via
roles.json with isSystem: false to make entities publicly readable:
{ "ANONYMOUS": { "isSystem": false, "permissions": ["product:view:all"] } }
Then: radish-cli seed --sync-roles
Field-Level Access
Restrict specific fields by role:
{ "costPrice": { "type": "float", "access": { "read": ["ADMIN", "MANAGER"] } } }
Fields with access.read are excluded from responses for unauthorized roles.
ADMIN always sees all fields.
Permission Checking
Services use a sophisticated permission system with role-based and direct permissions.
Permission Format
Permissions follow the pattern: entity:action:scope
product:view:all # View all products
product:view:own # View own products only
product:create # Create products
product:edit:all # Edit any product
product:edit:own # Edit own products
product:delete:all # Delete any product
Permission Resolution
Permissions are resolved from:
- Direct permissions - Granted directly to user or API key
- Role-based permissions - Inherited from user's roles
// BaseService automatically resolves permissions
protected async getResolvedPermissions(): Promise<string[]>
protected async hasPermission(permission: string): Promise<boolean>
Wildcard Permissions
The permission system supports wildcards:
product:* # All product operations
*:view:all # View all entities
system:admin # System admin (full access)
Permission Checking in Services
// Check single permission
const canCreate = await this.hasPermission('product:create');
// Check with context (field-conditional permissions)
const canView = await this.hasPermissionWithContext(
'product:view:all',
{ status: 'PUBLISHED' }
);
// Build database query from permissions
const query = await this.buildPermissionQuery('product', 'view', 'all');
Advanced Features
Archive & Restore (Soft Delete)
Entities can be archived (soft deleted) instead of permanently removed.
// Archive entity
const archived = await service.archive('prod-123');
console.log(archived.archivedAt); // Timestamp when archived
// List archived entities
const archivedProducts = await service.listArchived({
status: 'DRAFT'
});
// Restore archived entity
const restored = await service.restore('prod-123');
console.log(restored.archivedAt); // null
Permissions Required:
product:archive:all/product:archive:ownproduct:restore:all/product:restore:own
Bulk Operations
Bulk create with validation for migrations and data imports.
bulkCreateWithValidation(
inputs: unknown[],
options?: {
validate?: boolean; // Apply Zod validation (default: true)
preserveIds?: boolean; // Keep existing _id fields (default: false)
onError?: 'throw' | 'collect'; // Error handling strategy
}
): Promise<{ inserted: Product[], errors: any[] }>
Example:
// Import with validation
const result = await service.bulkCreateWithValidation(
prodData,
{
validate: true,
onError: 'collect' // Continue on errors, collect them
}
);
console.log(`Inserted: ${result.inserted.length}`);
console.log(`Errors: ${result.errors.length}`);
result.errors.forEach(err => {
console.log(`Record ${err.index}: ${err.error}`);
});
Permissions Required:
system:admin- Bulk operations require admin permission
Important Notes:
- Bulk operations bypass versioning and audit logs for performance
- Use
validate: falseonly when you trust the data source preserveIds: trueuseful for cross-environment sync
Count
Get count of entities matching criteria.
count(input?: unknown): Promise<number>
Example:
// Count all products
const total = await service.count();
// Count with filter
const activeCount = await service.count({ status: 'ACTIVE' });
Atomic Increment
Atomically increment or decrement numeric fields using MongoDB's $inc operator.
No read-modify-write race conditions — safe for concurrent updates.
increment(id: string, fields: Record<string, number>): Promise<Entity | null>
Permissions Required:
product:edit:all- Increment any product's fieldsproduct:edit:own- Increment own product's fields only
Example:
// Increment a single field
await service.increment(reviewId, { helpfulCount: 1 });
// Increment multiple fields atomically
await service.increment(productId, { stock: -1, sold: 1 });
// Decrement with negative values
await service.increment(cartId, { itemCount: -1 });
API Endpoint:
PATCH /api/v1/reviews/abc123/increment
Content-Type: application/json
{ "helpfulCount": 1 }
Use cases:
- Vote/like counters (upvote, downvote, helpful)
- Inventory management (stock adjustments)
- View/download counters
- Rate limiting counters
- Any field where concurrent updates must not lose data
Note: Available on all service types (entity, system-entity, user, role,
apiKey, profile, setting). System entity services require system:admin
permission.
Versioning Integration
Services automatically integrate with versioning systems when enabled in blueprints.
Full Versioning
Creates complete snapshots of entity state on each change.
{
"Product": {
"versioning": {
"type": "full"
},
"fields": {
"title": "string"
}
}
}
Generated service behavior:
// On create - creates version 1 snapshot
const product = await service.create({ title: 'Product' });
// → Creates ProductVersion record with version=1, snapshot=full entity
// On update - creates new version snapshot
await service.update(product.id, { title: 'Updated' });
// → Creates ProductVersion record with version=2, changedFields=['title']
// On archive/restore - creates version snapshot
await service.archive(product.id);
// → Creates ProductVersion record with changeType='archive'
Simple Versioning (Audit Log)
Tracks changes without full snapshots.
{
"Product": {
"versioning": {
"type": "simple"
},
"fields": {
"title": "string"
}
}
}
Generated service behavior:
// On update - creates audit log with diffs
await service.update(product.id, { title: 'Updated' });
// → Creates ProductAuditLog with changedFields=['title'],
// changes={ title: { old: 'Product', new: 'Updated' } }
Field-Level Tracking
Tracks individual field changes with metadata.
{
"Product": {
"versioning": {
"type": "trackChanges",
"fields": ["title", "price", "status"]
}
}
}
Generated service behavior:
// On update - creates field change records
await service.update(product.id, { price: 39.99 });
// → Creates ProductFieldChange record for each tracked field:
// { fieldName: 'price', oldValue: 29.99, newValue: 39.99 }
Secret Key Handling
Services automatically handle sensitive fields with encryption or hashing.
Field Types
secretKey - One-way hashing (passwords, etc.)
{
"User": {
"fields": {
"password": {
"type": "secretKey",
"lastChars": 0,
"hashField": "passwordHash"
}
}
}
}
encryptedKey - Reversible encryption (API keys, tokens, etc.)
{
"Integration": {
"fields": {
"apiKey": {
"type": "encryptedKey",
"lastChars": 4,
"encryptedField": "apiKeyEncrypted",
"displayField": "apiKeyDisplay"
}
}
}
}
Automatic Processing
// Create user with password
const user = await userService.create({
email: 'user@example.com',
password: 'secret123' // Plain text input
});
// Password is automatically:
// 1. Hashed with bcrypt
// 2. Stored in passwordHash field
// 3. Removed from response (security)
console.log(user.password); // undefined (removed)
console.log(user.passwordHash); // $2b$10$... (bcrypt hash)
Decrypting Keys
For encryptedKey fields, services provide decryption methods:
// Get decrypted API key
const apiKey = await integrationService.getDecryptedKey(
'integration-123',
'apiKey'
);
console.log(apiKey); // Original plain text value
Security Notes
- Encryption key from
process.env.RADISH_ENCRYPTION_KEY(configure in production!) - Secret fields never returned in API responses
- Display fields show last N characters for identification
- Use
secretKeyfor passwords (one-way) - Use
encryptedKeyfor API keys (need to retrieve)
Search Integration
Entities with search.enabled: true in the types blueprint get automatic search
index sync. After create/update/delete, the service syncs to the search engine.
Search Config
{
"Product": {
"search": {
"enabled": true,
"engine": "typesense",
"indexName": "products",
"fields": {
"searchable": ["name", "description"],
"filterable": ["brand", "price"],
"sortable": ["price", "createdAt"],
"facetable": ["brand"]
},
"sync": "inline"
}
}
}
Using the Search Index
import { ProductSearchIndex, createSearchAdapter } from '@generated/search';
const adapter = createSearchAdapter('typesense');
const index = new ProductSearchIndex(adapter);
const results = await index.search({
q: 'organic',
filters: { brand: 'FairTrade' },
facets: ['brand'],
limit: 20
});
// returns { hits, totalHits, facets, page, totalPages, processingTimeMs }
CLI Commands
radish-cli search status # Check engine health and index status
radish-cli search reindex # Rebuild indexes from MongoDB
radish-cli search reindex --entity Product # Reindex specific entity
Metadata Methods
Services provide metadata access for building dynamic UIs.
getEntityMetadata()
Get entity-level metadata (label, description, ownership, etc.)
const metadata = await service.getEntityMetadata();
console.log(metadata.label); // "Product"
console.log(metadata.plural); // "products"
console.log(metadata.owned); // true
console.log(metadata.timestamps); // true
getFieldsMetadata()
Get all field metadata for the entity.
const fields = await service.getFieldsMetadata();
console.log(fields.title);
// {
// name: 'title',
// type: 'string',
// required: true,
// label: 'Title',
// search: true,
// ...
// }
getFieldMetadata(fieldName)
Get metadata for a specific field.
const titleMeta = await service.getFieldMetadata('title');
console.log(titleMeta.type); // 'string'
console.log(titleMeta.required); // true
console.log(titleMeta.maxLength); // 200
Combined Data + Metadata
Convenience methods return data with metadata:
// Get single entity with metadata
const result = await service.getWithAllMetadata('prod-123');
console.log(result.data); // Product entity
console.log(result.metadata.entity); // Entity metadata
console.log(result.metadata.fields); // Fields metadata
// List entities with metadata
const listResult = await service.listWithMetadata({ limit: 10 });
console.log(listResult.items); // { items: Product[], nextCursor?: string }
console.log(listResult.metadata); // { entity, fields }
Use Cases:
- Dynamic form generation
- Admin UI table generation
- Client-side validation
- API documentation
Using Services
In HTTP Routes
Routes should instantiate services with auth context:
// SvelteKit example
import { ProductService, createServiceAuthFromLocals } from '@generated/datalayer/services';
import type { RequestEvent } from '@sveltejs/kit';
export async function GET({ locals }: RequestEvent) {
// Convert LocalsAuth to ServiceAuth for services
const auth = createServiceAuthFromLocals(locals.auth);
const service = new ProductService(auth);
const products = await service.list({ limit: 25 });
return { products };
}
In Business Logic
Services can call other services:
export class OrderService extends BaseService {
async createOrder(input: unknown) {
const order = await this.create(input);
// Atomically decrement inventory (no race conditions)
const productService = new ProductService(this.auth);
await productService.increment(order.productId, {
stock: -order.quantity,
sold: order.quantity
});
return order;
}
}
With Admin Override
Admins can create entities with explicit ownerId:
// Regular users - ownerId from auth context
const product1 = await service.create({
title: 'My Product'
// ownerId automatically set to ctx.userId
});
// Admins - specify ownerId explicitly
const product2 = await service.createWithOwner(
{ title: 'Product for User' },
'other-user-id'
);
System Auth (No User Context)
For webhooks, background jobs, cron tasks, and seed scripts:
import { ProductService, systemAuth } from '@generated/datalayer/services';
// System-level access — bypasses permission checks
const svc = new ProductService(systemAuth());
await svc.create({ name: 'Seeded Product' });
// With user association for audit trail
const svc2 = new OrderService(systemAuth(webhookUserId));
await svc2.update(orderId, { status: 'fulfilled' });
Use systemAuth() instead of creating custom auth workarounds. It's the
standard Radish pattern for server-side operations without a user session.
Best Practices
1. Always Use Services, Not Repos Directly
❌ Don't:
// Bypasses permissions and validation!
const repo = new ProductRepo();
const products = await repo.list({});
✅ Do:
// Enforces permissions and validation
const service = new ProductService(ctx);
const products = await service.list({});
2. Let Services Handle Ownership
❌ Don't:
// Manual ownership injection
const product = await service.create({
title: 'Product',
ownerId: ctx.userId // Don't do this!
});
✅ Do:
// Service automatically adds ownerId
const product = await service.create({
title: 'Product'
// ownerId added automatically
});
3. Trust Permission Scoping
❌ Don't:
// Manual filtering by ownership
const products = await service.list({ ownerId: ctx.userId });
✅ Do:
// Service automatically scopes by permission
const products = await service.list({});
// Users with view:own automatically filtered by ownerId
// Users with view:all see everything
4. Use Metadata for Dynamic UIs
❌ Don't:
// Hardcoded field definitions
const fields = [
{ name: 'title', type: 'text', required: true },
{ name: 'price', type: 'number', required: true }
];
✅ Do:
// Dynamic from metadata
const fields = await service.getFieldsMetadata();
// Automatically includes all fields with types, validation, labels
5. Handle Errors Appropriately
import { isAppError, ErrorCodes } from '@generated/datalayer/core/util/errors';
try {
const product = await service.create(input);
return { success: true, data: product };
} catch (error) {
if (isAppError(error)) {
if (error.code === ErrorCodes.FORBIDDEN) {
return { success: false, error: 'Permission denied' };
}
if (error.code === ErrorCodes.VALIDATION_ERROR) {
return { success: false, errors: error.details };
}
}
throw error; // Re-throw unknown errors
}
6. Use Transactions for Multi-Step Operations
// For operations that need atomicity, use repo transactions
import { getConnection } from '@generated/datalayer/core/db';
const session = await getConnection().startSession();
session.startTransaction();
try {
const order = await orderRepo.create(orderData, { session });
await productRepo.update(productId, { inventory: -1 }, { session });
await session.commitTransaction();
} catch (error) {
await session.abortTransaction();
throw error;
} finally {
session.endSession();
}
Next Steps
- Repositories - Data access layer and MongoDB integration
- Contracts - Zod schemas and TypeScript types
- Routes - HTTP API endpoints and response formats
- Access Control - Permissions and authorization