Standardized backend REST API development following layered architecture patterns (Route → Controller → Service → Repository). Use when building new REST APIs, implementing features, fixing bugs, or refactoring backend code. Enforces strict separation of concerns, centralized error handling, input validation, DTO/mapper patterns, and Prisma ORM usage.
This skill provides a standardized, production-ready architecture for Node.js/TypeScript REST APIs following the 4-layer pattern proven across multiple production systems.
Client Request
↓
[routes/] HTTP handlers + middleware
↓
[controller/] Request parsing + response formatting
↓
[service/] Business logic + orchestration
↓
[repository/] Database access (Prisma)
Each layer has a single responsibility and clear boundaries.
Runtime: Node.js + TypeScript
Framework: Express
ORM: Prisma
Validation: Joi + Zod (request validation)
Error Handling: Custom AppError class + centralized middleware
Standardized JSON with
{status, data, meta, error}Each feature gets a feature module with 6 files:
src/
├── app/
│ └── [feature]/
│ ├── [feature].route.ts ← HTTP routes + middleware
│ ├── [feature].controller.ts ← Request → response
│ ├── [feature].service.ts ← Business logic
│ ├── [feature].repository.ts ← Database queries
│ ├── [feature].dto.ts ← TypeScript types
│ ├── [feature].mapper.ts ← Entity → DTO transformation
│ └── [feature].request.ts ← Joi/Zod validation schemas
├── config/
│ └── config.ts ← Environment loading
├── interface/
│ └── index.ts ← Global types, ERROR_CODE, ApiResponse
├── middleware/
│ ├── auth-middleware.ts
│ ├── error-handler.ts ← Centralized error handling
│ ├── validate-request.ts
│ ├── security.middleware.ts
│ └── index.ts
├── lib/
│ └── prisma.ts ← Prisma client singleton
├── utils/
│ ├── response-handler.ts ← ResponseHandler utilities
│ ├── handle-prisma-error.ts
│ └── clean-joi-error-message.ts
├── routes/
│ └── index.ts ← Central route aggregator
└── index.ts ← Express app setup
[feature].route.ts)Responsibility: HTTP method binding, middleware ordering, parameter extraction
Does: Apply auth middleware → validate input → call controller → error handling
Does NOT: Business logic, database access
export const [feature]Routes = express.Router();
[feature]Routes.post(
'/',
auth('ACCESS', [Roles.Admin]), // ← Auth middleware
validate(createSchema, 'body'), // ← Validation middleware
catchAsync([feature]Controller.create), // ← Error wrapping
);
[feature]Routes.get(
'/:id',
auth('ACCESS', [Roles.User, Roles.Admin]),
catchAsync([feature]Controller.findById),
);
Key utilities:
auth(tokenType, allowedRoles) — JWT verificationvalidate(schema, 'body'|'query'|'params') — Input validationcatchAsync(fn) — Wrapper that catches promise rejections[feature].controller.ts)Responsibility: Extract request data, call service, format response
Does: req.body, req.query, req.params → service call → ResponseHandler.ok()
Does NOT: Business logic, database queries
export const [feature]Controller = {
create: async (req: Request, res: Response, next: NextFunction) => {
const { body } = req;
const result = await [feature]Service.create(body);
// Service returns AppError or data
if (result instanceof AppError) {
next(result);
return;
}
ResponseHandler.created(res, result, 'Created successfully');
},
findAll: async (req: Request, res: Response, next: NextFunction) => {
const { query } = req;
const { data, meta } = await [feature]Service.findAll(query);
if (data instanceof AppError) {
next(data);
return;
}
ResponseHandler.ok(res, data, 'Fetched successfully', meta);
},
};
Pattern:
req dataAppError | data)AppError → next(error)ResponseHandler.ok() or .created()[feature].service.ts)Responsibility: Business logic, data orchestration, mapper usage
Does: Calls repository → transforms data (via mappers) → returns AppError | data
Does NOT: Direct database queries, HTTP handling
export const [feature]Service = {
create: async (input: CreateDto): Promise<AppError | [Feature]Dto> => {
// Validate business rules (not input format — that's the request layer)
const existing = await [feature]Repository.findByEmail(input.email);
if (existing) {
return new AppError('CONFLICT', 'Email already exists');
}
// Call repository
const entity = await [feature]Repository.create(input);
// Transform entity to DTO via mapper
return [feature]Mapper.toDtoArray([entity])[0];
},
findAll: async (query: QueryParams) => {
const { page = 1, perPage = 10 } = query;
const result = await [feature]Repository.findAll(page, perPage);
return {
data: [feature]Mapper.toDtoArray(result.data),
meta: {
currentPage: page,
totalPages: Math.ceil(result.count / perPage),
perPage,
totalEntries: result.count,
},
};
},
};
Pattern:
AppError | data (union type)[feature].repository.ts)Responsibility: Raw database access via Prisma
Does: prisma.model.query() — nothing else
Does NOT: Business logic, data transformation
export const [feature]Repository = {
create: async (input: CreateDto) => {
return prisma.[feature].create({
data: input,
});
},
findAll: async (page: number, perPage: number) => {
const skip = (page - 1) * perPage;
const [data, count] = await Promise.all([
prisma.[feature].findMany({
skip,
take: perPage,
where: { deletedAt: null }, // Soft delete filter
}),
prisma.[feature].count({
where: { deletedAt: null },
}),
]);
return { data, count };
},
};
Pattern:
DTO (Data Transfer Object) — TypeScript interface defining what data leaves the system:
// [feature].dto.ts
export interface [Feature]Dto {
id: string;
name: string;
email: string;
createdAt: Date;
}
Mapper — Transform database entity to DTO:
// [feature].mapper.ts
export const [feature]Mapper = {
toDto(entity: [FeatureEntity]): [Feature]Dto {
return {
id: entity.id,
name: entity.name,
email: entity.email,
createdAt: entity.createdAt,
};
},
toDtoArray(entities: [FeatureEntity][]): [Feature]Dto[] {
return entities.map(e => this.toDto(e));
},
};
Central error class:
export class AppError extends Error {
constructor(
public readonly code: ErrorCode, // 'BAD_REQUEST', 'UNAUTHORIZED', etc.
message?: string,
) {
super(message);
}
}
Error codes (from interface/index.ts):
export const ERROR_CODE = {
BAD_REQUEST: { code: 'BAD_REQUEST', message: 'Bad Request', httpStatus: 400 },
UNAUTHORIZED: { code: 'UNAUTHORIZED', message: 'Unauthorized', httpStatus: 401 },
FORBIDDEN: { code: 'FORBIDDEN', message: 'Forbidden', httpStatus: 403 },
NOT_FOUND: { code: 'NOT_FOUND', message: 'Not Found', httpStatus: 404 },
CONFLICT: { code: 'CONFLICT', message: 'Resource already exists', httpStatus: 409 },
INTERNAL_SERVER_ERROR: { code: 'INTERNAL_SERVER_ERROR', httpStatus: 500 },
// ... extend as needed
};
Centralized error handler middleware:
export const errorHandler: ErrorRequestHandler = (
err: AppError | Error,
req: Request,
res: Response,
) => {
if (err instanceof AppError) {
return res.status(err.httpStatus).json({
status: 'error',
error: {
code: err.code,
message: err.message,
},
});
}
// Fallback for unhandled errors
console.error(err.stack);
res.status(500).json({
status: 'error',
error: { code: 'INTERNAL_SERVER_ERROR', message: 'Internal Server Error' },
});
};
Success response:
{
"status": "success",
"message": "User created successfully",
"data": { "id": "123", "name": "John", "email": "[email protected]" },
"meta": null
}
Paginated response:
{
"status": "success",
"data": [{ "id": "1" }, { "id": "2" }],
"meta": {
"currentPage": 1,
"totalPages": 5,
"perPage": 10,
"totalEntries": 42
}
}
Error response:
{
"status": "error",
"error": {
"code": "NOT_FOUND",
"message": "User not found"
}
}
Request file defines Joi schema + TypeScript type:
// [feature].request.ts
import Joi from 'joi';
export const createSchema = Joi.object({
name: Joi.string().required(),
email: Joi.string().email().required(),
phone: Joi.string().optional(),
});
export type CreateRequest = {
name: string;
email: string;
phone?: string;
};
Route uses it:
[feature]Routes.post(
'/',
validate(createSchema, 'body'), // Validates against schema
catchAsync([feature]Controller.create),
);
Controller receives typed input: