Skip to main content

Interceptor Middlewares

Arkos automatically generates RESTful API endpoints for your Prisma models, but your application needs custom business logic. Interceptor middlewares let you hook into these auto-generated endpoints to add your own processing logic without losing the power of automation - both for your Prisma model operations and built-in authentication endpoints.

Think of interceptor middlewares as request-level hooks that let you run custom code before and after Arkos handles your API requests. They work just like Express middlewares but are specifically designed to intercept the auto-generated CRUD operations.

// Your auto-generated POST /api/posts endpoint becomes:
Request → beforeCreateOne → Arkos Create Logic → afterCreateOne → Response

This gives you the flexibility of custom Express apps while keeping the speed of auto-generated APIs.

Philosophy: Separation of Concerns

Following Express.js best practices, Arkos encourages a clean separation between reusable middleware functions and interceptor chains:

.middlewares.ts - Reusable Functions

Store your reusable middleware functions here - just like you would in any Express application:

// src/modules/post/post.middlewares.ts
import { ArkosRequest, ArkosResponse, ArkosNextFunction } from "arkos";
import { AppError } from "arkos/error-handler";

export const validatePostOwnership = async (
req: ArkosRequest,
res: ArkosResponse,
next: ArkosNextFunction
) => {
const post = await postService.findOne({ id: req.params.id });

if (post.authorId !== req.user.id && req.user.role !== "Admin") {
throw new AppError("You can only modify your own posts", 403);
}

res.locals.originalPost = post;
next();
};

export const sanitizePostContent = async (
req: ArkosRequest,
res: ArkosResponse,
next: ArkosNextFunction
) => {
if (req.body.content) {
req.body.content = sanitizeHtml(req.body.content);
}
next();
};

export const checkPostQuota = async (
req: ArkosRequest,
res: ArkosResponse,
next: ArkosNextFunction
) => {
const count = await postService.count({ authorId: req.user.id });

if (count >= req.user.maxPosts) {
throw new AppError("Post quota exceeded", 429);
}
next();
};

Benefits:

  • Reusable across different interceptor chains
  • Testable in isolation
  • Clear single responsibility
  • Standard Express middleware pattern

.interceptors.ts - Orchestration

Chain your middleware functions together:

// src/modules/post/post.interceptors.ts
import {
validatePostOwnership,
sanitizePostContent,
checkPostQuota,
} from "./post.middlewares";

export const beforeUpdateOne = [
validatePostOwnership,
sanitizePostContent,
checkPostQuota,
];

export const beforeCreateOne = [sanitizePostContent, checkPostQuota];

Why this matters:

  • Reusability: Write validatePostOwnership once, use it in multiple interceptors
  • Testability: Test middleware functions independently
  • Readability: Interceptor chains become self-documenting
  • Maintainability: Change business logic in one place

This is the Express.js way - and Arkos embraces it fully.

Setting Up Interceptor Middlewares

Generating Interceptors with CLI

Quickly scaffold interceptor files using the Arkos CLI:

npx arkos generate interceptors --module user

Shorthand:

npx arkos g i -m user

File Structure

Interceptor middlewares follow Arkos's convention-based structure with two files:

my-arkos-project/
└── src/
└── modules/
└── [model-name]/
├── [model-name].middlewares.ts # Reusable functions
└── [model-name].interceptors.ts # Interceptor chains
File Naming Change

Starting from v1.4.0-beta, the recommended file naming convention has changed from .middlewares.ts to .interceptors.ts. This change provides better separation of concerns:

  • .interceptors.ts - For request-level hooks that intercept auto-generated endpoints
  • .middlewares.ts - For reusable Express middleware functions used within interceptors

This naming better reflects their purpose and aligns with the framework's architecture.

Deprecation Notice

While .middlewares.ts files still work in v1.4.0-beta for backward compatibility, you'll see this warning:

Found deprecated post.middlewares.ts that will be removed from v1.6.0-beta, consider switching to post.interceptors.ts

Timeline:

  • v1.4.0-beta: Both .middlewares.ts and .interceptors.ts work, warnings shown
  • v1.5.0-beta: .middlewares.ts still works with deprecation warnings
  • v1.6.0-beta: .middlewares.ts will be completely removed, only .interceptors.ts supported
Important

It's important to follow the convention above because Arkos expects to find those files there as it auto-discovers them. Note also that the model name must be in kebab-case (e.g., UserProfile becomes user-profile).

Basic Example: Proper Separation

Step 1: Create reusable middleware functions

// src/modules/post/post.middlewares.ts
import { ArkosRequest, ArkosResponse, ArkosNextFunction } from "arkos";

export const generateSlug = async (
req: ArkosRequest,
res: ArkosResponse,
next: ArkosNextFunction
) => {
if (req.body.title && !req.body.slug) {
req.body.slug = req.body.title.toLowerCase().replace(/\s+/g, "-");
}
next();
};

export const notifySubscribers = async (
req: ArkosRequest,
res: ArkosResponse,
next: ArkosNextFunction
) => {
const post = res.locals.data.data;

// Send notification (don't block response)
notificationService
.notifySubscribers(`New post: ${post.title}`)
.catch(console.error);

next();
};

Step 2: Chain them in interceptors

// src/modules/post/post.interceptors.ts
import { generateSlug, notifySubscribers } from "./post.middlewares";

export const beforeCreateOne = [generateSlug];

export const afterCreateOne = [notifySubscribers];
No catchAsync Needed (v1.3.0+)

Since v1.3.0-beta, you no longer need to wrap interceptor middlewares in catchAsync - Arkos handles this automatically. This applies to both ArkosRouter route handlers and interceptor middlewares.

Type-Safe Interceptors with Generics

One of Arkos's most powerful features is full type safety across your interceptors. By leveraging TypeScript generics, you get autocomplete, compile-time checks, and catch errors before they reach production.

Understanding Generic Types

Arkos provides fully typed request and response objects:

ArkosRequest Generic Signature

ArkosRequest<Params, ResBody, ReqBody, Query>;
Generic ParameterDescriptionExample
ParamsURL parameters (:id, :slug){ id: string }
ResBodyResponse body type (rarely used)User
ReqBodyRequest body typeCreateUserInput
QueryQuery string parameters{ page: number; limit: number }

ArkosResponse Type

ArkosResponse<ResBody, Locals>;

The response object is typed to ensure type-safe access to res.locals:

// Type-safe locals access
res.locals.data; // Response data
res.locals.status; // HTTP status code
res.locals.additional; // Additional operation data
res.locals.myCustomData; // Your custom data

Combining Validation + Prisma Types

The real power comes from combining your validation schemas (Zod/class-validator) with ArkosPrismaInput<T> for complete type safety across API validation and Prisma relations.

Why Combine Them?

  • API Validation: Zod/class-validator ensures incoming data is valid
  • Prisma Relations: ArkosPrismaInput handles nested create/connect/update operations
  • Together: Full type safety from request validation to database

Let's see this in action:

Step 1: Define your validation schema

// src/modules/user/schemas/create-user.schema.ts
import { z } from "zod";

export const CreateUserSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
age: z.number().min(18).optional(),
profile: z
.object({
bio: z.string().optional(),
isPublic: z.boolean().default(true),
})
.optional(),
});

export type CreateUserSchemaType = z.infer<typeof CreateUserSchema>;

Step 2: Create type-safe middleware functions

// src/modules/user/user.middlewares.ts
import { ArkosRequest, ArkosResponse, ArkosNextFunction } from "arkos";
import { Prisma } from "@prisma/client";
import { ArkosPrismaInput } from "arkos/prisma";
import { CreateUserSchemaType } from "./schemas/create-user.schema";
import { AppError } from "arkos/error-handler";

// Combine API validation with Prisma relation handling
type CreateUserBody = CreateUserSchemaType &
ArkosPrismaInput<Prisma.UserCreateInput>;

type UserParams = { id: string };

export const addDefaultProfile = async (
req: ArkosRequest<UserParams, any, CreateUserBody>,
res: ArkosResponse,
next: ArkosNextFunction
) => {
// TypeScript knows: req.body.name, req.body.email (from Zod)
// TypeScript knows: req.body.posts, req.body.profile (from Prisma)

if (!req.body.profile) {
req.body.profile = {
bio: "New user",
isPublic: true,
};
}

next();
};

export const validateEmailUniqueness = async (
req: ArkosRequest<UserParams, any, CreateUserBody>,
res: ArkosResponse,
next: ArkosNextFunction
) => {
const existing = await prisma.user.findUnique({
where: { email: req.body.email },
});

if (existing) {
throw new AppError("Email already registered", 409, "EmailExists");
}

next();
};

export const handleUserPosts = async (
req: ArkosRequest<UserParams, any, CreateUserBody>,
res: ArkosResponse,
next: ArkosNextFunction
) => {
// ArkosPrismaInput allows intuitive relation handling
if (req.body.posts) {
// posts can be:
// - [{ title: "First" }] → create
// - [{ id: 1 }] → connect
// - [{ id: 2, title: "Updated" }] → update

req.body.posts = req.body.posts.map((post) => ({
...post,
published: false,
}));
}

next();
};

Step 3: Chain them in interceptors

// src/modules/user/user.interceptors.ts
import {
addDefaultProfile,
validateEmailUniqueness,
handleUserPosts,
} from "./user.middlewares";

export const beforeCreateOne = [
validateEmailUniqueness,
addDefaultProfile,
handleUserPosts,
];

export const beforeUpdateOne = [validateEmailUniqueness, handleUserPosts];

What you get:

  • Full type safety: TypeScript knows both API fields (from Zod or Class-validator) AND Prisma relations
  • Auto-completion: IDE suggests req.body.name, req.body.posts, etc.
  • Compile-time checks: Typos and type mismatches caught before runtime
  • Reusable functions: Write once, use in multiple interceptors

Learn more about the TypeScript relation handler utility type in Arkos Prisma Input Guide.

Accessing response data after operations in After Interceptors

After interceptors have full type-safe access to response data via res.locals:

// src/modules/user/user.middlewares.ts
import { ArkosRequest, ArkosResponse, ArkosNextFunction } from "arkos";

export const removePassword = async (
req: ArkosRequest,
res: ArkosResponse,
next: ArkosNextFunction
) => {
const user = res.locals.data.data;

// Remove sensitive fields
delete user.password;
delete user.verificationToken;

// Update the response data
res.locals.data.data = user;

next();
};

export const addComputedFields = async (
req: ArkosRequest,
res: ArkosResponse,
next: ArkosNextFunction
) => {
const user = res.locals.data.data;

// Add computed fields
res.locals.data.data = {
...user,
fullName: `${user.firstName} ${user.lastName}`,
age: calculateAge(user.birthdate),
};

next();
};
// src/modules/user/user.interceptors.ts
import { removePassword, addComputedFields } from "./user.middlewares";

export const afterCreateOne = [removePassword, addComputedFields];

export const afterFindMany = [removePassword, addComputedFields];

Before Interceptor Middlewares

Before interceptors run before the main Arkos operation. They're perfect for validation, data preprocessing, and permission checks.

Available Before Interceptors

export const beforeCreateOne = [];
export const beforeCreateMany = [];
export const beforeUpdateOne = [];
export const beforeUpdateMany = [];
export const beforeDeleteOne = [];
export const beforeDeleteMany = [];
export const beforeFindOne = [];
export const beforeFindMany = [];
tip

You can pass multiple functions into the interceptors in order to handle different aspects of your application and at the same time following the practice of letting a function be responsible for a single thing.

Practical Before Interceptor Example

// src/modules/post/post.middlewares.ts
import { AppError } from "arkos/error-handler";
import { ArkosRequest, ArkosResponse, ArkosNextFunction } from "arkos";
import postService from "./post.service";

export const validatePostOwnership = async (
req: ArkosRequest,
res: ArkosResponse,
next: ArkosNextFunction
) => {
if (req.user.role === "author") {
const post = await postService.findOne({
id: req.params.id,
authorId: req.user.id,
});
if (!post) {
throw new AppError("You can only update your own posts", 403);
}
res.locals.originalPost = post;
}
next();
};

export const sanitizePostContent = async (
req: ArkosRequest,
res: ArkosResponse,
next: ArkosNextFunction
) => {
if (req.body.content) {
req.body.content = sanitizeHtml(req.body.content);
}
next();
};

export const checkPostQuota = async (
req: ArkosRequest,
res: ArkosResponse,
next: ArkosNextFunction
) => {
const count = await postService.count({ authorId: req.user.id });
if (count >= req.user.maxPosts) {
throw new AppError("Post quota exceeded", 429);
}
next();
};

export const logPostActivity = async (
req: ArkosRequest,
res: ArkosResponse,
next: ArkosNextFunction
) => {
await activityLog.record({
userId: req.user.id,
action: "update_post",
postId: req.params.id,
});
next();
};
// src/modules/post/post.interceptors.ts
import {
validatePostOwnership,
sanitizePostContent,
checkPostQuota,
logPostActivity,
} from "./post.middlewares";

export const beforeUpdateOne = [
validatePostOwnership,
sanitizePostContent,
checkPostQuota,
logPostActivity,
];

After Interceptor Middlewares

After interceptors run after the main Arkos operation completes. They have access to the operation results and can modify the response.

Response Data Access

After interceptors provide access to three key objects:

Current (v1.3.0+):

  • res.locals.data.data - The data that will be sent to the client
  • res.locals.status - The HTTP status code that will be sent
  • res.locals.additional.data - Extra data from the operation (usually null)

Legacy (prior to v1.3.0-beta, still supported):

  • req.responseData - The data that will be sent to the client
  • req.responseStatus - The HTTP status code that will be sent
  • req.additionalData - Extra data from the operation (usually null)
tip

Prior to v1.3.0-beta only the legacy way was available. After it, the current way was added to be more aligned with Express Community Conventions.

Available After Interceptors

export const afterCreateOne = [];
export const afterCreateMany = [];
export const afterUpdateOne = [];
export const afterUpdateMany = [];
export const afterDeleteOne = [];
export const afterDeleteMany = [];
export const afterFindOne = [];
export const afterFindMany = [];

Practical After Interceptor Example

// src/modules/author/author.middlewares.ts
import { ArkosRequest, ArkosResponse, ArkosNextFunction } from "arkos";

export const removeSensitiveData = async (
req: ArkosRequest,
res: ArkosResponse,
next: ArkosNextFunction
) => {
if (Array.isArray(res.locals.data.data)) {
res.locals.data.data = res.locals.data.data.map((author) => {
delete author.email;
delete author.phone;
return author;
});
} else {
delete res.locals.data.data.email;
delete res.locals.data.data.phone;
}
next();
};

export const addComputedFields = async (
req: ArkosRequest,
res: ArkosResponse,
next: ArkosNextFunction
) => {
if (Array.isArray(res.locals.data.data)) {
res.locals.data.data = res.locals.data.data.map((author) => ({
...author,
fullName: `${author.firstName} ${author.lastName}`,
postCount: author.posts?.length || 0,
}));
} else {
res.locals.data.data = {
...res.locals.data.data,
fullName: `${res.locals.data.data.firstName} ${res.locals.data.data.lastName}`,
postCount: res.locals.data.data.posts?.length || 0,
};
}
next();
};

export const formatResponseData = async (
req: ArkosRequest,
res: ArkosResponse,
next: ArkosNextFunction
) => {
res.locals.data.formatted = true;
res.locals.data.timestamp = new Date().toISOString();
next();
};

export const logAuthorActivity = async (
req: ArkosRequest,
res: ArkosResponse,
next: ArkosNextFunction
) => {
await analyticsLog.record({
operation: "findMany",
resource: "author",
resultsCount: res.locals.data.data.length,
});
next();
};
// src/modules/author/author.interceptors.ts
import {
removeSensitiveData,
addComputedFields,
formatResponseData,
logAuthorActivity,
} from "./author.middlewares";

export const afterFindMany = [
removeSensitiveData,
addComputedFields,
formatResponseData,
logAuthorActivity,
];

Error Interceptor Middlewares v1.3.0+

available from v1.3.0-beta

Error interceptors handle failures in CRUD operations, allowing you to clean up resources, rollback transactions, or perform custom error handling.

Important: Error Middleware Signature

Error middlewares must have exactly 4 parameters (err, req, res, next) - this is an Express.js requirement. Without all 4 parameters, Express won't recognize it as an error middleware.

Available Error Middlewares

export const onCreateOneError = [];
export const onCreateManyError = [];
export const onUpdateOneError = [];
export const onUpdateManyError = [];
export const onDeleteOneError = [];
export const onDeleteManyError = [];
export const onFindOneError = [];
export const onFindManyError = [];

Practical Error Middleware Example

// src/modules/user/user.middlewares.ts
import { ArkosRequest, ArkosResponse, ArkosNextFunction } from "arkos";

export const cleanupUploadedFiles = async (
err: any,
req: ArkosRequest,
res: ArkosResponse,
next: ArkosNextFunction
) => {
if (req.uploadedFiles) {
await cleanupFiles(req.uploadedFiles);
}
next(err);
};

export const rollbackTransaction = async (
err: any,
req: ArkosRequest,
res: ArkosResponse,
next: ArkosNextFunction
) => {
if (res.locals.transactionData) {
await rollbackDatabaseTransaction(res.locals.transactionData);
}
next(err);
};

export const logUserCreationError = async (
err: any,
req: ArkosRequest,
res: ArkosResponse,
next: ArkosNextFunction
) => {
console.error("User creation failed:", err.message);
await errorLog.record({
operation: "createUser",
error: err.message,
userId: req.body.email,
});
next(err);
};

export const handlePrismaErrors = async (
err: any,
req: ArkosRequest,
res: ArkosResponse,
next: ArkosNextFunction
) => {
if (err.code === "P2002") {
// Unique constraint violation
console.log("Duplicate entry detected");
}
next(err);
};
// src/modules/user/user.interceptors.ts
import {
cleanupUploadedFiles,
rollbackTransaction,
logUserCreationError,
handlePrismaErrors,
} from "./user.middlewares";

export const onCreateOneError = [
cleanupUploadedFiles,
rollbackTransaction,
handlePrismaErrors,
logUserCreationError,
];

Authentication Interceptor Middlewares

Beyond model-based interceptors, Arkos provides specialized interceptor middlewares for authentication endpoints. These follow the same patterns as model interceptors but target the built-in authentication system.

Authentication Endpoints

Authentication interceptors work with these built-in routes:

RouteMethodAvailable Interceptors
/api/auth/loginPOSTbeforeLogin, afterLogin, onLoginError
/api/auth/signupPOSTbeforeSignup, afterSignup, onSignupError
/api/auth/logoutDELETEbeforeLogout, afterLogout, onLogoutError
/api/auth/update-passwordPOSTbeforeUpdatePassword, afterUpdatePassword, onUpdatePasswordError
/api/users/meGET/PATCH/DELETEbeforeGetMe, afterGetMe, beforeUpdateMe, afterUpdateMe, beforeDeleteMe, afterDeleteMe

File Structure for Authentication Interceptors

src/modules/auth/
├── auth.middlewares.ts
└── auth.interceptors.ts

Authentication Interceptor Examples

Before Authentication:

// src/modules/auth/auth.middlewares.ts
import { ArkosRequest, ArkosResponse, ArkosNextFunction } from "arkos";
import { AppError } from "arkos/error-handler";

export const trackLoginAttempts = async (
req: ArkosRequest,
res: ArkosResponse,
next: ArkosNextFunction
) => {
const identifier = req.body.email || req.body.username;
const attempts = await getFailedLoginAttempts(identifier);

if (attempts > 5) {
throw new AppError("Too many failed attempts. Try again later.", 429);
}

await logLoginAttempt(identifier, req.ip);
next();
};

export const generateVerificationToken = async (
req: ArkosRequest,
res: ArkosResponse,
next: ArkosNextFunction
) => {
req.body.verificationToken = crypto.randomBytes(32).toString("hex");
req.body.verificationTokenExpires = new Date(
Date.now() + 24 * 60 * 60 * 1000
);
next();
};
// src/modules/auth/auth.interceptors.ts
import {
trackLoginAttempts,
generateVerificationToken,
} from "./auth.middlewares";

export const beforeLogin = [trackLoginAttempts];

export const beforeSignup = [generateVerificationToken];

After Authentication:

// src/modules/auth/auth.middlewares.ts
export const resetFailedAttempts = async (
req: ArkosRequest,
res: ArkosResponse,
next: ArkosNextFunction
) => {
const user = res.locals.additional.user;
await resetFailedLoginAttempts(user.email);
next();
};

export const updateLastLogin = async (
req: ArkosRequest,
res: ArkosResponse,
next: ArkosNextFunction
) => {
const user = res.locals.additional.user;
await updateUserLastLogin(user.id);
next();
};

export const logSuccessfulLogin = async (
req: ArkosRequest,
res: ArkosResponse,
next: ArkosNextFunction
) => {
const user = res.locals.additional.user;
auditLog.info("User login", { userId: user.id, ip: req.ip });
next();
};

export const sendVerificationEmail = async (
req: ArkosRequest,
res: ArkosResponse,
next: ArkosNextFunction
) => {
const user = res.locals.data.data;

emailService
.sendVerificationEmail(user.email, user.verificationToken)
.catch(console.error);

delete res.locals.data.data.verificationToken;
delete res.locals.data.data.verificationTokenExpires;

res.locals.data.message = "Please check your email to verify your account";
next();
};
// src/modules/auth/auth.interceptors.ts
import {
resetFailedAttempts,
updateLastLogin,
logSuccessfulLogin,
sendVerificationEmail,
} from "./auth.middlewares";

export const afterLogin = [
resetFailedAttempts,
updateLastLogin,
logSuccessfulLogin,
];

export const afterSignup = [sendVerificationEmail];

Error Authentication Interceptors:

// src/modules/auth/auth.middlewares.ts
export const incrementFailedAttempts = async (
err: any,
req: ArkosRequest,
res: ArkosResponse,
next: ArkosNextFunction
) => {
const identifier = req.body.email || req.body.username;
if (identifier) {
await incrementFailedLoginAttempts(identifier);
}
next(err);
};

export const logFailedLogin = async (
err: any,
req: ArkosRequest,
res: ArkosResponse,
next: ArkosNextFunction
) => {
const identifier = req.body.email || req.body.username;
securityLog.warn("Failed login attempt", {
identifier,
ip: req.ip,
error: err.message,
});
next(err);
};

export const cleanupFailedSignup = async (
err: any,
req: ArkosRequest,
res: ArkosResponse,
next: ArkosNextFunction
) => {
if (req.body.email && err.code === "P2002") {
await cleanupPartialSignup(req.body.email);
}
next(err);
};
// src/modules/auth/auth.interceptors.ts
import {
incrementFailedAttempts,
logFailedLogin,
cleanupFailedSignup,
} from "./auth.middlewares";

export const onLoginError = [incrementFailedAttempts, logFailedLogin];

export const onSignupError = [cleanupFailedSignup];

User Profile Management Interceptors

The /api/users/me endpoint supports additional interceptors:

// src/modules/auth/auth.middlewares.ts
export const preventPasswordUpdate = async (
req: ArkosRequest,
res: ArkosResponse,
next: ArkosNextFunction
) => {
if ("password" in req.body) {
throw new AppError("Use /api/auth/update-password to change password", 400);
}
next();
};

export const auditProfileChanges = async (
req: ArkosRequest,
res: ArkosResponse,
next: ArkosNextFunction
) => {
res.locals.profileChangeAudit = {
userId: req.user.id,
changes: Object.keys(req.body),
timestamp: new Date(),
};
next();
};

export const cleanupUserData = async (
req: ArkosRequest,
res: ArkosResponse,
next: ArkosNextFunction
) => {
const userId = req.user.id;

userCleanupService.cleanup(userId).catch(console.error);

emailService
.sendAccountDeletionConfirmation(req.user.email)
.catch(console.error);

next();
};
// src/modules/auth/auth.interceptors.ts
import {
preventPasswordUpdate,
auditProfileChanges,
cleanupUserData,
} from "./auth.middlewares";

export const beforeUpdateMe = [preventPasswordUpdate, auditProfileChanges];

export const afterDeleteMe = [cleanupUserData];

Integration with Authentication System

Authentication interceptors work seamlessly with the Authentication System. They intercept the built-in authentication flow without requiring custom route definitions.

The execution order is:

  1. Authentication data validation (DTOs/schemas)
  2. Before interceptors (if defined)
  3. Core authentication logic (login, signup, etc.)
  4. After interceptors (if defined)
  5. Error interceptors (if operation fails)
  6. Response sent to client

Generating Authentication Interceptors

Use the CLI to scaffold authentication interceptor files:

npx arkos generate interceptors --module auth

Shorthand:

npx arkos g i -m auth

This creates the src/modules/auth/auth.interceptors.ts file with empty interceptor exports ready for customization.

Advanced Usage

Customizing Base Query Options

Instead of writing custom controllers, you can customize how Prisma queries work by default using query options:

// src/modules/user/user.query.ts
import { PrismaQueryOptions } from "arkos/prisma";
import { Prisma } from "@prisma/client";

const userPrismaQueryOptions: PrismaQueryOptions<Prisma.UserDelegate> = {
global: {
select: {
id: true,
name: true,
email: true,
profile: true,
},
},
findMany: {
orderBy: {
createdAt: "desc",
},
take: 50,
},
createOne: {
include: {
profile: true,
posts: true,
},
},
};

export default userPrismaQueryOptions;

You can explore more about .query.ts components under Custom Prisma Query Options Guide.

Passing Data Between Interceptors

Use res.locals to pass data between interceptors - this is the Express-recommended approach:

// src/modules/post/post.middlewares.ts
import { ArkosRequest, ArkosResponse, ArkosNextFunction } from "arkos";

export const trackStartTime = async (
req: ArkosRequest,
res: ArkosResponse,
next: ArkosNextFunction
) => {
res.locals.startTime = Date.now();
res.locals.clientIP = req.ip;
next();
};

export const loadOriginalPost = async (
req: ArkosRequest,
res: ArkosResponse,
next: ArkosNextFunction
) => {
const post = await postService.findOne({ id: req.params.id });
res.locals.originalPost = post;
next();
};

export const logPerformance = async (
req: ArkosRequest,
res: ArkosResponse,
next: ArkosNextFunction
) => {
const duration = Date.now() - res.locals.startTime;

await performanceLog.record({
operation: "createOne",
duration,
clientIP: res.locals.clientIP,
resourceId: res.locals.data.data.id,
});

next();
};

export const notifyAuthor = async (
req: ArkosRequest,
res: ArkosResponse,
next: ArkosNextFunction
) => {
const originalPost = res.locals.originalPost;
const updatedPost = res.locals.data.data;

if (originalPost.title !== updatedPost.title) {
await emailService.send({
to: originalPost.author.email,
subject: "Post title was changed",
body: `Your post title was changed from "${originalPost.title}" to "${updatedPost.title}"`,
});
}

next();
};
// src/modules/post/post.interceptors.ts
import {
trackStartTime,
loadOriginalPost,
logPerformance,
notifyAuthor,
} from "./post.middlewares";

export const beforeUpdateOne = [trackStartTime, loadOriginalPost];

export const afterUpdateOne = [logPerformance, notifyAuthor];

Why use res.locals?

  • Express standard: This is how Express recommends passing data
  • Type-safe: Can be typed with ArkosResponse generics
  • Clean separation: Clear distinction between request data and middleware state
  • No type casting: No need for (req as any).customField

Complete Example: Blog Post with Auto-Slug

Here's a real-world example showing before, after, and error middlewares working together:

// src/modules/post/post.middlewares.ts
import { AppError } from "arkos/error-handler";
import { ArkosRequest, ArkosResponse, ArkosNextFunction } from "arkos";
import postService from "./post.service";

export const generateSlug = async (
req: ArkosRequest,
res: ArkosResponse,
next: ArkosNextFunction
) => {
if (req.body.title && !req.body.slug) {
req.body.slug = req.body.title
.toLowerCase()
.replace(/[^a-zA-Z0-9\s]/g, "")
.replace(/\s+/g, "-");
}
next();
};

export const ensureUniqueSlug = async (
req: ArkosRequest,
res: ArkosResponse,
next: ArkosNextFunction
) => {
const existingPost = await postService.findOne({
slug: req.body.slug,
});

if (existingPost) {
req.body.slug += `-${Date.now()}`;
}
next();
};

export const notifyFollowers = async (
req: ArkosRequest,
res: ArkosResponse,
next: ArkosNextFunction
) => {
const post = res.locals.data.data;

notificationService
.notifyFollowers(post.authorId, {
type: "new_post",
postId: post.id,
title: post.title,
})
.catch(console.error);

next();
};

export const updateSearchIndex = async (
req: ArkosRequest,
res: ArkosResponse,
next: ArkosNextFunction
) => {
const post = res.locals.data.data;
searchService.indexPost(post).catch(console.error);
next();
};

export const cleanupFeaturedImage = async (
err: any,
req: ArkosRequest,
res: ArkosResponse,
next: ArkosNextFunction
) => {
if (req.body.featuredImage) {
await deleteUploadedFile(req.body.featuredImage).catch(console.error);
}
next(err);
};
// src/modules/post/post.interceptors.ts
import {
generateSlug,
ensureUniqueSlug,
notifyFollowers,
updateSearchIndex,
cleanupFeaturedImage,
} from "./post.middlewares";

export const beforeCreateOne = [generateSlug, ensureUniqueSlug];

export const afterCreateOne = [notifyFollowers, updateSearchIndex];

export const onCreateOneError = [cleanupFeaturedImage];

Arkos allows you to customize beyond the request level; it also enables you to customize the flow of the service methods. Let's take the example above: you may want to execute this behavior at any level where a new post is created, whether through an endpoint call or programmatically elsewhere in your code. For this situation, we highly encourage the use of Service Hooks together with instances of the BaseService Class, which is one of the main strengths of Arkos.js.

The BaseService Class generates services for all of your Prisma models, each containing all standard CRUD operations and many features that Arkos offers:

  • Automatic relation field handling
  • Automatic password hashing for the user model
  • Consistency in CRUD operations across all model services
  • Service hooks, allowing you to execute before/after/onError consistently across your codebase

File-Upload Interceptor Middlewares

Arkos provides additional interceptor middleware systems for specific modules, in this case the file-upload module. You can check more about this at File Upload Interceptor Middlewares Guide - Intercept file upload, update, and delete operations.

Best Practices

  1. Keep interceptors focused - Each interceptor should have a single responsibility, hence choose to use arrays of functions instead of simple functions
  2. Use AppError for custom errors - Provides consistent error handling across your app
  3. Always call next() - Unless you're sending a custom response
  4. Don't block responses - Use .catch() for non-critical async operations
  5. Leverage error middlewares - Clean up resources when operations fail
  6. Use custom prisma query options - Customize Prisma behavior instead of writing complex interceptors when possible
  7. Migrate to .interceptors.ts - If you're on v1.4.0+, start using the new naming convention for better code organization
  8. Combine schemas with ArkosPrismaInput - Get full type safety for both API validation and Prisma relations (v1.5.0+)
  9. Separate concerns - Keep reusable functions in .middlewares.ts and chains in .interceptors.ts
  10. Use res.locals for passing data - Follow Express conventions for sharing data between middlewares

Interceptor middlewares give you the power to customize every aspect of your auto-generated API while maintaining the simplicity and speed that makes Arkos powerful.