.claude/skills/service-layer/SKILL.md
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.
npx skillsauth add NextSpark-js/nextspark service-layerInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Patterns for implementing business logic and data access services with RLS integration.
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.
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' })
// 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>
// 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>
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
}
// 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:
app.user_id session variableuserId parameter for proper isolationimport { 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
}
}
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
}
}
}
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' })
Use queryWithRLS and mutateWithRLS for database operations:
userId parameterQuery pre-generated registries (O(1) lookups):
// Registry service example
export class ThemeService {
static getAll(): ThemeConfig[] {
return Object.values(THEME_REGISTRY)
}
static getByName(name: string): ThemeConfig | undefined {
return THEME_REGISTRY[name]
}
}
Combine multiple services for complex operations:
| 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 |
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'
)
}
}
/**
* 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
}
// 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)
Before finalizing service implementation:
entity-system - Entity definition patternsentity-api - API endpoint patternspermissions-system - Permission checkingdatabase-migrations - Migration patternsdevelopment
Zod validation patterns for this Next.js application. Covers schema definition, API validation, form integration, error formatting, and type inference. Use this skill when implementing validation for APIs, forms, or entity schemas.
development
Review UI code for Web Interface Guidelines compliance. Use when asked to "review my UI", "check accessibility", "audit design", "review UX", or "check my site against best practices".
testing
Test coverage metrics and registry system for this Next.js application. Covers FEATURE_REGISTRY, FLOW_REGISTRY, TAGS_REGISTRY, and coverage metrics interpretation. Use this skill when evaluating test coverage, identifying gaps, or planning testing priorities.
development
TanStack Query (React Query) patterns for data fetching in this Next.js application. Covers useQuery, useMutation, optimistic updates, cache invalidation, and anti-patterns. Use this skill when implementing data fetching or state management with server data.