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.
Three kinds of code in one body
Section titled “Three kinds of code in one body”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.
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.
import type { z } from 'zod';import type { lineItemSchema } from '@/lib/invoices/schema';
type LineItem = z.infer<typeof lineItemSchema>;
export const calculateInvoiceTotal = (lineItems: LineItem[]): number => lineItems.reduce((total, line) => total + line.quantity * line.unitAmount, 0);const total = calculateInvoiceTotal(lineItems);The math lives in /lib as a pure function named for what it does, and the action calls it in one line. LineItem is derived from the line-item schema with z.infer, not hand-written, so when the schema’s shape changes, this type follows for free. The explicit : number return type is the convention for exported helpers.
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 — wrongimport { 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.
The three layers under a feature
Section titled “The three layers under a feature”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
- actions.ts
Directorylib/
Directoryinvoices/
- calculate-total.ts pure, no
db, nocookies() - policy.ts pure predicates,
canCreateInvoice(user, org)
- calculate-total.ts pure, no
- 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
dbimporter
- invoices.ts tenant-scoped reads + writes, the only
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.
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.
Don’t wrap the framework’s seam
Section titled “Don’t wrap the framework’s seam”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.
'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); } // authorize, then mutate, then return — all visible, in order}The parse line is repeated per action, on purpose. A reviewer reads top to bottom and sees every seam in order, with nothing hidden behind a generic call. The repetition is small, and it’s legible.
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.
The two wrappers that earn their weight
Section titled “The two wrappers that earn their weight”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.
The thin action, assembled
Section titled “The thin action, assembled”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.
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.
- Side effects fire at three named boundaries only: Server Actions, route handlers, background jobs. Everything else is a pure function of its inputs.
- The action body is thin orchestration: parse, authorize, call
/libanddb/queries, revalidate, return. When it grows uncomfortable, the next extraction goes to a/libhelper, never to an abstraction layer over the action itself. - 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?
lib/email.ts seam, and the action invokes that seam from its body once the row has committed.lib/invoices/, on the grounds that anything under /lib is automatically safe to reuse and to unit-test.db.transaction, so the row and the notification either both happen or neither does.withNotification(...) wrapper layered over the action, so the same emailing logic can decorate every future action for free.lib/email.ts adapter called from the action body after the commit. Apply the rule: sending an email touches an external service, so it is a side effect and fires at a boundary — the action body, never /lib itself. But the raw SDK is centralized behind a thin lib/email.ts adapter, the same carve-out reasoning as the billing interface: one concern, one named seam, so the SDK has a single home. A pure helper can’t send anything (the moment it calls the network it stops being pure), so that option mis-slices it. Putting the send inside the transaction couples a real-world effect you can’t undo to a write that might still roll back — an email you can never recall for a row that no longer exists. And a generic withNotification(...) wrapper fails Principle #5’s bar: it’s a parallel mechanism over the framework’s seam for a concern that doesn’t clear the exception. The send happens from the body, after the commit — the exact ordering the next lesson makes precise.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.
External resources
Section titled “External resources”The framework convention Principle #5 says to use directly. Skim how Next.js treats a 'use server' export as the mutation seam.
The well-built action wrapper the course deliberately doesn't default to. Read it to understand the trade, then decide at your own team's threshold.
Google's Testing Blog names exactly the split you just made: a pure core, side effects pushed to a thin shell, for code you can test without setup.
The official rationale for the Data Access Layer: why every db touch funnels through one audited file, and why a 'use server' export is a public endpoint.