Agent skill
service-layer
Service layer patterns for this Next.js application. Covers static class pattern, RLS integration, standard method signatures, and BaseEntityService. Use this skill when implementing business logic or data access services.
Install this agent skill to your Project
npx add-skill https://github.com/majiayu000/claude-skill-registry/tree/main/skills/other/other/service-layer
SKILL.md
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
Recommended Agent Skills
Expand your agent's capabilities with these related and highly-rated skills.
agent-ops-spec
Manage specification documents in .agent/specs/. Use when user provides requirements, acceptance criteria, or feature descriptions that need to be tracked and validated against implementation.
agent-ops-state
Maintain .agent state files. Use at session start, after meaningful steps, and before concluding: read/update constitution/memory/focus/issues/baseline consistently.
agent-ops-spec
Manage specification documents in .agent/specs/. Use when user provides requirements, acceptance criteria, or feature descriptions that need to be tracked and validated against implementation.
agent-ops-testing
Test strategy, execution, and coverage analysis. Use when designing tests, running test suites, or analyzing test results beyond baseline checks.
agent-ops-testing
Test strategy, execution, and coverage analysis. Use when designing tests, running test suites, or analyzing test results beyond baseline checks.
agent-ops-state
Maintain .agent state files. Use at session start, after meaningful steps, and before concluding: read/update constitution/memory/focus/issues/baseline consistently.
Didn't find tool you were looking for?