Service Layer Skill
Patterns for implementing business logic and data access services with RLS integration.
Architecture Overview
core/lib/services/
├── base-entity.service.ts # Generic CRUD service base class
├── user.service.ts # User management
├── team.service.ts # Team CRUD and membership
├── team-member.service.ts # Team membership management
├── permission.service.ts # Permission checks (O(1))
├── membership.service.ts # Complete membership context
├── subscription.service.ts # Subscription lifecycle
├── plan.service.ts # Plan queries
├── usage.service.ts # Usage tracking and quotas
├── meta.service.ts # Flexible entity metadata
├── invoice.service.ts # Invoice management
├── theme.service.ts # Theme registry queries
├── entity-type.service.ts # Entity registry queries
└── ...
core/lib/db.ts # RLS query functions
📍 Context-Aware Paths: Paths shown assume monorepo development. In consumer projects, create in
contents/themes/{theme}/services/instead. Core is read-only. Seecore-theme-responsibilitiesskill for complete rules.
When to Use This Skill
- Implementing business logic services
- Creating data access layer for entities
- Working with RLS (Row Level Security)
- Implementing transactions
- Querying registries
Static Class Pattern
All services use static methods exclusively:
// core/lib/services/customer.service.ts
export class CustomerService {
// No constructor - all methods are static
// No instance state
static async getById(id: string, userId: string): Promise<Customer | null> {
// Implementation
}
static async list(userId: string, options?: ListOptions): Promise<ListResult<Customer>> {
// Implementation
}
static async create(userId: string, teamId: string, data: CreateCustomer): Promise<Customer> {
// Implementation
}
static async update(id: string, userId: string, data: UpdateCustomer): Promise<Customer> {
// Implementation
}
static async delete(id: string, userId: string): Promise<boolean> {
// Implementation
}
}
Usage (no instantiation):
import { CustomerService } from '@/core/lib/services/customer.service'
// Direct static method calls
const customer = await CustomerService.getById('cust-123', userId)
const customers = await CustomerService.list(userId, { limit: 10 })
const created = await CustomerService.create(userId, teamId, { name: 'Acme' })
Standard Method Signatures
Query Methods (Read Operations)
// Get single entity by ID
static async getById(
id: string,
userId: string
): Promise<Entity | null>
// List with pagination, filtering, ordering
static async list(
userId: string,
options?: ListOptions
): Promise<ListResult<Entity>>
// Simple query (wrapper over list)
static async query(
userId: string,
options?: QueryOptions
): Promise<Entity[]>
// Check existence
static async exists(
id: string,
userId: string
): Promise<boolean>
// Count with optional filtering
static async count(
userId: string,
where?: Record<string, unknown>
): Promise<number>
Mutation Methods (Write Operations)
// Create new entity
static async create(
userId: string,
teamId: string,
data: CreateEntity
): Promise<Entity>
// Update existing entity
static async update(
id: string,
userId: string,
data: UpdateEntity
): Promise<Entity>
// Delete entity
static async delete(
id: string,
userId: string
): Promise<boolean>
ListOptions Interface
interface ListOptions {
limit?: number // Pagination limit (default: 20)
offset?: number // Pagination offset
orderBy?: string // Sort field (default: 'createdAt')
orderDir?: 'asc' | 'desc' // Sort direction (default: 'desc')
where?: Record<string, unknown> // Filters
search?: string // Text search
}
interface ListResult<T> {
data: T[]
total: number
limit: number
offset: number
}
RLS Integration
Core RLS Functions
// core/lib/db.ts
// Query with RLS context (SELECT)
export async function queryWithRLS<T>(
query: string,
params: unknown[] = [],
userId?: string | null
): Promise<T[]>
// Query single result with RLS
export async function queryOneWithRLS<T>(
query: string,
params: unknown[] = [],
userId?: string | null
): Promise<T | null>
// Mutation with RLS (INSERT, UPDATE, DELETE)
export async function mutateWithRLS<T>(
query: string,
params: unknown[] = [],
userId?: string | null
): Promise<{ rows: T[], rowCount: number }>
RLS Context:
- Functions set PostgreSQL
app.user_idsession variable - RLS policies use this to filter/restrict data access
- Always pass
userIdparameter for proper isolation
Service Implementation with RLS
import { queryOneWithRLS, queryWithRLS, mutateWithRLS } from '@/core/lib/db'
export class CustomerService {
static async getById(id: string, userId: string): Promise<Customer | null> {
// Input validation
if (!id?.trim()) throw new Error('Customer ID is required')
if (!userId?.trim()) throw new Error('User ID is required')
return queryOneWithRLS<Customer>(
'SELECT * FROM "customers" WHERE id = $1',
[id],
userId // Sets app.user_id for RLS
)
}
static async list(userId: string, options: ListOptions = {}): Promise<ListResult<Customer>> {
const { limit = 20, offset = 0, orderBy = 'createdAt', orderDir = 'desc' } = options
const data = await queryWithRLS<Customer>(
`SELECT * FROM "customers"
ORDER BY "${orderBy}" ${orderDir.toUpperCase()}
LIMIT $1 OFFSET $2`,
[limit, offset],
userId
)
const countResult = await queryOneWithRLS<{ count: number }>(
'SELECT COUNT(*) as count FROM "customers"',
[],
userId
)
return {
data,
total: countResult?.count ?? 0,
limit,
offset
}
}
static async create(userId: string, teamId: string, data: CreateCustomer): Promise<Customer> {
const id = generateId()
const now = new Date().toISOString()
const result = await mutateWithRLS<Customer>(
`INSERT INTO "customers" (id, "userId", "teamId", name, email, "createdAt", "updatedAt")
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *`,
[id, userId, teamId, data.name, data.email, now, now],
userId
)
return result.rows[0]
}
static async update(id: string, userId: string, data: UpdateCustomer): Promise<Customer> {
const setClauses: string[] = []
const params: unknown[] = []
let paramIndex = 1
if (data.name !== undefined) {
setClauses.push(`name = $${paramIndex++}`)
params.push(data.name)
}
if (data.email !== undefined) {
setClauses.push(`email = $${paramIndex++}`)
params.push(data.email)
}
setClauses.push(`"updatedAt" = $${paramIndex++}`)
params.push(new Date().toISOString())
params.push(id) // WHERE id = $N
const result = await mutateWithRLS<Customer>(
`UPDATE "customers"
SET ${setClauses.join(', ')}
WHERE id = $${paramIndex}
RETURNING *`,
params,
userId
)
if (result.rowCount === 0) {
throw new Error('Customer not found')
}
return result.rows[0]
}
static async delete(id: string, userId: string): Promise<boolean> {
const result = await mutateWithRLS(
'DELETE FROM "customers" WHERE id = $1',
[id],
userId
)
return result.rowCount > 0
}
}
Transaction Pattern
import { getTransactionClient } from '@/core/lib/db'
export class TeamService {
static async createWithOwner(
userId: string,
teamData: CreateTeam
): Promise<{ team: Team; membership: TeamMember }> {
const tx = await getTransactionClient(userId)
try {
// Create team
const team = await tx.queryOne<Team>(
`INSERT INTO "teams" (id, name, slug, "createdAt")
VALUES ($1, $2, $3, $4)
RETURNING *`,
[generateId(), teamData.name, teamData.slug, new Date().toISOString()]
)
// Add owner membership
const membership = await tx.queryOne<TeamMember>(
`INSERT INTO "teamMembers" (id, "userId", "teamId", role, "createdAt")
VALUES ($1, $2, $3, $4, $5)
RETURNING *`,
[generateId(), userId, team.id, 'owner', new Date().toISOString()]
)
// Commit transaction
await tx.commit()
return { team, membership }
} catch (error) {
// Rollback on error
await tx.rollback()
throw error
}
}
}
BaseEntityService
Generic base class for entity CRUD operations:
// core/lib/services/base-entity.service.ts
export interface EntityServiceConfig {
tableName: string // e.g., 'customers'
fields: string[] // Custom fields (excluding system fields)
searchableFields?: string[] // Fields for text search
defaultOrderBy?: string // Default: 'createdAt'
defaultOrderDir?: 'asc' | 'desc' // Default: 'desc'
defaultLimit?: number // Default: 20
}
export class BaseEntityService<TEntity, TCreate, TUpdate> {
protected config: EntityServiceConfig
constructor(config: EntityServiceConfig) {
this.config = {
defaultOrderBy: 'createdAt',
defaultOrderDir: 'desc',
defaultLimit: 20,
...config
}
}
async getById(id: string, userId: string): Promise<TEntity | null> { ... }
async list(userId: string, options?: ListOptions): Promise<ListResult<TEntity>> { ... }
async create(userId: string, teamId: string, data: TCreate): Promise<TEntity> { ... }
async update(id: string, userId: string, data: TUpdate): Promise<TEntity> { ... }
async delete(id: string, userId: string): Promise<boolean> { ... }
}
Extending BaseEntityService:
class ProductsService extends BaseEntityService<Product, CreateProduct, UpdateProduct> {
constructor() {
super({
tableName: 'products',
fields: ['title', 'description', 'status', 'price'],
searchableFields: ['title', 'description'],
})
}
// Custom methods
async getByStatus(userId: string, status: string): Promise<Product[]> {
return this.query(userId, {
where: { status },
orderBy: 'createdAt'
})
}
}
// Usage
const service = new ProductsService()
const products = await service.list(userId, { search: 'laptop' })
Service Categories
Data Access Services
Use queryWithRLS and mutateWithRLS for database operations:
- UserService, TeamService, CustomerService
- SubscriptionService, InvoiceService
- Always accept
userIdparameter
Registry Services
Query pre-generated registries (O(1) lookups):
- ThemeService, EntityTypeService
- NamespaceService, ScopeService
- Synchronous operations, no RLS needed
// Registry service example
export class ThemeService {
static getAll(): ThemeConfig[] {
return Object.values(THEME_REGISTRY)
}
static getByName(name: string): ThemeConfig | undefined {
return THEME_REGISTRY[name]
}
}
Composite Services
Combine multiple services for complex operations:
- MembershipService (TeamMember + Subscription + Permissions)
- Returns rich objects with helper methods
Existing Services (24)
| Service | Category | Purpose |
|---|---|---|
UserService |
Data | User management, profile |
TeamService |
Data | Team CRUD, slug management |
TeamMemberService |
Data | Membership, role management |
MetaService |
Data | Flexible entity metadata |
PermissionService |
Registry | O(1) permission checks |
MembershipService |
Composite | Complete membership context |
SubscriptionService |
Data | Subscription lifecycle |
PlanService |
Registry | Plan queries |
UsageService |
Data | Usage tracking, quotas |
InvoiceService |
Data | Invoice management |
ThemeService |
Registry | Theme configuration |
EntityTypeService |
Registry | Entity registry queries |
NamespaceService |
Registry | Route namespaces |
ScopeService |
Registry | API scopes |
MiddlewareService |
Registry | Middleware execution |
RouteHandlerService |
Registry | Route handlers |
PluginService |
Registry | Plugin configuration |
ApiRoutesService |
Registry | API route discovery |
BlockService |
Registry | Page builder blocks |
TemplateService |
Registry | Template management |
TranslationService |
Registry | I18n translations |
UserFlagsService |
Data | Feature flags |
Error Handling
static async getById(id: string, userId: string): Promise<Entity | null> {
// Input validation
if (!id || id.trim() === '') {
throw new Error('Entity ID is required')
}
if (!userId || userId.trim() === '') {
throw new Error('User ID is required for authentication')
}
try {
return await queryOneWithRLS<Entity>(
'SELECT * FROM "entities" WHERE id = $1',
[id],
userId
)
} catch (error) {
console.error('EntityService.getById error:', error)
throw new Error(
error instanceof Error ? error.message : 'Failed to fetch entity'
)
}
}
JSDoc Documentation
/**
* Get entity by ID
*
* @param id - Entity UUID
* @param userId - Current user ID for RLS context
* @returns Entity object or null if not found
* @throws Error if ID or userId is missing
*
* @example
* const customer = await CustomerService.getById('cust-123', userId)
* if (customer) {
* console.log(customer.name)
* }
*/
static async getById(id: string, userId: string): Promise<Customer | null> {
// Implementation
}
Anti-Patterns
// NEVER: Instance methods
class BadService {
async getById(id: string, userId: string) {} // Not static!
}
const service = new BadService() // Requires instantiation
// CORRECT: Static methods
class GoodService {
static async getById(id: string, userId: string) {}
}
GoodService.getById('id', userId) // No instantiation
// NEVER: Skip RLS
const customers = await query('SELECT * FROM customers') // No RLS!
// CORRECT: Use RLS functions
const customers = await queryWithRLS('SELECT * FROM customers', [], userId)
// NEVER: Build SQL with string interpolation
const query = `SELECT * FROM customers WHERE name = '${name}'` // SQL injection!
// CORRECT: Use parameterized queries
const query = 'SELECT * FROM customers WHERE name = $1'
await queryWithRLS(query, [name], userId)
// NEVER: Skip input validation
static async delete(id: string, userId: string) {
await mutateWithRLS('DELETE FROM customers WHERE id = $1', [id], userId)
}
// CORRECT: Validate inputs
static async delete(id: string, userId: string) {
if (!id?.trim()) throw new Error('ID is required')
if (!userId?.trim()) throw new Error('User ID is required')
await mutateWithRLS('DELETE FROM customers WHERE id = $1', [id], userId)
}
// NEVER: Forget to pass userId for RLS
await queryWithRLS(query, params) // Missing userId!
// CORRECT: Always pass userId
await queryWithRLS(query, params, userId)
Checklist
Before finalizing service implementation:
- All methods are static
- Uses RLS functions (queryWithRLS, mutateWithRLS)
- userId parameter on all database operations
- Input validation at method start
- Parameterized queries (no string interpolation)
- Proper error handling with descriptive messages
- JSDoc documentation with @param, @returns, @example
- Transactions for multi-step operations
- Consistent method signatures
Related Skills
entity-system- Entity definition patternsentity-api- API endpoint patternspermissions-system- Permission checkingdatabase-migrations- Migration patterns