Skip to content
Chapter 43Lesson 4

Thin actions, pure /lib

The pattern that keeps Server Actions thin: pure logic in /lib, side effects at named boundaries, orchestration in the body.

Over the last three lessons your createInvoice action grew: it parses input, checks the invoice number isn’t taken, computes the total, inserts the row, and maps every failure into a Result. Each step is correct, but stacked together the body is too long to take in at a glance.

Length is only the symptom. The real problem is that these lines do three different kinds of work, braided together: pure computation, database access, and the orchestration that sequences them. This lesson teaches you to pull them apart, and when to stop, so you can look at any line and say where it belongs and why.

Here is the body, assembled from everything the last three lessons added.

'use server';
export async function createInvoice(formData: FormData) {
const parsed = createInvoiceSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return err('validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors);
}
const { number, lineItems, ...rest } = parsed.data;
const user = await getCurrentUser();
const existing = await db
.select({ id: invoicesTable.id })
.from(invoicesTable)
.where(and(eq(invoicesTable.organizationId, user.organizationId), eq(invoicesTable.number, number)));
if (existing.length > 0) {
return err('conflict', 'That invoice number is already in use.');
}
let total = 0;
for (const line of lineItems) {
total += line.quantity * line.unitAmount;
}
try {
const created = await db.transaction(async (tx) => {
const [invoice] = await tx
.insert(invoicesTable)
.values({ ...rest, number, total, organizationId: user.organizationId, createdBy: user.id })
.returning({ id: invoicesTable.id });
await insertInvoiceLines(tx, invoice.id, lineItems);
return invoice;
});
// revalidate → next lesson
return ok({ id: created.id });
} catch {
return err('internal', 'Could not create the invoice.');
}
}

Pure logic. Same line items in, same number out, every time. It touches nothing outside its arguments, so a test can run it without a database in sight.

'use server';
export async function createInvoice(formData: FormData) {
const parsed = createInvoiceSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return err('validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors);
}
const { number, lineItems, ...rest } = parsed.data;
const user = await getCurrentUser();
const existing = await db
.select({ id: invoicesTable.id })
.from(invoicesTable)
.where(and(eq(invoicesTable.organizationId, user.organizationId), eq(invoicesTable.number, number)));
if (existing.length > 0) {
return err('conflict', 'That invoice number is already in use.');
}
let total = 0;
for (const line of lineItems) {
total += line.quantity * line.unitAmount;
}
try {
const created = await db.transaction(async (tx) => {
const [invoice] = await tx
.insert(invoicesTable)
.values({ ...rest, number, total, organizationId: user.organizationId, createdBy: user.id })
.returning({ id: invoicesTable.id });
await insertInvoiceLines(tx, invoice.id, lineItems);
return invoice;
});
// revalidate → next lesson
return ok({ id: created.id });
} catch {
return err('internal', 'Could not create the invoice.');
}
}

Side effects: lines that read from or write to the world outside their arguments. They can fail for reasons the inputs can’t predict, like a dropped connection or a concurrent insert winning the race, and you can’t test them without a real database behind them.

'use server';
export async function createInvoice(formData: FormData) {
const parsed = createInvoiceSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return err('validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors);
}
const { number, lineItems, ...rest } = parsed.data;
const user = await getCurrentUser();
const existing = await db
.select({ id: invoicesTable.id })
.from(invoicesTable)
.where(and(eq(invoicesTable.organizationId, user.organizationId), eq(invoicesTable.number, number)));
if (existing.length > 0) {
return err('conflict', 'That invoice number is already in use.');
}
let total = 0;
for (const line of lineItems) {
total += line.quantity * line.unitAmount;
}
try {
const created = await db.transaction(async (tx) => {
const [invoice] = await tx
.insert(invoicesTable)
.values({ ...rest, number, total, organizationId: user.organizationId, createdBy: user.id })
.returning({ id: invoicesTable.id });
await insertInvoiceLines(tx, invoice.id, lineItems);
return invoice;
});
// revalidate → next lesson
return ok({ id: created.id });
} catch {
return err('internal', 'Could not create the invoice.');
}
}

Orchestration: the spine that decides what runs when and shapes every outcome into a Result. It owns no logic of its own, only the sequencing.

1 / 1

The three kinds interleave, and that braiding is the problem, because each one wants a different home. The total math will outlive this action: a CSV export and a public invoice page will both need it, and when the way you compute money changes you want one place to change. The database lines can only run against a real Postgres, so anything tangled with them inherits that weight. The orchestration is specific to this action, and it alone belongs in the body. Braided together, the pure math can’t be tested without a database, the database code is hard to reuse, and a reviewer has to untangle all three on every read.

Pure logic in /lib, side effects at named boundaries

Section titled “Pure logic in /lib, side effects at named boundaries”

The total calculation is pure : line items in, money out. The invoice-number check is a side effect , because it reads the database and its answer depends on what rows exist right now.

Principle #3 sorts every line: pure logic moves into /lib, where anything can import it and a test can run it in isolation, while side effects stay at a boundary, and the action is one of the three.

So pull the total math into its own pure function, and have the action call it by name.

const { number, lineItems, ...rest } = parsed.data;
let total = 0;
for (const line of lineItems) {
total += line.quantity * line.unitAmount;
}
const [created] = await db
.insert(invoicesTable)
.values({ ...rest, number, total, organizationId: user.organizationId, createdBy: user.id })
.returning({ id: invoicesTable.id });

Computed inline, right next to the side-effectful insert. To test this arithmetic you’d call the whole action, which means standing up a database. The pure math is trapped by the I/O beside it.

The extraction is small, but it buys three things.

Tests run without a database. calculateInvoiceTotal takes an array and returns a number, so a unit test passes it line items and asserts the total, no setup required.

One helper, many boundaries. The same function feeds the createInvoice action, a CSV-export job, and a route handler for public invoices, so a rounding bug has one place to fix and every caller is fixed at once.

The action reads as a sequence. A reviewer sees calculateInvoiceTotal(lineItems), trusts it without re-deriving the arithmetic, and moves on.

You can also get this exactly backwards, in a way that feels reasonable. Suppose you “extract” the insert into /lib/invoices/save.ts:

// lib/invoices/save.ts — wrong
import { db } from '@/db';
import { invoicesTable } from '@/db/schema';
export const saveInvoice = (data: typeof invoicesTable.$inferInsert) =>
db.insert(invoicesTable).values(data).returning({ id: invoicesTable.id });

The instinct is “it’s a helper, helpers go in /lib.” But saveInvoice imports db, so it carries a side effect: it has been mis-sliced. The fix isn’t to paste it back into the action; the side effect belongs at the boundary or in the data-access layer you’re about to meet, never hidden in a generic /lib helper. Once /lib is a place side effects can hide, you lose the guarantee that made it worth having: anything in /lib is safe to call in a test.

A pure helper is one of three file kinds the course uses per feature. Together they give every line of an action a home.

You’ve already built the first: pure helpers like lib/invoices/calculate-total.ts — logic, no IO, one verb-named function per file.

The second is the data-access layer, the one file allowed to import db. It lives at db/queries/invoices.ts and exports verb-led reads and writes. Keeping every database touch for an entity in one file gives you a single place to scope queries to the tenant later. You may have seen this layer called a repository elsewhere.

The third is the policy layer at lib/invoices/policy.ts, holding authorization predicates like canCreateInvoice(user, org): boolean. A predicate is pure: it takes the already-loaded user and answers a yes/no question about their role. It never reads the session, because reading the session is a side effect that fires at the boundary.

The action file at app/invoices/actions.ts ties the three together. Here’s the whole shape.

  • Directoryapp/
    • Directoryinvoices/
      • actions.ts 'use server', thin orchestration
  • Directorylib/
    • Directoryinvoices/
      • calculate-total.ts pure, no db, no cookies()
      • policy.ts pure predicates, canCreateInvoice(user, org)
    • result.ts Result<T>, ok, err, from the previous lesson
  • Directorydb/
    • schema.ts the source of truth
    • Directoryqueries/
      • invoices.ts tenant-scoped reads + writes, the only db importer

The action body imports all three but holds none of their internals: it’s the boundary where side effects fire, and the only place that knows the order things happen in.

With the shape in your head, classifying a new line is mechanical:

The rule earns its keep on work that looks like one thing and is really two. Run “validate a payment and reserve inventory” through it and it splits: validatePayment is pure logic for /lib, while reserveInventory writes to the world and is called from the action body. The skill is noticing when a “function” is secretly two functions wearing one name.

Try the rule on a handful of realistic lines.

For each piece of work an action does, decide whether it's pure logic that belongs in /lib, or a side effect that fires at the action boundary. The rule: does it touch a database, cache, queue, or external service? Drag each item into the bucket it belongs to, then press Check.

Lives in /lib (pure) Logic only — no db, cache, queue, or network
Fires at the boundary (action body) Touches the database, session, queue, or an external service
Compute the invoice total from its line items
Read the current user’s organization from the session
Insert the new invoice row
Decide whether a user’s role is allowed to create an invoice (a predicate)
Send the confirmation email after the invoice is created
Format a number as a currency string
Check that the invoice number isn’t already taken in the database
Sum the quantities across the line items

Two cases reward a second look. The policy predicate lands in /lib despite sounding like authorization, because it only does boolean arithmetic on a user it was handed. And “check the invoice number isn’t taken” lands at the boundary despite feeling like validation, because answering it takes a database round-trip. The rule asks not what the work feels like but whether it touches the world.

The action body is shorter now, but its top still repeats: every action parses input, authorizes the caller, and returns a Result. By the third action with the same preamble, the question writes itself: why not factor it out? One generic wrapper could own parse, authorize, and result.

export const safeAction = <Schema extends z.ZodType, T>(
schema: Schema,
fn: (data: z.infer<Schema>, ctx: Ctx) => Promise<Result<T>>,
) => {
return async (formData: FormData) => {
const parsed = schema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return err('validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors);
}
const ctx = await buildContext();
return fn(parsed.data, ctx);
};
};
export const createInvoice = safeAction(createInvoiceSchema, async (data, ctx) => {
// just the mutation — parse + ctx already handled
});

This is roughly what libraries like next-safe-action ship, and it looks clean. Every action sheds its boilerplate top and you write only the part that differs. The call site reads small.

The wrapper is tempting, so weigh what it costs.

It blurs what the compiler sees. Next.js analyzes your 'use server' exports directly to emit action IDs, strip action source from the client bundle, and encrypt captured closures; every layer between export and 'use server' risks confusing that analysis.

It hides the seams from the reviewer. safeAction(schema, fn) collapses parse, authorize, mutate, revalidate, and return behind one call, so you can’t read an action without reading its wrapper.

It’s a custom language only your team speaks. A new hire knows 'use server', Zod, and Result, but not your DSL ; that’s one more thing to learn before they can read a single action.

So the default is to write the parse line again, even on the third action where the urge to factor it out peaks. next-safe-action and zsa are real, well-built libraries that pay off for a large team standardizing dozens of actions, but that’s a choice past a clear bar, not where you start.

Principle #5 says don’t wrap the action, yet this course ships one action wrapper and one SDK interface. An exception has to clear a high bar: a single concern, identical boilerplate at every call site, and a failure that’s an incident, not a style nit. A generic safeAction fails the first count by bundling unrelated concerns; two concerns clear all three.

Authorization and the billing SDK clear the bar; everything else, parse, business-rule checks, the transaction, the revalidate, stays inline in the body.

Put the pieces together: createInvoice keeps the behavior of the fat body, but every line is now either a call into a named layer or a piece of orchestration.

'use server';
import { findInvoiceByNumber, insertInvoice, insertInvoiceLines } from '@/db/queries/invoices';
import { getCurrentUser } from '@/lib/auth';
import { calculateInvoiceTotal } from '@/lib/invoices/calculate-total';
import { canCreateInvoice } from '@/lib/invoices/policy';
import { err, ok } from '@/lib/result';
export async function createInvoice(formData: FormData) {
const parsed = createInvoiceSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return err('validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors);
}
const { number, lineItems, ...rest } = parsed.data;
const user = await getCurrentUser();
if (!user || !canCreateInvoice(user, user.organizationId)) {
return err('forbidden', 'You do not have access to create invoices here.');
}
if (await findInvoiceByNumber(user.organizationId, number)) {
return err('conflict', 'That invoice number is already in use.');
}
const total = calculateInvoiceTotal(lineItems);
const invoice = await insertInvoice({
...rest,
number,
total,
organizationId: user.organizationId,
createdBy: user.id,
});
await insertInvoiceLines(invoice.id, lineItems);
// db.transaction wraps the two inserts + revalidatePath → next lesson
return ok({ id: invoice.id });
}

Parse stays in the body. The destructure pulls the client-supplied number and lineItems out of parsed.data; the server-set identity columns are stamped at the insert, never read from the client.

'use server';
import { findInvoiceByNumber, insertInvoice, insertInvoiceLines } from '@/db/queries/invoices';
import { getCurrentUser } from '@/lib/auth';
import { calculateInvoiceTotal } from '@/lib/invoices/calculate-total';
import { canCreateInvoice } from '@/lib/invoices/policy';
import { err, ok } from '@/lib/result';
export async function createInvoice(formData: FormData) {
const parsed = createInvoiceSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return err('validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors);
}
const { number, lineItems, ...rest } = parsed.data;
const user = await getCurrentUser();
if (!user || !canCreateInvoice(user, user.organizationId)) {
return err('forbidden', 'You do not have access to create invoices here.');
}
if (await findInvoiceByNumber(user.organizationId, number)) {
return err('conflict', 'That invoice number is already in use.');
}
const total = calculateInvoiceTotal(lineItems);
const invoice = await insertInvoice({
...rest,
number,
total,
organizationId: user.organizationId,
createdBy: user.id,
});
await insertInvoiceLines(invoice.id, lineItems);
// db.transaction wraps the two inserts + revalidatePath → next lesson
return ok({ id: invoice.id });
}

Authorize. The session read is the side effect and fires here at the boundary; the decision is the pure predicate from lib/invoices/policy.ts.

'use server';
import { findInvoiceByNumber, insertInvoice, insertInvoiceLines } from '@/db/queries/invoices';
import { getCurrentUser } from '@/lib/auth';
import { calculateInvoiceTotal } from '@/lib/invoices/calculate-total';
import { canCreateInvoice } from '@/lib/invoices/policy';
import { err, ok } from '@/lib/result';
export async function createInvoice(formData: FormData) {
const parsed = createInvoiceSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return err('validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors);
}
const { number, lineItems, ...rest } = parsed.data;
const user = await getCurrentUser();
if (!user || !canCreateInvoice(user, user.organizationId)) {
return err('forbidden', 'You do not have access to create invoices here.');
}
if (await findInvoiceByNumber(user.organizationId, number)) {
return err('conflict', 'That invoice number is already in use.');
}
const total = calculateInvoiceTotal(lineItems);
const invoice = await insertInvoice({
...rest,
number,
total,
organizationId: user.organizationId,
createdBy: user.id,
});
await insertInvoiceLines(invoice.id, lineItems);
// db.transaction wraps the two inserts + revalidatePath → next lesson
return ok({ id: invoice.id });
}

The uniqueness check is a database read, so it lives behind db/queries/invoices.ts.

'use server';
import { findInvoiceByNumber, insertInvoice, insertInvoiceLines } from '@/db/queries/invoices';
import { getCurrentUser } from '@/lib/auth';
import { calculateInvoiceTotal } from '@/lib/invoices/calculate-total';
import { canCreateInvoice } from '@/lib/invoices/policy';
import { err, ok } from '@/lib/result';
export async function createInvoice(formData: FormData) {
const parsed = createInvoiceSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return err('validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors);
}
const { number, lineItems, ...rest } = parsed.data;
const user = await getCurrentUser();
if (!user || !canCreateInvoice(user, user.organizationId)) {
return err('forbidden', 'You do not have access to create invoices here.');
}
if (await findInvoiceByNumber(user.organizationId, number)) {
return err('conflict', 'That invoice number is already in use.');
}
const total = calculateInvoiceTotal(lineItems);
const invoice = await insertInvoice({
...rest,
number,
total,
organizationId: user.organizationId,
createdBy: user.id,
});
await insertInvoiceLines(invoice.id, lineItems);
// db.transaction wraps the two inserts + revalidatePath → next lesson
return ok({ id: invoice.id });
}

The pure step: the green math, now a single named call. Testable on its own, reusable everywhere.

'use server';
import { findInvoiceByNumber, insertInvoice, insertInvoiceLines } from '@/db/queries/invoices';
import { getCurrentUser } from '@/lib/auth';
import { calculateInvoiceTotal } from '@/lib/invoices/calculate-total';
import { canCreateInvoice } from '@/lib/invoices/policy';
import { err, ok } from '@/lib/result';
export async function createInvoice(formData: FormData) {
const parsed = createInvoiceSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return err('validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors);
}
const { number, lineItems, ...rest } = parsed.data;
const user = await getCurrentUser();
if (!user || !canCreateInvoice(user, user.organizationId)) {
return err('forbidden', 'You do not have access to create invoices here.');
}
if (await findInvoiceByNumber(user.organizationId, number)) {
return err('conflict', 'That invoice number is already in use.');
}
const total = calculateInvoiceTotal(lineItems);
const invoice = await insertInvoice({
...rest,
number,
total,
organizationId: user.organizationId,
createdBy: user.id,
});
await insertInvoiceLines(invoice.id, lineItems);
// db.transaction wraps the two inserts + revalidatePath → next lesson
return ok({ id: invoice.id });
}

The mutation. The header insert stamps organizationId and createdBy from the session, and the line items go to their own child table through insertInvoiceLines, also behind db/queries.

'use server';
import { findInvoiceByNumber, insertInvoice, insertInvoiceLines } from '@/db/queries/invoices';
import { getCurrentUser } from '@/lib/auth';
import { calculateInvoiceTotal } from '@/lib/invoices/calculate-total';
import { canCreateInvoice } from '@/lib/invoices/policy';
import { err, ok } from '@/lib/result';
export async function createInvoice(formData: FormData) {
const parsed = createInvoiceSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return err('validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors);
}
const { number, lineItems, ...rest } = parsed.data;
const user = await getCurrentUser();
if (!user || !canCreateInvoice(user, user.organizationId)) {
return err('forbidden', 'You do not have access to create invoices here.');
}
if (await findInvoiceByNumber(user.organizationId, number)) {
return err('conflict', 'That invoice number is already in use.');
}
const total = calculateInvoiceTotal(lineItems);
const invoice = await insertInvoice({
...rest,
number,
total,
organizationId: user.organizationId,
createdBy: user.id,
});
await insertInvoiceLines(invoice.id, lineItems);
// db.transaction wraps the two inserts + revalidatePath → next lesson
return ok({ id: invoice.id });
}

Revalidate and the transaction are the next lesson’s seams, shown here as a comment. The return shapes the outcome into a Result. The body is now mostly orchestration and named calls, the three colors separated rather than braided.

1 / 1

Notice what’s not in the import list: db. Every database touch now goes through db/queries/invoices, so what’s left reads as a list of named steps a reviewer can trust in seconds.

The three braided colors now live in three homes. Leave with three sentences as your mental model.

  1. Side effects fire at three named boundaries only: Server Actions, route handlers, background jobs. Everything else is a pure function of its inputs.
  2. The action body is thin orchestration: parse, authorize, call /lib and db/queries, revalidate, return. When it grows uncomfortable, the next extraction goes to a /lib helper, never to an abstraction layer over the action itself.
  3. The decision rule: does this function touch a database, cache, queue, or external API? Yes → boundary. No → /lib.

Two more mistakes to sidestep. The first looks like good engineering: passing db as a parameter to a “pure” helper (createInvoice(db, data)) so you can swap it in tests. It resembles dependency injection, but the function is impure the moment it can write. The plainer default: unit-test the pure helpers, integration-test the action against a real database. The second is over-structuring: splitting /lib/invoices/ into both feature and layer subfolders (lib/invoices/repository/select.ts) before the feature needs it. Keep one folder per feature until it genuinely strains.

One quick check on the rule before the next lesson.

Your createInvoice now works. Product asks for one more step: once an invoice is created, email its PDF to the customer. The send goes through an email provider’s SDK. Run the decision rule on this new line — where does the send belong?

The SDK call lives behind a small lib/email.ts seam, and the action invokes that seam from its body once the row has committed.
A pure function in lib/invoices/, on the grounds that anything under /lib is automatically safe to reuse and to unit-test.
The final statement inside the insert’s db.transaction, so the row and the notification either both happen or neither does.
A reusable withNotification(...) wrapper layered over the action, so the same emailing logic can decorate every future action for free.

The body is thin now, every line in its home. Next come the last two seams: revalidatePath to tell the cache the data changed, db.transaction to make the multi-row write all-or-nothing, and the rule for what’s allowed to fire after the write commits.