Theme 02 · Domain-Driven / Functional-Core Minded
Keeping business logic out of framework entrypoints
Explanation
Keeping business logic out of framework entrypoints
Plain Human Explanation
Routes, controllers, server actions, and queue handlers should translate the outside world into a product command. They should not become the place where pricing, access, cancellation, or retry rules live. When business logic sits inside framework code, it becomes hard to reuse and hard to test.
Technical Explanation
Keep entrypoints responsible for parsing input, calling a use-case or domain function, and mapping the result to the framework response. Move decisions into plain TypeScript functions that do not know about Request, Response, job payloads, or framework-specific objects.
Why It Matters
- User impact: the same rule behaves consistently across API, admin, and background paths.
- Product behavior: product decisions can be tested without starting the framework.
- Risk: route handlers become long scripts where validation, rules, storage, and response mapping are tangled together.
- Decision point: use this when a handler contains product decisions that another path may need.
The Core Move
Make the framework entrypoint a translator. Put the product decision in a plain function or use-case module.
Small Example
Keeping business logic out of framework entrypoints: Small Example
Bad TypeScript Example
declare function cancelInDatabase(subscriptionId: string): Promise<void>;
export async function postCancelSubscription(req: Request) {
const body = await req.json();
if (body.reason === "fraud") {
return Response.json({ error: "Contact support" }, { status: 403 });
}
await cancelInDatabase(body.subscriptionId);
return Response.json({ ok: true });
}
declare function cancelInDatabase(
subscriptionId: string,
): Promise<void>;
export async function postCancelSubscription(
req: Request,
) {
const body = await req.json();
if (body.reason === "fraud") {
return Response.json(
{
error: "Contact support",
},
{
status: 403,
},
);
}
await cancelInDatabase(
body.subscriptionId,
);
return Response.json({
ok: true,
});
}
Good TypeScript Example
type CancelSubscriptionCommand = {
subscriptionId: string;
reason: "user-request" | "fraud";
};
declare function cancelInDatabase(subscriptionId: string): Promise<void>;
function parseCancelSubscriptionCommand(body: unknown): CancelSubscriptionCommand | null {
if (typeof body !== "object" || body === null) return null;
const value = body as Record<string, unknown>;
if (typeof value.subscriptionId !== "string") return null;
if (value.reason !== "user-request" && value.reason !== "fraud") return null;
return { subscriptionId: value.subscriptionId, reason: value.reason };
}
function planCancellation(command: CancelSubscriptionCommand) {
if (command.reason === "fraud") return { ok: false as const, error: "manual-review-required" };
return { ok: true as const, subscriptionId: command.subscriptionId };
}
export async function postCancelSubscription(req: Request) {
const command = parseCancelSubscriptionCommand(await req.json());
if (!command) return Response.json({ error: "Invalid request" }, { status: 400 });
const result = planCancellation(command);
if (!result.ok) return Response.json({ error: result.error }, { status: 403 });
await cancelInDatabase(result.subscriptionId);
return Response.json({ ok: true });
}
type CancelSubscriptionCommand = {
subscriptionId: string;
reason: "user-request" | "fraud";
};
declare function cancelInDatabase(
subscriptionId: string,
): Promise<void>;
function parseCancelSubscriptionCommand(
body: unknown,
): CancelSubscriptionCommand | null {
if (
typeof body !== "object" ||
body === null
)
return null;
const value = body as Record<
string,
unknown
>;
if (
typeof value.subscriptionId !== "string"
)
return null;
if (
value.reason !== "user-request" &&
value.reason !== "fraud"
)
return null;
return {
subscriptionId: value.subscriptionId,
reason: value.reason,
};
}
function planCancellation(
command: CancelSubscriptionCommand,
) {
if (command.reason === "fraud")
return {
ok: false as const,
error: "manual-review-required",
};
return {
ok: true as const,
subscriptionId: command.subscriptionId,
};
}
export async function postCancelSubscription(
req: Request,
) {
const command =
parseCancelSubscriptionCommand(
await req.json(),
);
if (!command)
return Response.json(
{
error: "Invalid request",
},
{
status: 400,
},
);
const result = planCancellation(command);
if (!result.ok)
return Response.json(
{
error: result.error,
},
{
status: 403,
},
);
await cancelInDatabase(
result.subscriptionId,
);
return Response.json({
ok: true,
});
}
What Changed
- The bad version puts the cancellation rule directly in the route.
- The good version moves the rule into a plain function.
- The route still owns HTTP parsing and response mapping, but not the cancellation decision.
Realistic Example
Keeping business logic out of framework entrypoints: Realistic Example
This example uses an upgrade endpoint where eligibility should be shared with previews and admin tools.
Bad TypeScript Example
type Account = {
id: string;
status: "active" | "cancelled";
hasPastDueInvoice: boolean;
};
type AccountStore = {
findAccount(accountId: string): Promise<Account>;
updatePlan(accountId: string, plan: string): Promise<void>;
};
export async function postUpgrade(req: Request, store: AccountStore) {
const body = await req.json();
const account = await store.findAccount(body.accountId);
if (account.status !== "active" || account.hasPastDueInvoice) {
return Response.json({ error: "Not eligible" }, { status: 409 });
}
await store.updatePlan(account.id, body.plan);
return Response.json({ ok: true });
}
type Account = {
id: string;
status: "active" | "cancelled";
hasPastDueInvoice: boolean;
};
type AccountStore = {
findAccount(
accountId: string,
): Promise<Account>;
updatePlan(
accountId: string,
plan: string,
): Promise<void>;
};
export async function postUpgrade(
req: Request,
store: AccountStore,
) {
const body = await req.json();
const account = await store.findAccount(
body.accountId,
);
if (
account.status !== "active" ||
account.hasPastDueInvoice
) {
return Response.json(
{
error: "Not eligible",
},
{
status: 409,
},
);
}
await store.updatePlan(
account.id,
body.plan,
);
return Response.json({
ok: true,
});
}
Good TypeScript Example
type Account = {
id: string;
status: "active" | "cancelled";
hasPastDueInvoice: boolean;
};
type UpgradePlan = "pro" | "team";
type AccountStore = {
findAccount(accountId: string): Promise<Account>;
updatePlan(accountId: string, plan: UpgradePlan): Promise<void>;
};
type UpgradeCommand = {
accountId: string;
plan: UpgradePlan;
};
function parseUpgradeCommand(body: unknown): UpgradeCommand | null {
if (typeof body !== "object" || body === null) return null;
const value = body as Record<string, unknown>;
if (typeof value.accountId !== "string") return null;
if (value.plan !== "pro" && value.plan !== "team") return null;
return { accountId: value.accountId, plan: value.plan };
}
function planUpgrade(account: Account, plan: UpgradePlan) {
if (account.status !== "active") return { ok: false as const, error: "account-not-active" };
if (account.hasPastDueInvoice) return { ok: false as const, error: "past-due-invoice" };
return { ok: true as const, accountId: account.id, plan };
}
export async function postUpgrade(req: Request, store: AccountStore) {
const command = parseUpgradeCommand(await req.json());
if (!command) return Response.json({ error: "Invalid upgrade request" }, { status: 400 });
const result = planUpgrade(await store.findAccount(command.accountId), command.plan);
if (!result.ok) return Response.json({ error: result.error }, { status: 409 });
await store.updatePlan(result.accountId, result.plan);
return Response.json({ ok: true });
}
type Account = {
id: string;
status: "active" | "cancelled";
hasPastDueInvoice: boolean;
};
type UpgradePlan = "pro" | "team";
type AccountStore = {
findAccount(
accountId: string,
): Promise<Account>;
updatePlan(
accountId: string,
plan: UpgradePlan,
): Promise<void>;
};
type UpgradeCommand = {
accountId: string;
plan: UpgradePlan;
};
function parseUpgradeCommand(
body: unknown,
): UpgradeCommand | null {
if (
typeof body !== "object" ||
body === null
)
return null;
const value = body as Record<
string,
unknown
>;
if (typeof value.accountId !== "string")
return null;
if (
value.plan !== "pro" &&
value.plan !== "team"
)
return null;
return {
accountId: value.accountId,
plan: value.plan,
};
}
function planUpgrade(
account: Account,
plan: UpgradePlan,
) {
if (account.status !== "active")
return {
ok: false as const,
error: "account-not-active",
};
if (account.hasPastDueInvoice)
return {
ok: false as const,
error: "past-due-invoice",
};
return {
ok: true as const,
accountId: account.id,
plan,
};
}
export async function postUpgrade(
req: Request,
store: AccountStore,
) {
const command = parseUpgradeCommand(
await req.json(),
);
if (!command)
return Response.json(
{
error: "Invalid upgrade request",
},
{
status: 400,
},
);
const result = planUpgrade(
await store.findAccount(
command.accountId,
),
command.plan,
);
if (!result.ok)
return Response.json(
{
error: result.error,
},
{
status: 409,
},
);
await store.updatePlan(
result.accountId,
result.plan,
);
return Response.json({
ok: true,
});
}
What Changed
- The bad version traps upgrade eligibility inside an HTTP handler.
- The good version parses the request first, then gives eligibility a plain function with named failures.
- A preview screen or admin action can call the same upgrade rule without faking a request.
System Example
Keeping business logic out of framework entrypoints: System Example
At system scale, thin entrypoints keep HTTP handlers and queue workers from becoming two separate versions of the product.
Larger System-Level Bad TypeScript Example
type Invoice = {
id: string;
status: "open" | "paid" | "failed";
retryCount: number;
};
type BillingStore = {
findInvoice(invoiceId: string): Promise<Invoice>;
retryInvoice(invoiceId: string): Promise<void>;
};
export async function postRetryPayment(req: Request, store: BillingStore) {
const body = await req.json();
const invoice = await store.findInvoice(body.invoiceId);
if (invoice.status !== "failed" || invoice.retryCount >= 3) return Response.json({ error: "Cannot retry" });
await store.retryInvoice(invoice.id);
return Response.json({ ok: true });
}
export async function retryPaymentJob(
job: {
invoiceId: string;
},
store: BillingStore,
) {
const invoice = await store.findInvoice(job.invoiceId);
if (invoice.status === "failed") await store.retryInvoice(invoice.id);
}
type Invoice = {
id: string;
status: "open" | "paid" | "failed";
retryCount: number;
};
type BillingStore = {
findInvoice(
invoiceId: string,
): Promise<Invoice>;
retryInvoice(
invoiceId: string,
): Promise<void>;
};
export async function postRetryPayment(
req: Request,
store: BillingStore,
) {
const body = await req.json();
const invoice = await store.findInvoice(
body.invoiceId,
);
if (
invoice.status !== "failed" ||
invoice.retryCount >= 3
)
return Response.json({
error: "Cannot retry",
});
await store.retryInvoice(invoice.id);
return Response.json({
ok: true,
});
}
export async function retryPaymentJob(
job: {
invoiceId: string;
},
store: BillingStore,
) {
const invoice = await store.findInvoice(
job.invoiceId,
);
if (invoice.status === "failed")
await store.retryInvoice(invoice.id);
}
Larger System-Level Good TypeScript Example
type Invoice = {
id: string;
status: "open" | "paid" | "failed";
retryCount: number;
};
type BillingStore = {
findInvoice(invoiceId: string): Promise<Invoice>;
retryInvoice(invoiceId: string): Promise<void>;
};
function parseRetryPaymentRequest(body: unknown): { invoiceId: string } | null {
if (typeof body !== "object" || body === null) return null;
const value = body as Record<string, unknown>;
return typeof value.invoiceId === "string" ? { invoiceId: value.invoiceId } : null;
}
function planPaymentRetry(invoice: Invoice) {
if (invoice.status !== "failed") return { ok: false as const, error: "invoice-not-failed" };
if (invoice.retryCount >= 3) return { ok: false as const, error: "retry-limit-reached" };
return { ok: true as const, invoiceId: invoice.id };
}
export async function postRetryPayment(req: Request, store: BillingStore) {
const body = parseRetryPaymentRequest(await req.json());
if (!body) return Response.json({ error: "Invalid retry request" }, { status: 400 });
const result = planPaymentRetry(await store.findInvoice(body.invoiceId));
if (!result.ok) return Response.json({ error: result.error }, { status: 409 });
await store.retryInvoice(result.invoiceId);
return Response.json({ ok: true });
}
export async function retryPaymentJob(
job: {
invoiceId: string;
},
store: BillingStore,
) {
const result = planPaymentRetry(await store.findInvoice(job.invoiceId));
if (result.ok) await store.retryInvoice(result.invoiceId);
}
type Invoice = {
id: string;
status: "open" | "paid" | "failed";
retryCount: number;
};
type BillingStore = {
findInvoice(
invoiceId: string,
): Promise<Invoice>;
retryInvoice(
invoiceId: string,
): Promise<void>;
};
function parseRetryPaymentRequest(
body: unknown,
): {
invoiceId: string;
} | null {
if (
typeof body !== "object" ||
body === null
)
return null;
const value = body as Record<
string,
unknown
>;
return typeof value.invoiceId === "string"
? {
invoiceId: value.invoiceId,
}
: null;
}
function planPaymentRetry(
invoice: Invoice,
) {
if (invoice.status !== "failed")
return {
ok: false as const,
error: "invoice-not-failed",
};
if (invoice.retryCount >= 3)
return {
ok: false as const,
error: "retry-limit-reached",
};
return {
ok: true as const,
invoiceId: invoice.id,
};
}
export async function postRetryPayment(
req: Request,
store: BillingStore,
) {
const body = parseRetryPaymentRequest(
await req.json(),
);
if (!body)
return Response.json(
{
error: "Invalid retry request",
},
{
status: 400,
},
);
const result = planPaymentRetry(
await store.findInvoice(body.invoiceId),
);
if (!result.ok)
return Response.json(
{
error: result.error,
},
{
status: 409,
},
);
await store.retryInvoice(
result.invoiceId,
);
return Response.json({
ok: true,
});
}
export async function retryPaymentJob(
job: {
invoiceId: string;
},
store: BillingStore,
) {
const result = planPaymentRetry(
await store.findInvoice(job.invoiceId),
);
if (result.ok)
await store.retryInvoice(
result.invoiceId,
);
}
What Changed
- The bad version has different retry rules in HTTP and job entrypoints.
- The good version gives retry eligibility one plain product function.
- Each entrypoint still maps the result to its own response style.
When To Use It
Keeping business logic out of framework entrypoints: When To Use It
Use This When
- A route, controller, server action, or job handler contains a product decision.
- Another entrypoint may need the same rule.
- You want focused tests without framework setup.
Avoid This When
- The handler is only translating input and returning a response.
- The logic is purely framework behavior, such as setting headers or reading cookies.
- Moving the code would create a vague service with no clear product name.
Tradeoffs
Thin entrypoints add one extra function call. The benefit is that product rules become easier to test and reuse. Keep the extracted function named after the product decision, not after the framework action.
Related Concepts
- Domain modules
- Functional core / imperative shell
- Parsed inputs
Practice Prompt
Keeping business logic out of framework entrypoints: Practice Prompt
Beginner Exercise
Find a route or job handler with an if statement that represents a product rule. Write the rule without mentioning the framework.
Intermediate Exercise
Move that rule into a plain function and update the entrypoint so it only parses input, calls the function, and maps the result.
Stretch Exercise
Call the same function from a second entrypoint, such as an admin action, preview endpoint, or background job.
Reflection Question
After the change, can the product rule be tested without creating a request, response, or job payload?
Suggest an edit
Leave a private editorial note. This creates a GitHub issue for this curriculum page.