Skip to main content

Services API

Generated service classes provide business logic with four-layer access control, validation, transaction support, versioning, and search 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/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 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'
});

// 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: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.


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:

  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' });

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 fields
  • product: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 secretKey for passwords (one-way)
  • Use encryptedKey for 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