Thin actions, pure /lib
The architecture pattern that keeps Server Actions thin by sorting every line into pure logic in /lib, side effects at named boundaries, and orchestration in the action body.
Across the last three lessons your createInvoice action has been growing. It started as a skeleton, then you added safeParse on entry, a check that the invoice number isn’t already taken, a calculation of the invoice total from the line items, the db insert, and the mapping of every failure into a Result. Each addition was correct. Stacked together, the body is now long enough that you can’t take it in at a glance: you have to read it top to bottom to know what it does.
That length is the symptom, not the cause. Look closer and you’ll see the lines are not all the same kind of work. Computing a total is pure arithmetic: give it the same line items and it returns the same number every time, touching nothing outside its inputs. Reading the database to check the invoice number, and writing the new row, are the opposite. They reach out and touch the world, and they can fail for reasons that have nothing to do with the inputs. A third kind of line is neither of those: the if (!parsed.success) branches, the sequencing, and the final return only decide what runs when. So the function braids three kinds of work together.
This lesson teaches you to pull them apart, and, just as importantly, to know when to stop pulling. By the end you’ll be able to look at any line in an action and say “that belongs in /lib” or “that stays in the body,” with a one-sentence reason. You already write safeParse and return a Result, and nothing new gets added to that vocabulary here. What changes is where the code that produces them lives, and why the course doesn’t wrap your actions in a clever helper even though it’s tempting and a good library exists to do it.
The action body that grew too big
Section titled “The action body that grew too big”Here is the body, assembled from everything the last three lessons added. Read it once and notice how much your eyes have to travel.
'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 reads no cookie, opens no connection, and leaves no trace on the world. You could run it a thousand times in a test with no 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 reach outside their arguments to read from or write to the world. They can fail for reasons the inputs can’t predict, such as the connection dropping 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. It sequences the other two kinds and reports the result.
Notice that the colors are interleaved: green, then orange, then green again, then blue wrapping all of it. That braiding is the problem. Three kinds of code, with three different reasons to change, three different ways to test, and three different opportunities for reuse, all share one function body.
The braiding matters not because of tidiness but because the three kinds have genuinely different lives. The total math will outlive this action: a CSV export will need it, a public invoice page will need it, and when the way you compute money has to change, you want one place to change it. The database lines can only be exercised against a real Postgres, so anything tangled with them inherits that weight. The orchestration is specific to this action, and it’s the one part that genuinely belongs in the body. Tangle them and the pure math can no longer be tested without a database, the database code becomes hard to reuse, and a reviewer has to mentally untangle all three every time they read the function.
So the rest of this lesson answers a narrow, practical question: which of these lines move out of the body, where do they go, and what earns the right to stay.
Pure logic in /lib, side effects at named boundaries
Section titled “Pure logic in /lib, side effects at named boundaries”Start with the green. The rule that tells you where the total calculation belongs carries a lot of weight through the rest of the course, so it’s worth stating in full.
That word pure carries the whole principle, so be precise about it. A pure function has two properties: the same inputs always produce the same output, and it leaves no mark on the world, writing no row, setting no cookie, sending no request. The opposite is a side effect , where the function reaches outside its arguments to read or change something. The total calculation is pure: line items in, money out. The invoice-number check is not, because it reads the database, and the answer depends on what other rows happen to exist right now.
Principle #3 sorts every line by that test. Pure logic moves into /lib, where it can be imported by anything and tested in isolation. Side effects stay at a boundary, and an action is one of the three boundaries, because that’s the one place the course allows a function to touch the world.
So pull the green out. The total math becomes a pure function in its own file, and the action calls 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, mixed in with the side-effectful insert right below it. To test this arithmetic you’d have to call the whole action, which means standing up a database. The pure math is trapped by the I/O it sits next to.
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 now lives in /lib as a pure function, named after what it does, and the action calls it in one line. LineItem is derived from the line-item input schema with z.infer, not hand-written. That schema is the source of truth for a submitted line, so when its shape changes, this type follows for free. The explicit : number return type is the convention for exported helpers.
That extraction is small, but it buys three things you’ll feel concretely as the course goes on.
Tests run without a database. calculateInvoiceTotal takes an array and returns a number. A unit test passes it line items and asserts the total: no database, no mocks, no setup. When you reach the testing chapters and write your first money-path tests, this is the function you’ll test first, precisely because it’s pure.
One helper, many boundaries. The same function feeds today’s createInvoice action, the CSV-export job you’ll build later, and a route handler that renders a public invoice. The day a customer reports a rounding bug, there’s exactly one place to fix it, and every caller is fixed at once. Inline in the action, that math would have to be copied, and copies drift.
The action reads as a sequence. A reviewer scanning the action sees calculateInvoiceTotal(lineItems) and moves on, without re-deriving the arithmetic in their head to trust it. The body becomes a list of named steps, and named steps are easy to scan.
There’s a way to get this exactly backwards, and it’s worth naming because it feels reasonable in the moment. 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 this helper imports db, so it has a side effect, which means it has been mis-sliced. The fix is not to move it back into the action verbatim. It’s to recognize that the pure part already left (the total math) and the side effect belongs at the boundary or in the data-access layer you’re about to meet, never hiding in a generic /lib helper. The moment /lib becomes a place side effects can hide, you’ve lost the one guarantee that made it worth having: that anything in /lib is safe to call in a test.
The three layers under a feature
Section titled “The three layers under a feature”You’ve moved one pure helper out. To give every line a home, you need the two other kinds of file the course settles on for each feature. Together they form a small, repeatable shape you’ll follow from the CRUD project a few chapters from now onward, and in every SaaS feature you build after it.
The first you’ve already built: pure helpers like lib/invoices/calculate-total.ts. Logic, no IO, one function per file, verb-named after what it computes.
The second is the data-access layer, the single file in the feature that’s allowed to import db. It lives at db/queries/invoices.ts and exports verb-led reads and writes: findInvoiceByNumber(organizationId, number), insertInvoice(data), insertInvoiceLines(tx, invoiceId, lineItems). Concentrating database access in one named file per entity means there’s one place to look when you ask “how does this feature touch the database,” and one place the eventual tenant-scoping rules will land. You may have seen this layer called a repository elsewhere. This course names the file db/queries/<entity>.ts, and that’s the name to use throughout.
The third is the policy layer at lib/invoices/policy.ts, which holds authorization predicates . A predicate is just a function that returns a boolean: canCreateInvoice(user, org): boolean. The key move is that it’s pure: it takes the already-loaded user as an argument and answers a yes/no question about their role. It does not read the session itself, because reading the session is a side effect that happens at the boundary. The predicate only decides; the action does the reading and then asks the predicate.
The action file ties the three together. It lives at app/invoices/actions.ts with a file-level 'use server', and its job is thin orchestration: call each layer in sequence and shape the result. Here’s the whole shape at a glance.
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
Two things make this shape work. Pure logic and policy live under lib/<feature>/, where they’re testable in isolation. Database access lives under db/queries/<feature>.ts, the one file that imports db. The action body imports all three but contains none of their internals: it’s the boundary where the side effects fire and the only place that knows the order things happen in.
Once the shape is in your head, classifying a new line is mechanical. Here’s the test:
The rule earns its keep on the cases that look like one thing and are really two. Take “validate a payment and reserve inventory.” That sounds like a single helper. Run it through the test and it splits cleanly: validatePayment is pure logic (does the amount match the cart, is the currency allowed) and goes in /lib, while reserveInventory writes to the world and is called from the action body. One sentence, two homes. The skill isn’t memorizing where things go; it’s 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 in that drill are worth dwelling on. The policy predicate lands in /lib even though it sounds like authorization, because the function itself only does boolean arithmetic on a user it was handed. And “check the invoice number isn’t taken” lands at the boundary even though it feels like validation, because answering it requires a database round-trip, and the answer changes depending on what rows exist. The rule cuts through the impression: it doesn’t ask what the work feels like, it asks whether the work touches the world.
Don’t wrap the framework’s seam
Section titled “Don’t wrap the framework’s seam”Now for the part where you learn to stop.
You’ve extracted the pure logic. The action body is shorter, but it still has a repetitive top: every action you write parses its input, authorizes the caller, and returns a Result. After you write that same three-line preamble for the third action in a row, the question arises naturally: why not factor it out? You could write one generic wrapper that owns parse, authorize, and result, and call it like this:
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 isn’t a strawman: it’s roughly what real libraries like next-safe-action ship, and it genuinely looks clean. Every action loses its boilerplate top and you write only the part that’s different. The call site reads appealingly 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 the body 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 version is shorter and genuinely tempting, so the principle that argues against it has to make a real case.
Here’s what the wrapper actually costs, so the decision is reasoned rather than dogmatic.
It blurs what the compiler sees. Next.js analyzes your 'use server' exports directly. That’s how it emits the opaque action IDs, strips action source from the client bundle, and encrypts captured closure values, all of which you saw when you first met the directive. A wrapper sits between the export and the framework’s view of it. The more indirection between export and 'use server', the more you risk confusing the static analysis the platform depends on.
It hides the seams from the reviewer. safeAction(schema, fn) collapses parse, authorize, mutate, revalidate, and return behind a single call. The reviewer can no longer scan the action and see those five steps happen in order; they have to read the wrapper to know what it does on their behalf. The repetition the wrapper removed was the thing making the code legible, so removing it trades a small cost (a few repeated lines) for a real one (you can’t read an action without also reading its framework).
It’s a custom language only your team speaks. A new hire already knows 'use server', Zod, and Result, because those are industry knowledge. They do not know your safeAction; it’s a small DSL they have to learn before they can read a single action. When the abstraction it replaces is just five well-understood lines, that extra thing to learn isn’t worth the keystrokes it saves.
So the 2026 default is to skip the wrapper and write the parse line again. The five seams are small, and the repetition is a feature: it’s what lets a reviewer trust an action at a glance. This isn’t ignorance of the alternatives. next-safe-action and zsa are real, well-built libraries, and a large team standardizing dozens of actions might genuinely cross the threshold where one of them pays off. But that’s a choice you make past a clear bar, not the place you start. The default is the framework’s seam, written plainly.
The urge peaks at a specific moment, so watch for it: right after you finish the third near-identical action body. That’s when the repetition feels unbearable and the wrapper feels like wisdom. The move at that moment is to type the parse line one more time, not to abstract it away.
The two wrappers that earn their weight
Section titled “The two wrappers that earn their weight”There’s a contradiction to resolve here, because without it the course will look like it’s breaking its own rule later. Principle #5 says don’t wrap the action, and yet this course will ship exactly one action wrapper and one SDK interface. Why are those allowed when safeAction isn’t?
Because an exception has to clear a bar, and stating that bar gives you a reusable test for any future “should this be a wrapper?” question. The bar is three things at once: a single concern, that is identical boilerplate at every call site, and where getting it wrong is an incident, not a style nit. A generic safeAction fails on the first count, because it bundles several unrelated concerns. Two concerns clear all three.
Those two are the whole list. Authorization and the billing SDK clear the bar; nothing else in an action does. Parse, business-rule checks, the transaction, and the revalidate all stay inline in the body. When you reach those later chapters and wire the two wrappers up, it’ll be assembly rather than invention, because you already know exactly why they exist and why they’re the only two.
The thin action, assembled
Section titled “The thin action, assembled”Put it all together and look at what createInvoice becomes. It has the same behavior as the fat body you started with: same parse, same uniqueness check, same total, same insert. But now every line is either a call into a named layer or a piece of orchestration. Walk it once more, this time as a sequence.
'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, the orchestration entry. This is the same line you’ve always written, and it 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. The read-then-predicate split is exactly what the auth wrapper later collapses, but the shape is the same.
'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, a database read, so it lives behind db/queries/invoices.ts, the one file that touches db.
'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 same green math from the opener, 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; there the two inserts get wrapped in one atomic db.transaction. The return shapes the outcome into a Result. The body is now mostly blue orchestration and named calls, with the three colors separated rather than braided.
This is the same function as the opener, doing the same work.
Notice what’s not in the import list: db.
The fat body imported it directly; this one doesn’t, because every database touch now goes through db/queries/invoices.
The green and orange have left the body, pushed out to files that own them, and what’s left reads as a list of named steps a reviewer can trust in seconds.
That’s the payoff: the three braided colors from the first section now live in three homes, with a body that’s mostly the blue spine. 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 a new abstraction layer over the action itself. - The one decision rule: does this function touch a database, cache, queue, or external API? Yes → boundary. No →
/lib.
Two more mistakes to sidestep, now that the shape is clear. The first looks like good engineering: passing db as a parameter to a “pure” helper, as in createInvoice(db, data), so you can swap it in tests. It resembles dependency injection, but the function is impure the moment it can write, so you’ve gained nothing and lost the simple rule. The 2026 default is plainer than DI: 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 is big enough to need it. Keep one folder per feature until it genuinely strains, because premature structure is just a different kind of weight.
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, and every line is in the right home. What’s still missing are the last two seams: the revalidatePath that tells the cache the data changed, and the db.transaction that makes a multi-row write all-or-nothing, plus the rule for what’s allowed to fire after the write commits. That’s the next lesson.
Going further
Section titled “Going further”Two anchors are worth reading after this lesson: one for the convention you’re leaning on, one for the wrapper you’re deliberately not reaching for.
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.
External resources
Section titled “External resources”Two anchors for the deeper ideas under this lesson: the name for the /lib-and-boundary split you just made, and the official rationale for the data-access layer.
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.