Theme 02 · Domain-Driven / Functional-Core Minded
Functional core / imperative shell
Explanation
Functional core / imperative shell
Plain Human Explanation
Put the decision in one place and the outside-world work in another. The core decides what should happen. The shell reads from databases, sends emails, writes logs, and calls APIs. That split makes the important rule easier to test and the side effects easier to see.
Technical Explanation
The functional core is plain TypeScript: it takes inputs and returns a decision or plan without performing IO. The imperative shell gathers inputs, calls the core, and performs the requested effects. This is not about avoiding all mutation; it is about keeping product decisions independent from clocks, databases, HTTP clients, and queues.
Why It Matters
- User impact: important decisions are easier to test across edge cases.
- Product behavior: the same pure decision can power API responses, previews, and jobs.
- Risk: rules mixed with IO become hard to test without mocks and easy to change accidentally.
- Decision point: use this when a workflow combines business decisions with database, network, email, logging, or time.
The Core Move
Make a pure function return the plan. Let a thin shell execute that plan.
Small Example
Functional core / imperative shell: Small Example
Bad TypeScript Example
type User = {
email: string;
plan: "free" | "pro" | "team";
};
type EmailClient = {
send(to: string, subject: string): Promise<void>;
};
export async function sendWelcomeEmail(user: User, email: EmailClient) {
if (user.plan === "team") {
await email.send(user.email, "Welcome your team");
} else {
await email.send(user.email, "Welcome");
}
}
type User = {
email: string;
plan: "free" | "pro" | "team";
};
type EmailClient = {
send(
to: string,
subject: string,
): Promise<void>;
};
export async function sendWelcomeEmail(
user: User,
email: EmailClient,
) {
if (user.plan === "team") {
await email.send(
user.email,
"Welcome your team",
);
} else {
await email.send(user.email, "Welcome");
}
}
Good TypeScript Example
type User = {
email: string;
plan: "free" | "pro" | "team";
};
type WelcomeEmail = {
to: string;
subject: string;
};
type EmailClient = {
send(to: string, subject: string): Promise<void>;
};
function planWelcomeEmail(user: User): WelcomeEmail {
return {
to: user.email,
subject: user.plan === "team" ? "Welcome your team" : "Welcome",
};
}
export async function sendWelcomeEmail(user: User, email: EmailClient) {
const message = planWelcomeEmail(user);
await email.send(message.to, message.subject);
}
type User = {
email: string;
plan: "free" | "pro" | "team";
};
type WelcomeEmail = {
to: string;
subject: string;
};
type EmailClient = {
send(
to: string,
subject: string,
): Promise<void>;
};
function planWelcomeEmail(
user: User,
): WelcomeEmail {
return {
to: user.email,
subject:
user.plan === "team"
? "Welcome your team"
: "Welcome",
};
}
export async function sendWelcomeEmail(
user: User,
email: EmailClient,
) {
const message = planWelcomeEmail(user);
await email.send(
message.to,
message.subject,
);
}
What Changed
- The bad version mixes the welcome decision with the email side effect.
- The good version makes the decision a pure function.
- The shell is now only responsible for sending the planned email.
Realistic Example
Functional core / imperative shell: Realistic Example
This example uses invoice reminders, where the decision should be testable without sending email.
Bad TypeScript Example
type Invoice = {
id: string;
status: "open" | "paid";
dueAt: Date;
customerEmail: string;
};
type InvoicePorts = {
invoices: {
find(invoiceId: string): Promise<Invoice>;
};
email: {
send(to: string, subject: string): Promise<void>;
};
log: {
info(event: string, payload: Record<string, string>): Promise<void>;
};
};
export async function sendInvoiceReminder(invoiceId: string, ports: InvoicePorts) {
const invoice = await ports.invoices.find(invoiceId);
const daysLate = Math.floor((Date.now() - invoice.dueAt.getTime()) / 86_400_000);
if (invoice.status === "open" && daysLate > 3) {
await ports.email.send(invoice.customerEmail, "Your invoice is overdue");
await ports.log.info("invoice.reminder_sent", { invoiceId });
}
}
type Invoice = {
id: string;
status: "open" | "paid";
dueAt: Date;
customerEmail: string;
};
type InvoicePorts = {
invoices: {
find(
invoiceId: string,
): Promise<Invoice>;
};
email: {
send(
to: string,
subject: string,
): Promise<void>;
};
log: {
info(
event: string,
payload: Record<string, string>,
): Promise<void>;
};
};
export async function sendInvoiceReminder(
invoiceId: string,
ports: InvoicePorts,
) {
const invoice =
await ports.invoices.find(invoiceId);
const daysLate = Math.floor(
(Date.now() - invoice.dueAt.getTime()) /
86_400_000,
);
if (
invoice.status === "open" &&
daysLate > 3
) {
await ports.email.send(
invoice.customerEmail,
"Your invoice is overdue",
);
await ports.log.info(
"invoice.reminder_sent",
{
invoiceId,
},
);
}
}
Good TypeScript Example
type Invoice = {
id: string;
status: "open" | "paid";
dueAt: Date;
customerEmail: string;
};
type ReminderPlan = { send: false } | { send: true; to: string; subject: string; eventName: string };
type InvoicePorts = {
invoices: {
find(invoiceId: string): Promise<Invoice>;
};
email: {
send(to: string, subject: string): Promise<void>;
};
log: {
info(event: string, payload: Record<string, string>): Promise<void>;
};
};
function planInvoiceReminder(invoice: Invoice, now: Date): ReminderPlan {
const daysLate = Math.floor((now.getTime() - invoice.dueAt.getTime()) / 86_400_000);
if (invoice.status !== "open" || daysLate <= 3) return { send: false };
return {
send: true,
to: invoice.customerEmail,
subject: "Your invoice is overdue",
eventName: "invoice.reminder_sent",
};
}
export async function sendInvoiceReminder(invoiceId: string, ports: InvoicePorts, now: Date) {
const plan = planInvoiceReminder(await ports.invoices.find(invoiceId), now);
if (!plan.send) return;
await ports.email.send(plan.to, plan.subject);
await ports.log.info(plan.eventName, { invoiceId });
}
type Invoice = {
id: string;
status: "open" | "paid";
dueAt: Date;
customerEmail: string;
};
type ReminderPlan =
| {
send: false;
}
| {
send: true;
to: string;
subject: string;
eventName: string;
};
type InvoicePorts = {
invoices: {
find(
invoiceId: string,
): Promise<Invoice>;
};
email: {
send(
to: string,
subject: string,
): Promise<void>;
};
log: {
info(
event: string,
payload: Record<string, string>,
): Promise<void>;
};
};
function planInvoiceReminder(
invoice: Invoice,
now: Date,
): ReminderPlan {
const daysLate = Math.floor(
(now.getTime() -
invoice.dueAt.getTime()) /
86_400_000,
);
if (
invoice.status !== "open" ||
daysLate <= 3
)
return {
send: false,
};
return {
send: true,
to: invoice.customerEmail,
subject: "Your invoice is overdue",
eventName: "invoice.reminder_sent",
};
}
export async function sendInvoiceReminder(
invoiceId: string,
ports: InvoicePorts,
now: Date,
) {
const plan = planInvoiceReminder(
await ports.invoices.find(invoiceId),
now,
);
if (!plan.send) return;
await ports.email.send(
plan.to,
plan.subject,
);
await ports.log.info(plan.eventName, {
invoiceId,
});
}
What Changed
- The bad version hides the reminder rule inside IO and the real clock.
- The good version passes time in and returns a plan.
- Tests can cover paid, open, early, and overdue invoices without mocking email.
System Example
Functional core / imperative shell: System Example
At system scale, a plan object can coordinate several side effects while keeping the product decision pure.
Larger System-Level Bad TypeScript Example
type Subscription = {
id: string;
providerId: string;
ownerEmail: string;
plan: "pro" | "team";
};
type SubscriptionPorts = {
store: {
findActiveByUser(userId: string): Promise<Subscription>;
markCancelled(subscriptionId: string): Promise<void>;
};
billing: {
cancel(providerId: string): Promise<void>;
};
email: {
send(to: string, subject: string): Promise<void>;
};
audit: {
write(name: string, payload: Record<string, string>): Promise<void>;
};
};
export async function cancelSubscription(userId: string, ports: SubscriptionPorts) {
const subscription = await ports.store.findActiveByUser(userId);
if (subscription.plan === "team") {
await ports.email.send(subscription.ownerEmail, "Your team subscription was cancelled");
await ports.audit.write("team_subscription_cancelled", { userId });
} else {
await ports.email.send(subscription.ownerEmail, "Your subscription was cancelled");
}
await ports.billing.cancel(subscription.providerId);
await ports.store.markCancelled(subscription.id);
}
type Subscription = {
id: string;
providerId: string;
ownerEmail: string;
plan: "pro" | "team";
};
type SubscriptionPorts = {
store: {
findActiveByUser(
userId: string,
): Promise<Subscription>;
markCancelled(
subscriptionId: string,
): Promise<void>;
};
billing: {
cancel(
providerId: string,
): Promise<void>;
};
email: {
send(
to: string,
subject: string,
): Promise<void>;
};
audit: {
write(
name: string,
payload: Record<string, string>,
): Promise<void>;
};
};
export async function cancelSubscription(
userId: string,
ports: SubscriptionPorts,
) {
const subscription =
await ports.store.findActiveByUser(
userId,
);
if (subscription.plan === "team") {
await ports.email.send(
subscription.ownerEmail,
"Your team subscription was cancelled",
);
await ports.audit.write(
"team_subscription_cancelled",
{
userId,
},
);
} else {
await ports.email.send(
subscription.ownerEmail,
"Your subscription was cancelled",
);
}
await ports.billing.cancel(
subscription.providerId,
);
await ports.store.markCancelled(
subscription.id,
);
}
Larger System-Level Good TypeScript Example
type Subscription = {
id: string;
providerId: string;
ownerEmail: string;
plan: "pro" | "team";
};
type SubscriptionPorts = {
store: {
findActiveByUser(userId: string): Promise<Subscription>;
markCancelled(subscriptionId: string): Promise<void>;
};
billing: {
cancel(providerId: string): Promise<void>;
};
email: {
send(to: string, subject: string): Promise<void>;
};
audit: {
write(name: string, payload: Record<string, string>): Promise<void>;
};
};
type CancellationPlan = {
providerId: string;
subscriptionId: string;
email: {
to: string;
subject: string;
};
auditEvents: Array<{
name: string;
payload: Record<string, string>;
}>;
};
function planCancellation(subscription: Subscription, userId: string): CancellationPlan {
return {
providerId: subscription.providerId,
subscriptionId: subscription.id,
email: {
to: subscription.ownerEmail,
subject: subscription.plan === "team" ? "Your team subscription was cancelled" : "Your subscription was cancelled",
},
auditEvents: subscription.plan === "team" ? [{ name: "team_subscription_cancelled", payload: { userId } }] : [],
};
}
export async function cancelSubscription(userId: string, ports: SubscriptionPorts) {
const plan = planCancellation(await ports.store.findActiveByUser(userId), userId);
await ports.billing.cancel(plan.providerId);
await ports.store.markCancelled(plan.subscriptionId);
await ports.email.send(plan.email.to, plan.email.subject);
for (const event of plan.auditEvents) await ports.audit.write(event.name, event.payload);
}
type Subscription = {
id: string;
providerId: string;
ownerEmail: string;
plan: "pro" | "team";
};
type SubscriptionPorts = {
store: {
findActiveByUser(
userId: string,
): Promise<Subscription>;
markCancelled(
subscriptionId: string,
): Promise<void>;
};
billing: {
cancel(
providerId: string,
): Promise<void>;
};
email: {
send(
to: string,
subject: string,
): Promise<void>;
};
audit: {
write(
name: string,
payload: Record<string, string>,
): Promise<void>;
};
};
type CancellationPlan = {
providerId: string;
subscriptionId: string;
email: {
to: string;
subject: string;
};
auditEvents: Array<{
name: string;
payload: Record<string, string>;
}>;
};
function planCancellation(
subscription: Subscription,
userId: string,
): CancellationPlan {
return {
providerId: subscription.providerId,
subscriptionId: subscription.id,
email: {
to: subscription.ownerEmail,
subject:
subscription.plan === "team"
? "Your team subscription was cancelled"
: "Your subscription was cancelled",
},
auditEvents:
subscription.plan === "team"
? [
{
name: "team_subscription_cancelled",
payload: {
userId,
},
},
]
: [],
};
}
export async function cancelSubscription(
userId: string,
ports: SubscriptionPorts,
) {
const plan = planCancellation(
await ports.store.findActiveByUser(
userId,
),
userId,
);
await ports.billing.cancel(
plan.providerId,
);
await ports.store.markCancelled(
plan.subscriptionId,
);
await ports.email.send(
plan.email.to,
plan.email.subject,
);
for (const event of plan.auditEvents)
await ports.audit.write(
event.name,
event.payload,
);
}
What Changed
- The bad version mixes cancellation decisions with billing, storage, email, and audit effects.
- The good version makes one pure plan that lists the effects to perform.
- The shell still controls effect ordering, retries, and error handling.
When To Use It
Functional core / imperative shell: When To Use It
Use This When
- A workflow mixes product decisions with database, email, logging, queues, clocks, or external APIs.
- You want to test the decision without mocking every side effect.
- The same decision might be reused by a preview, admin tool, job, or API route.
Avoid This When
- The function is already a tiny one-off wrapper around a single side effect.
- The plan object would be more complicated than simply performing the action.
- The core still imports IO clients, framework objects, or global time.
Tradeoffs
This split adds a small amount of ceremony. The benefit is that the most important decision becomes easy to read and test. Keep the plan concrete; avoid inventing a generic workflow engine for one use case.
Related Concepts
- Keeping business logic out of framework entrypoints
- Real seams
- Observable behavior over mocks/spies
Practice Prompt
Functional core / imperative shell: Practice Prompt
Beginner Exercise
Find a function that both decides what should happen and performs a side effect. Underline the decision part.
Intermediate Exercise
Move the decision into a pure function that returns a plan. Update the original function so it executes the plan.
Stretch Exercise
Write tests for the pure function covering at least three edge cases, without mocking a database, clock, email client, or HTTP client.
Reflection Question
Did the split make the product decision easier to test, or did it create a plan object that is harder to understand than the original code?
Suggest an edit
Leave a private editorial note. This creates a GitHub issue for this curriculum page.