Loading...
Loading...
[Pragmatic DDD Architecture] Guide for creating Next.js Server Actions exactly tied to zod, neverthrow, and the domain architecture. Use when creating or editing any file in presentation/actions/. Covers "use server" placement, unknown parameters validated with `zod`, discriminated union response types, auth-first pattern, Value Object validation with TypeScript narrowing, use-case error mapping via `assertNever`, serviceContainer invocation, and revalidation strategy.
npx skill4agent add leif-sync/pragmatic-ddd server-actions<verb><Resource>ActioncreateShortUrlActiondeleteFolderActionunknown"use server"; // (1) Always first line
import { z } from "zod";
import { assertNever } from "@/shared/domain/utils/assertNever";
import { err, ok } from "neverthrow";
import { SessionError, type GetSession } from "@/auth/getSession";
export type MyActionErrorCode =
| "NOT_AUTHENTICATED"
| "INVALID_INPUT"
| "INVALID_ID"
| "NOT_FOUND"
| "UNEXPECTED_ERROR";
// (2) Exported types. MUST return branded types, NEVER Value Objects or Entities explicitly.
// Some client components need validated data; since we use Result<T,E>, components cannot throw errors.
export type MyActionResponse =
| { success: true; data: BrandedUUID }
| { success: false; error: MyActionErrorCode };
// (3) Zod Schema for incoming unknown parameters
// Zod CAN validate native/primitive types (e.g., `z.string()`, `z.number()`, `z.boolean()`) and specific common formats (e.g., `z.uuid()`, `z.email()`, `z.url()`).
// You MUST NOT use Zod for specific business logic boundaries like checking if a number is greater than/less than a specific amount, or specific lengths.
// Those specific business rules strictly belong in Value Objects.
// Zod validation returns a generic "INVALID_INPUT", while VO validation returns descriptive, domain-specific errors.
const formDataSchema = z.object({
id: z.uuid(),
});
// (4) Map domain errors exhaustively to action error codes
function handleMyUseCaseErrors(error: MyUseCaseErrors): MyActionErrorCode {
if (error instanceof NotFoundError) return "NOT_FOUND";
if (error instanceof SessionError) return "NOT_AUTHENTICATED";
if (error instanceof RepositoryError) return "UNEXPECTED_ERROR";
return assertNever(error); // Prevents missing union members
}
// (5) The action function ALWAYS takes `unknown` to ensure type-safety at runtime
export async function myAction(rawData: unknown): Promise<MyActionResponse> {
// zod parsing → vo validation → getSession construction → use case → return
}successerrorstringsuccess: trueBrandedUUID// Simple: single error field returning a Branded Type
export type CreateFolderActionResult =
| { success: true; folderId: BrandedUUID }
| { success: false; error: CreateFolderActionErrorCode };
// Form actions: multiple error codes possible using an array
export type CreateShortUrlActionResponse =
| { success: true }
| { success: false; errors: CreateShortUrlActionErrorCodes[] };"INVALID_INPUT""UNEXPECTED_ERROR"unknownrawData: unknownz.string()export async function createThingAction(rawData: unknown): Promise<CreateThingResponse> {
const paramsParse = formDataSchema.safeParse(rawData);
if (!paramsParse.success) return { success: false, error: "INVALID_INPUT" };
// Proceed to Value Object instantiation...
}getSession: GetSession const getSession: GetSession = async () => {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return err(new SessionError());
const userIdResult = UUID.from(session.user.id);
if (userIdResult.isErr()) return err(new SessionError());
return ok({ userId: userIdResult.value });
};neverthrowconst workspaceIdResult = UUID.from(formData.workspaceId);
const nameResult = ShortUrlName.from(formData.name);
const errors: CreateShortUrlActionErrorCodes[] = [];
if (workspaceIdResult.isErr()) errors.push("INVALID_WORKSPACE_ID");
if (nameResult.isErr()) errors.push("INVALID_NAME");
// Guard ensures narrow types
if (
errors.length > 0 ||
workspaceIdResult.isErr() ||
nameResult.isErr()
) {
return { success: false, errors };
}
// All are now Ok -> `.value` avoids _unsafeUnwrap!
const result = await serviceContainer.foo.create.execute({
workspaceId: workspaceIdResult.value,
name: nameResult.value,
getSession
});assertNeverassertNeverassertNeverfunction handleCreateShortUrlErrors(error: CreateShortUrlErrors): CreateShortUrlActionErrorCodes {
if (error instanceof UserNotFoundError) return "USER_NOT_FOUND";
if (error instanceof WorkspaceNotFoundError) return "WORKSPACE_NOT_FOUND";
if (error instanceof PathAlreadyInUseError) return "PATH_ALREADY_IN_USE";
if (error instanceof SessionError) return "NOT_AUTHENTICATED";
if (error instanceof RepositoryError) return "UNEXPECTED_ERROR"; // Must explicitly map!
// Enforces TS compile-time error if new use-case errors are unhandled
return assertNever(error);
}RepositoryError"UNEXPECTED_ERROR"serviceContainerimport { serviceContainer } from "@/shared/infrastructure/bootstrap";
const result = await serviceContainer.folders.createFolder.execute({ ... });"use server";
import { z } from "zod";
import { auth } from "@/auth/auth";
import { headers } from "next/headers";
import { err, ok } from "neverthrow";
import { serviceContainer } from "@/shared/infrastructure/bootstrap";
import { BrandedUUID, UUID } from "@/shared/domain/value-objects/uuid";
import { ShortUrlName } from "@/short-urls/domain/value-objects/shortUrlName";
import type { CreateShortUrlErrors } from "@/short-urls/use-cases/createShortUrl";
import { PathAlreadyInUseError } from "@/short-urls/domain/errors/pathAlreadyInUseError";
import { UserNotFoundError } from "@/users/domain/errors/userNotFoundError";
import { RepositoryError } from "@/shared/domain/errors/repositoryError";
import { assertNever } from "@/shared/domain/utils/assertNever";
import { GetSession, SessionError } from "@/auth/getSession";
export type CreateShortUrlActionErrorCode =
| "NOT_AUTHENTICATED"
| "INVALID_INPUT"
| "INVALID_NAME"
| "USER_NOT_FOUND"
| "PATH_ALREADY_IN_USE"
| "UNEXPECTED_ERROR";
export type CreateShortUrlActionResponse =
| { success: true; id: BrandedUUID } // MUST output primitives or branded primitive types.
| { success: false; error: CreateShortUrlActionErrorCode };
function handleCreateShortUrlActionErrors(error: CreateShortUrlErrors): CreateShortUrlActionErrorCode {
if (error instanceof UserNotFoundError) return "USER_NOT_FOUND";
if (error instanceof PathAlreadyInUseError) return "PATH_ALREADY_IN_USE";
if (error instanceof SessionError) return "NOT_AUTHENTICATED";
if (error instanceof RepositoryError) return "UNEXPECTED_ERROR";
return assertNever(error);
}
const formDataSchema = z.object({
name: z.string(),
});
export async function createShortUrlAction(rawData: unknown): Promise<CreateShortUrlActionResponse> {
const parseResult = formDataSchema.safeParse(rawData);
if (!parseResult.success) {
return { success: false, error: "INVALID_INPUT" };
}
const nameResult = ShortUrlName.from(parseResult.data.name);
if (nameResult.isErr()) {
return { success: false, error: "INVALID_NAME" };
}
const getSession: GetSession = async () => {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return err(new SessionError());
const userIdResult = UUID.from(session.user.id);
if (userIdResult.isErr()) return err(new SessionError());
return ok({ userId: userIdResult.value });
}
const result = await serviceContainer.shortUrls.create.execute({
name: nameResult.value,
getSession
});
if (result.isErr()) {
return { success: false, error: handleCreateShortUrlActionErrors(result.error) };
}
// Value Objects MUST cross to the client using `.toBranded()`
return { success: true, id: result.value.getId().toBranded() };
}todo.tstodo.tscore-utilities.mdtodo.panic("missing mapping")todo.unimplemented("WIP")neverthrow