Skip to main content

Services API

Generated service classes provide business logic with permission checks, validation, transaction support, and versioning integration.

Table of Contents


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 ownerId injection 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 products
  • product: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:all see all products
  • Users with product:view:own only see their own products (auto-filtered by ownerId)
  • Users with neither permission get FORBIDDEN error

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 product
  • product: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:

  • ownerId injected 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 product
  • product: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)
  • updatedAt timestamp 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 product
  • product: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:

  1. Direct permissions - Granted directly to user or API key
  2. 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:own
  • product: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: false only when you trust the data source
  • preserveIds: true useful 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 secretKey for passwords (one-way)
  • Use encryptedKey for 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