Analyze and refactor backend code to follow entity/service separation patterns. Use when asked to "refactor X to entity pattern", "analyze architecture for X", "extract business rules for X", "separate concerns for X", "create entity for X", or "clean up X service". Triggers on domain concepts like deals, leads, applications, locations, franchisees, etc.
This skill inherits all available tools. When active, it can use any tool Claude has access to.
templates.mdAnalyze backend code for a domain concept and refactor to follow the entity/service separation pattern.
Controller (HTTP only)
↓
Service (orchestration, enforcement, side effects)
↓
Entity (business rules as predicates, data access)
↓
Database
| Layer | Does | Does Not |
|---|---|---|
| Controller | Parse request, auth, permissions, call service, format response | Business logic, call entities, transactions |
| Service | Orchestrate entities, enforce rules (throw), business logic, transactions, trigger side effects | Define rules, write SQL, know HTTP |
| Entity | Define rules (canX → boolean), CRUD, queries, data transformation | Throw on rules, call other entities, side effects |
ENTITY (defines rules):
canConvert(deal) → { allowed: false, reason: 'Already converted' }
SERVICE (enforces rules):
const { allowed, reason } = dealEntity.canConvert(deal);
if (!allowed) throw new ValidationError(reason); ← SERVICE THROWS
When user asks to refactor a concept (e.g., "deals"), locate:
# Find relevant files
find . -name "*deal*" -type f | grep -E "\.(ts|js)$"
Look for:
{concept}Service.ts (e.g., dealsService.ts){concept}Controller.ts (e.g., dealsController.ts){concept}Entity.ts if it exists@fsai/sdk or local .types.ts filesReport what you found:
📁 Found files for "deals":
• api/deals/dealsService.ts (523 lines)
• api/deals/dealsController.ts (412 lines)
• contact/deal/dealEntity.ts (89 lines) - partial implementation
• Types: @fsai/sdk Deal, DealOverview, DealSummary
Scan the service file and categorize code:
🔴 SCATTERED BUSINESS RULES (should be entity predicates)
// These patterns should become entity predicates:
if (deal.convertedAt) throw new ValidationError('...') → canConvert()
if (!deal.applicationId) throw new ValidationError('...') → canConvert()
if (deal.status === 'won') ... → canDelete(), isWon()
if (!deal.applicationId && deal.franchiseeOrgId) ... → isEntityDeal()
🟡 DATA ACCESS (should move to entity)
// Direct database calls should move to entity:
await database.query.deals.findFirst(...) → entity.getById()
await database.insert(drizzleSchema.deals).values(...) → entity.create()
await database.update(drizzleSchema.deals).set(...) → entity.update()
🟠 SIDE EFFECTS (should be extracted to notification/event classes)
// These should be separate:
await notificationsService.franchisees.sendBatch(...) → dealNotifications.onConverted()
await portalEventsService.fireEvent(...) → dealEvents.emitConverted()
logger.info('Business event...') → dealEvents.emit*()
🟢 ORCHESTRATION (correct location - stays in service)
// This is correct for service:
await database.transaction(async () => { ... })
const result = await entityA.create(); await entityB.update();
if (featureFlag) { ... } else { ... }
Report findings:
🔍 Analysis of dealsService.ts:
BUSINESS RULES (scattered - move to entity):
• Line 340: if (deal.convertedAt) throw → canConvert()
• Line 336: if (!deal.applicationId) throw → canConvert()
• Line 89: implicit - deals require applicationId → document as rule
DATA ACCESS (move to entity):
• getDealOverview() - 80 lines of joins
• createDeal() - insert with displayId generation
• updateDeal() - direct update
SIDE EFFECTS (extract):
• Line 412: notification batch → dealNotifications.onConverted()
• Line 380: invitation service → keep in service but after transaction
ORCHESTRATION (correct - keep in service):
• convertDealToFranchisee() - coordinates multiple entities
• Transaction at line 350
Based on analysis, propose the entity structure:
// Proposed: dealEntity.ts
class DealEntity {
// ═══════════ Business Rules (Predicates) ═══════════
// Return boolean or { allowed, reason } - NEVER throw
isEntityDeal(deal): boolean;
isApplicationDeal(deal): boolean;
canConvert(deal): { allowed: boolean; reason?: string };
canHaveProposedLocations(deal): boolean;
canHaveAdditionalApplications(deal): boolean;
canDelete(deal): { allowed: boolean; reason?: string };
// ═══════════ Data Access ═══════════
getById(dealId): Promise<Deal | null>;
getOverview(dealId): Promise<DealOverview | null>;
getByApplication(applicationId): Promise<string | null>;
getByBrand(brandId): Promise<DealSummary[]>;
create(params): Promise<string>;
update(dealId, updates): Promise<void>;
markConverted(dealId, franchiseeOrgId): Promise<void>;
delete(dealId): Promise<void>;
}
Ask user to confirm before proceeding with implementation.
Create or update the entity file following this pattern:
import { eq, and, desc } from "drizzle-orm";
import { drizzleSchema } from "@fsai/supabase";
import { database } from "../../db/db.js";
import type { Deal, DealOverview } from "@fsai/sdk";
class DealEntity {
// ═══════════════════════════════════════════════════════════════
// BUSINESS RULES (Predicates)
// - Return boolean or { allowed, reason }
// - NEVER throw
// - No side effects
// - Testable in isolation
// ═══════════════════════════════════════════════════════════════
isEntityDeal(
deal: Pick<DealOverview, "applicationId" | "franchiseeOrgId">
): boolean {
return !deal.applicationId && Boolean(deal.franchiseeOrgId);
}
isApplicationDeal(deal: Pick<DealOverview, "applicationId">): boolean {
return Boolean(deal.applicationId);
}
canConvert(
deal: Pick<
DealOverview,
"applicationId" | "convertedAt" | "franchiseeOrgId"
>
): { allowed: boolean; reason?: string } {
if (deal.convertedAt) {
return { allowed: false, reason: "Deal has already been converted" };
}
if (this.isEntityDeal(deal)) {
return {
allowed: false,
reason: "Entity-based deals cannot be converted",
};
}
if (!deal.applicationId) {
return { allowed: false, reason: "Deal has no application to convert" };
}
return { allowed: true };
}
canHaveProposedLocations(
deal: Pick<DealOverview, "applicationId" | "franchiseeOrgId">
): boolean {
return this.isApplicationDeal(deal);
}
canDelete(deal: Pick<DealOverview, "convertedAt" | "status">): {
allowed: boolean;
reason?: string;
} {
if (deal.convertedAt) {
return { allowed: false, reason: "Cannot delete converted deals" };
}
return { allowed: true };
}
// ═══════════════════════════════════════════════════════════════
// DATA ACCESS
// - Encapsulate all database operations
// - Handle joins and transformations
// - Return null for not found (don't throw usually)
// ═══════════════════════════════════════════════════════════════
async getById(dealId: string): Promise<Deal | null> {
const data = await database.query.deals.findFirst({
where: eq(drizzleSchema.deals.id, dealId),
});
return data ?? null;
}
async getOverview(dealId: string): Promise<DealOverview | null> {
// Complex query with joins, transformed to domain shape
const data = await database.query.deals.findFirst({
where: eq(drizzleSchema.deals.id, dealId),
with: {
dealsAgreementsFees: { with: { agreementFee: true } },
dealsProposedLocations: true,
territories: true,
},
});
if (!data) return null;
return this.mapToOverview(data);
}
async create(params: {
applicationId?: string;
franchiseeOrgId?: string;
brandId: string;
}): Promise<string> {
const displayId = await this.getNextDisplayId(params.brandId);
const [data] = await database
.insert(drizzleSchema.deals)
.values({
displayId,
...params,
leadApplicationOwnershipPercentage: params.applicationId ? 100 : null,
})
.returning({ id: drizzleSchema.deals.id });
return data.id;
}
async update(dealId: string, updates: Partial<Deal>): Promise<void> {
await database
.update(drizzleSchema.deals)
.set(updates)
.where(eq(drizzleSchema.deals.id, dealId));
}
async markConverted(dealId: string, franchiseeOrgId: string): Promise<void> {
await database
.update(drizzleSchema.deals)
.set({
convertedAt: new Date().toISOString(),
franchiseeOrgId,
status: "won",
})
.where(eq(drizzleSchema.deals.id, dealId));
}
// ═══════════════════════════════════════════════════════════════
// PRIVATE HELPERS
// ═══════════════════════════════════════════════════════════════
private async getNextDisplayId(brandId: string): Promise<number> {
const prev = await database.query.deals.findFirst({
where: eq(drizzleSchema.deals.brandId, brandId),
orderBy: desc(drizzleSchema.deals.displayId),
columns: { displayId: true },
});
return (prev?.displayId ?? 0) + 1;
}
private mapToOverview(data: any): DealOverview {
// Transform DB shape → domain shape
return { ...data /* transformed */ };
}
}
export const dealEntity = new DealEntity();
Transform service methods to follow this pattern:
class DealsService {
async convertDealToFranchisee(
dealId: string,
sendInvitation: boolean,
userId: string
): Promise<string> {
// 1. FETCH via entity
const deal = await dealEntity.getOverview(dealId);
if (!deal) throw new NotFoundError('Deal not found');
// 2. ENFORCE rules (entity defines, service enforces)
const { allowed, reason } = dealEntity.canConvert(deal);
if (!allowed) throw new ValidationError(reason);
// 3. BUSINESS LOGIC (feature flags, conditional behavior)
const portalEnabled = await flagsService.isFranchiseePortalEnabled(deal.brandId);
const shouldInvite = sendInvitation && portalEnabled;
// 4. ORCHESTRATE (transaction wraps multiple entity calls)
const franchiseeOrgId = await database.transaction(async () => {
const orgId = await franchiseeOrgEntity.create({ ... });
await locationEntity.createFromProposed(deal.proposedLocations, orgId);
await dealEntity.markConverted(dealId, orgId);
return orgId;
});
// 5. SIDE EFFECTS (after transaction succeeds)
await dealNotifications.onConverted(deal, franchiseeOrgId);
await dealEvents.emitConverted(deal, franchiseeOrgId);
if (shouldInvite) {
await franchiseeInvitations.sendAll(franchiseeOrgId, deal.brandId, userId);
}
return franchiseeOrgId;
}
async updateDeal(dealId: string, updates: DealUpdates): Promise<void> {
const deal = await dealEntity.getById(dealId);
if (!deal) throw new NotFoundError('Deal not found');
// Enforce conditional rules via predicates
if (updates.proposedLocations && !dealEntity.canHaveProposedLocations(deal)) {
throw new ValidationError('Entity deals cannot have proposed locations');
}
await dealEntity.update(dealId, updates);
}
// Simple delegation is fine
async getDealOverview(dealId: string) {
return dealEntity.getOverview(dealId);
}
}
If there's significant notification/event logic, create separate files:
// dealNotifications.ts
class DealNotifications {
async onConverted(
deal: DealOverview,
franchiseeOrgId: string
): Promise<void> {
const recipients = deal.applications
?.filter((app) => app.email)
.map((app) => ({
email: app.email!,
name: `${app.firstName} ${app.lastName}`,
}));
if (!recipients?.length) return;
try {
await notificationsService.franchisees.sendDealConversionNotificationBatch(
{
recipients,
brandId: deal.brandId,
dealId: deal.id,
franchiseeOrgId,
}
);
} catch (error) {
logger.error("Failed to send conversion notifications", {
dealId: deal.id,
error,
});
// Don't rethrow - notifications shouldn't fail the operation
}
}
async onMadeVisible(deal: DealOverview): Promise<void> {
if (!deal.applicationId) return;
await notificationsService.applicants.triggerNotification({
applicationId: deal.applicationId,
type: "deal_available",
});
}
}
export const dealNotifications = new DealNotifications();
✅ Architecture Refactor Complete: deals
CREATED:
• api/deals/dealEntity.ts
- 5 business rule predicates (canConvert, canDelete, isEntityDeal, etc.)
- 8 data access methods (getById, getOverview, create, update, etc.)
• api/deals/dealNotifications.ts (optional)
- onConverted()
- onMadeVisible()
MODIFIED:
• api/deals/dealsService.ts
- Removed 180 lines of data access (→ entity)
- Removed 45 lines of inline rules (→ entity predicates)
- Service now orchestrates only
UNCHANGED:
• api/deals/dealsController.ts (already HTTP-only)
BUSINESS RULES NOW DISCOVERABLE:
dealEntity.canConvert()
dealEntity.canDelete()
dealEntity.canHaveProposedLocations()
dealEntity.canHaveAdditionalApplications()
dealEntity.isEntityDeal()
dealEntity.isApplicationDeal()
{concept}/
├── {concept}Entity.ts # Rules + data access
├── {concept}Entity.types.ts # Types (optional)
├── {concept}Service.ts # Orchestration
├── {concept}Controller.ts # HTTP handling
├── {concept}Notifications.ts # Side effects (optional)
└── {concept}Assertions.ts # Permission helpers (optional)
| Code Pattern | Location |
|---|---|
if (x.status === 'y') return false | Entity (predicate) |
if (!allowed) throw new ValidationError() | Service (enforcement) |
database.query.*.findFirst() | Entity |
database.transaction() | Service |
notificationsService.send*() | Notifications class or Service |
req.params, res.json() | Controller |
assertBrandPermissions() | Controller |