Service Creation Workflow
This skill guides you through creating properly structured service functions following clean architecture patterns.
When to Use
- Creating new data access functions
- Adding business logic operations
- Implementing database queries
- Building reusable data operations
Service Location
All services go in: <services-dir>/[feature]-service.<ext>
Example structure:
<services-dir>/
├── portfolio-service.<ext>
├── transaction-service.<ext>
├── road-path-service.<ext>
└── auth-service.<ext>
Service Template
import { db } from "<db-client>";
import { tableName } from "<schema-file>";
import { eq, and, desc } from "<orm>";
import type { TypeName } from "<types-dir>";
// GET operations (may not find data)
export async function getResourceById(
id: string
): Promise<TypeName | null> {
const result = await db.query.tableName.findFirst({
where: eq(tableName.id, id),
});
return result || null;
}
// GET operations (always return data)
export async function getAllResources(
userId: string
): Promise<TypeName[]> {
const results = await db.query.tableName.findMany({
where: eq(tableName.userId, userId),
orderBy: [desc(tableName.createdAt)],
});
return results;
}
// CREATE operations
export async function createResource(
userId: string,
data: CreateResourceData
): Promise<TypeName> {
const [resource] = await db
.insert(tableName)
.values({
id: crypto.randomUUID(),
userId,
...data,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning();
return resource;
}
// UPDATE operations
export async function updateResource(
id: string,
userId: string,
data: UpdateResourceData
): Promise<TypeName> {
const [updated] = await db
.update(tableName)
.set({
...data,
updatedAt: new Date(),
})
.where(and(
eq(tableName.id, id),
eq(tableName.userId, userId) // Always verify ownership
))
.returning();
if (!updated) {
throw new Error("Resource not found or access denied");
}
return updated;
}
// DELETE operations
export async function deleteResource(
id: string,
userId: string
): Promise<void> {
await db
.delete(tableName)
.where(and(
eq(tableName.id, id),
eq(tableName.userId, userId) // Always verify ownership
));
}
Required Patterns
1. Explicit Return Types
Always define explicit return types:
// ✅ Good - explicit return type
export async function getUser(id: string): Promise<User | null> {
// ...
}
// ❌ Bad - inferred return type
export async function getUser(id: string) {
// ...
}
2. Return Type Conventions
- GET (nullable):
Promise<Type | null> - GET (array):
Promise<Type[]> - CREATE/UPDATE:
Promise<Type> - DELETE:
Promise<void> - With result status:
Promise<{ success: boolean; data?: Type }>
3. Import Types
Never define types inline. Always import from <types-dir>:
// ✅ Good - imported type
import type { Portfolio } from "<types-dir>";
export async function getPortfolio(): Promise<Portfolio | null> { }
// ❌ Bad - inline type
export async function getPortfolio(): Promise<{
id: string;
// ... many fields
}> { }
4. Handle Nullable Fields
Match ORM's nullable returns:
// In <types-dir>
export type RoadPath = {
id: string;
title: string;
description: string | null; // Nullable
startDate: Date | null; // Nullable
};
// In service
const path = await db.query.roadPaths.findFirst(...);
if (path?.startDate) {
const date = new Date(path.startDate);
}
5. Ownership Verification
Always verify user ownership:
export async function updateResource(
id: string,
userId: string, // ← Always require userId
data: UpdateData
): Promise<Resource> {
const [updated] = await db
.update(resources)
.set(data)
.where(and(
eq(resources.id, id),
eq(resources.userId, userId) // ← Verify ownership
))
.returning();
if (!updated) {
throw new Error("Resource not found or access denied");
}
return updated;
}
6. Use Transactions
For multi-table operations:
export async function createWithRelated(
userId: string,
data: CreateData
): Promise<Result> {
return await db.transaction(async (tx) => {
const [parent] = await tx
.insert(parents)
.values({ userId, ...data.parent })
.returning();
const children = await tx
.insert(children)
.values(
data.children.map(child => ({
parentId: parent.id,
...child,
}))
)
.returning();
return { parent, children };
});
}
Naming Conventions
Follow these naming patterns:
- Get single:
getResourceById,getResourceBySlug - Get multiple:
getAllResources,getResourcesByFilter - Create:
createResource - Update:
updateResource - Delete:
deleteResource - Utilities:
calculateTotal,formatResource,validateResource
Complete Example
// <services-dir>/portfolio-service.<ext>
import { db } from "<db-client>";
import { portfolios, transactions } from "<schema-file>";
import { eq, and, desc, sum, sql } from "<orm>";
import type { Portfolio, Transaction } from "<types-dir>";
import type { CreatePortfolioData, UpdatePortfolioData } from "<schemas-dir>/portfolio";
export async function getPortfolioByUserId(
userId: string
): Promise<Portfolio | null> {
const portfolio = await db.query.portfolios.findFirst({
where: eq(portfolios.userId, userId),
with: {
transactions: {
orderBy: [desc(transactions.date)],
limit: 10,
},
},
});
return portfolio || null;
}
export async function createPortfolio(
userId: string,
data: CreatePortfolioData
): Promise<Portfolio> {
const [portfolio] = await db
.insert(portfolios)
.values({
id: crypto.randomUUID(),
userId,
...data,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning();
return portfolio;
}
export async function calculatePortfolioValue(
portfolioId: string
): Promise<number> {
const result = await db
.select({
total: sum(transactions.amount),
})
.from(transactions)
.where(eq(transactions.portfolioId, portfolioId));
return Number(result[0]?.total || 0);
}
Checklist
Before completing a service:
- All functions have explicit return types
- Types imported from
<types-dir>and<schemas-dir> - Nullable database fields handled properly
- Ownership verified with userId checks
- Errors thrown for exceptional cases only
- Return null for "not found" (let caller handle)
- Transactions used for multi-table operations
- Naming conventions followed
- Utility functions added if needed
Acceptance Criteria
✅ Service file created in correct location ✅ All functions have explicit return types ✅ Types properly imported (not inline) ✅ Ownership verification in place ✅ Error handling implemented ✅ Transaction usage where needed ✅ Tests added (if required)
Project-Specific Placeholders
<services-dir>: Directory for service layer files<ext>: File extension (.ts, .js, etc.)<db-client>: Database client import path<schema-file>: Schema definitions import path<orm>: ORM library name<types-dir>: Shared types directory<schemas-dir>: Validation schemas directory
Common Mistakes to Avoid
- Inline types - Always import from
<types-dir> - Missing ownership checks - Always verify userId
- Inferred return types - Always be explicit
- Ignoring null - Handle nullable fields
- No transactions - Use for multi-table ops
- Poor naming - Follow conventions
