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:
- v1.4.0+ (Recommended)
- v1.3.0 and earlier
npx arkos generate interceptors --module user
Shorthand:
npx arkos g i -m user
npx arkos generate middlewares --model user
Shorthand:
npx arkos g m -m user
File Structure
- v1.4.0+ (Recommended)
- v1.3.0 and earlier
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
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.
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.tsand.interceptors.tswork, warnings shown - v1.5.0-beta:
.middlewares.tsstill works with deprecation warnings - v1.6.0-beta:
.middlewares.tswill be completely removed, only.interceptors.tssupported
Interceptor middlewares follow Arkos's convention-based structure:
my-arkos-project/
└── src/
└── modules/
└── [model-name]/
└── [model-name].middlewares.ts
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
- TypeScript
- JavaScript
// 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();
};
// src/modules/post/post.middlewares.js
export const generateSlug = async (req, res, next) => {
if (req.body.title && !req.body.slug) {
req.body.slug = req.body.title.toLowerCase().replace(/\s+/g, "-");
}
next();
};
export const notifySubscribers = async (req, res, next) => {
const post = res.locals.data.data;
notificationService
.notifySubscribers(`New post: ${post.title}`)
.catch(console.error);
next();
};
Step 2: Chain them in interceptors
- TypeScript
- JavaScript
// src/modules/post/post.interceptors.ts
import { generateSlug, notifySubscribers } from "./post.middlewares";
export const beforeCreateOne = [generateSlug];
export const afterCreateOne = [notifySubscribers];
// src/modules/post/post.interceptors.js
import { generateSlug, notifySubscribers } from "./post.middlewares";
export const beforeCreateOne = [generateSlug];
export const afterCreateOne = [notifySubscribers];
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 Parameter | Description | Example |
|---|---|---|
Params | URL parameters (:id, :slug) | { id: string } |
ResBody | Response body type (rarely used) | User |
ReqBody | Request body type | CreateUserInput |
Query | Query 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:
- Zod + ArkosPrismaInput
- class-validator + ArkosPrismaInput
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];
Step 1: Define your validation DTO
// src/modules/user/dtos/create-user.dto.ts
import {
IsString,
IsEmail,
IsNumber,
IsOptional,
ValidateNested,
Min,
} from "class-validator";
import { Type } from "class-transformer";
class ProfileDto {
@IsString()
@IsOptional()
bio?: string;
@IsOptional()
isPublic?: boolean;
}
export class CreateUserDto {
@IsString()
name: string;
@IsEmail()
email: string;
@IsNumber()
@Min(18)
@IsOptional()
age?: number;
@ValidateNested()
@Type(() => ProfileDto)
@IsOptional()
profile?: ProfileDto;
}
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 { CreateUserDto } from "./dtos/create-user.dto";
import { AppError } from "arkos/error-handler";
// Combine DTO validation with Prisma relation handling
type CreateUserBody = CreateUserDto & ArkosPrismaInput<Prisma.UserCreateInput>;
type UserParams = { id: string };
export const addDefaultProfile = async (
req: ArkosRequest<UserParams, any, CreateUserBody>,
res: ArkosResponse,
next: ArkosNextFunction
) => {
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
) => {
if (req.body.posts) {
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,
];
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 = [];
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 clientres.locals.status- The HTTP status code that will be sentres.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 clientreq.responseStatus- The HTTP status code that will be sentreq.additionalData- Extra data from the operation (usually null)
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:
| Route | Method | Available Interceptors |
|---|---|---|
/api/auth/login | POST | beforeLogin, afterLogin, onLoginError |
/api/auth/signup | POST | beforeSignup, afterSignup, onSignupError |
/api/auth/logout | DELETE | beforeLogout, afterLogout, onLogoutError |
/api/auth/update-password | POST | beforeUpdatePassword, afterUpdatePassword, onUpdatePasswordError |
/api/users/me | GET/PATCH/DELETE | beforeGetMe, afterGetMe, beforeUpdateMe, afterUpdateMe, beforeDeleteMe, afterDeleteMe |
File Structure for Authentication Interceptors
- v1.4.0+ (Recommended)
- v1.3.0 and earlier
src/modules/auth/
├── auth.middlewares.ts
└── auth.interceptors.ts
src/modules/auth/
└── auth.middlewares.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:
- Authentication data validation (DTOs/schemas)
- Before interceptors (if defined)
- Core authentication logic (login, signup, etc.)
- After interceptors (if defined)
- Error interceptors (if operation fails)
- Response sent to client
Generating Authentication Interceptors
Use the CLI to scaffold authentication interceptor files:
- v1.4.0+ (Recommended)
- v1.3.0 and earlier
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.
npx arkos generate middlewares --model auth
Shorthand:
npx arkos g m -m auth
This creates the src/modules/auth/auth.middlewares.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
- Keep interceptors focused - Each interceptor should have a single responsibility, hence choose to use arrays of functions instead of simple functions
- Use AppError for custom errors - Provides consistent error handling across your app
- Always call next() - Unless you're sending a custom response
- Don't block responses - Use
.catch()for non-critical async operations - Leverage error middlewares - Clean up resources when operations fail
- Use custom prisma query options - Customize Prisma behavior instead of writing complex interceptors when possible
- Migrate to
.interceptors.ts- If you're on v1.4.0+, start using the new naming convention for better code organization - Combine schemas with ArkosPrismaInput - Get full type safety for both API validation and Prisma relations (v1.5.0+)
- Separate concerns - Keep reusable functions in
.middlewares.tsand chains in.interceptors.ts - Use
res.localsfor 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.