Services API
Generated service classes provide business logic with permission checks, validation, transaction support, and versioning integration.
Table of Contents
- Overview
- Service Architecture
- Standard CRUD Methods
- Permission Checking
- Advanced Features
- Versioning Integration
- Secret Key Handling
- 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/server/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/server/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
// Archive methods: archive, restore, listArchived
// Metadata methods: getEntityMetadata, getFieldsMetadata
// Custom methods: getWithMetadata, listWithMetadata
}
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)
}
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'
});
console.log(result.items); // Product[]
console.log(result.nextCursor); // Next page cursor (if more results)
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.
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' });
Versioning Integration
Services automatically integrate with versioning systems when enabled in blueprints.
Full Versioning
Creates complete snapshots of entity state on each change.
# Blueprint
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.
# Blueprint
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.
# Blueprint
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)
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);
// Use ProductService to update inventory
const productService = new ProductService(this.auth);
await productService.update(order.productId, {
inventory: { $inc: -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'
);
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/server/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/server/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