skip to content

In a Next.js App Router app, one "cancel subscription" operation must be triggered both by a form in your own UI and by a partner's server over HTTP. How do you structure the code so the business rule is implemented only once?

level: seniorimportance: should knowfreq 42%

answer

  1. transport is not the rule
  2. two thin adapters, one core
  3. authentication differs, authorization does not
  4. never call yourself over HTTP
  5. keep the core out of an action file

basics

~20 s

Put the rule in a plain server module that knows nothing about HTTP, then make the Server Action and the route handler thin adapters over it. Each adapter authenticates its own caller, validates its own input, and shapes its own response.

solid answer

~50 s

I split transport from rule. The rule — load the subscription, check it belongs to the caller, apply the cancellation, record it — goes in an ordinary exported async function in a server-only module, with no request or response types in its signature. Then each surface is an adapter. The Server Action authenticates from the session, calls the function with the current user, and revalidates the affected paths so the UI reflects the change. The route handler authenticates the partner with their own credential, parses and validates the body, calls the same function, and maps success or a domain error onto a status code and a JSON payload. Two traps to avoid: having one surface call the other over HTTP, which buys you a network hop and a second failure mode, and putting the shared function in a file whose exports all become callable actions.

code

typescript · 10 lines
typescript
import { db } from '@/lib/db'

export class NotCancellableError extends Error {}

export async function cancelSubscription(actorId: string, subscriptionId: string) {
  const sub = await db.subscription.findById(subscriptionId)
  if (!sub || sub.ownerId !== actorId) throw new NotCancellableError('not found')
  if (sub.status === 'cancelled') throw new NotCancellableError('already cancelled')
  return db.subscription.cancel(sub.id)
}

go deeper

for a junior

Recognise that the same rule should not be written twice, and that a plain exported function can be imported by more than one server surface.

for a middle

Explain the layering concretely: a core function with no request or response types, plus adapters that handle parsing, credentials and response shaping for their own caller.

for a senior

Demonstrate the judgment: authentication in the adapter, authorization in the core, no self-calls over HTTP, and an honest account of where the two paths legitimately diverge on caching, errors and rate limits.

for a principal

Own the boundary as policy. Decide which operations are allowed a public adapter at all, how those contracts are versioned and credentialed, and how you keep the domain layer from leaking into transport code as the system grows.

## Why this question is asked Every real app eventually has an operation with two kinds of caller. The naive answers are to duplicate the logic, or to have one surface call the other. Both are wrong, and articulating why shows you understand what each Next.js surface is actually for. ## The layering Think of three layers rather than two surfaces. **The core.** A plain module — `lib/subscriptions.ts` — exporting `cancelSubscription(actorId, subscriptionId)`. Its signature contains no `Request`, no `NextRequest`, no `FormData`. It performs the domain checks (does this subscription exist, may this actor cancel it, is it already cancelled) and the write, and it signals failure in domain terms, either by returning a discriminated result or by throwing a typed error. Because it has no transport in it, it is trivially unit-testable and can gain a third caller — a CLI, a scheduled job — without change. **The action adapter.** A `'use server'` function invoked by the form. It reads the session to learn who the actor is, calls the core, and then does the UI-side work that only makes sense for a browser caller: revalidating the paths whose rendered output is now stale, and returning a value the form can display. **The route handler adapter.** A `POST` export that authenticates the partner with an API key or signature — a completely different credential model from the session — parses and validates the JSON body, calls the core, and translates the outcome to HTTP: `200` or `204` on success, `404` when the subscription is unknown, `409` when it is already cancelled, `401` when the credential fails. ## What goes in the adapter and what goes in the core The useful split is **authentication versus authorization**. *Who is calling* is transport-specific: a cookie in one case, a signed header in the other, so it belongs in the adapter. *May this actor perform this operation on this record* is a domain rule, identical for both callers, so it belongs in the core. Getting this backwards is the common failure — an ownership check written only in the Server Action means the partner's HTTP path skips it entirely, which is exactly the bug an interviewer is probing for. Input validation appears in both layers and that is fine: the adapter validates shape (is this JSON, are the fields present and of the right type), the core validates meaning (does this subscription exist and is it in a cancellable state). ## The two traps **Calling one surface from the other over HTTP.** Having the route handler `fetch` your own action's endpoint, or having the action `fetch` your own API route, adds a network round trip inside your own process boundary, a second serialization, a second set of timeouts, and a second thing that can be down. The whole point of the core function is that both callers are already on the server and can just call it. **Exporting the core from an action module.** If the shared function lives in a file with a top-level `'use server'`, every export in that file becomes an invocable endpoint, and you have quietly published your domain layer. Keep the core in a normal module and let only the adapter files carry the directive. ## What the two callers legitimately do differently Even with a shared core, the adapters are not symmetric, and saying so is what separates a senior answer from a textbook one: - **Cache invalidation** matters to the browser path, because a page is rendering that data. The partner call has no rendered UI, though it may still need to revalidate paths so your own users see the change. - **Error surface** differs. The UI wants a message a human reads; the partner wants a stable machine-readable code and status. - **Rate limiting and auditing** are usually stricter on the public path, because its caller is outside your deployment. - **Compatibility** differs. The action can change shape in the same commit as the form that calls it. The route handler's request and response are a published contract you must version. ## The one-sentence version Surfaces are adapters; rules are functions. Decide the rule once, then let each caller reach it through the surface that matches who they are.

  • Could you skip the shared function and just have the route handler import and call the Server Action directly?
    Technically yes — an action is an ordinary async function on the server, so importing it works. But it drags the browser caller's concerns into the partner path: session assumptions, UI-shaped return values, cache revalidation the partner did not ask for. It also makes the action's signature a contract for two very different callers. Extracting the core costs one file and keeps both adapters honest.
  • Where should the ownership check live, and what happens if you put it in the wrong layer?
    In the core. Authentication — reading a session cookie versus verifying a partner credential — is transport-specific and belongs in the adapter, but "may this actor cancel this subscription" is a domain rule. If it lives only in the Server Action, the HTTP path reaches the write without it, which is an authorization bypass rather than a style problem.

saying these in an interview costs you the question

  • Just duplicate the logic in both places and keep them in sync
  • Have the route handler fetch your own Server Action endpoint
  • Put the ownership check in the UI-facing surface only
  • Export the shared function from a 'use server' file
  • One shared function means both callers need identical responses

context