断断续续用了好几年的Node,从 MVC 到 DDD 再到函数式,我发现最顺手的是函数式架构。Express 的中间件机制本身就是函数组合,强行套用OOP,会让它显得格格不入。
问题:DDD 完整分层太重
先看典型的 DDD 四层架构(详细讨论见 node-ddd-practice):
// ❌ 创建一个订单要写 7 个文件
// 1. Presentation 层
class OrderController {
constructor(private createOrderUseCase: CreateOrderUseCase) {}
async create(req: Request, res: Response) {
const result = await this.createOrderUseCase.execute(req.body);
res.json(result);
}
}
// 2. Application 层
class CreateOrderUseCase {
constructor(private orderRepo: IOrderRepository) {}
async execute(dto: CreateOrderDTO) {
const order = Order.create(dto); // Domain 对象
return this.orderRepo.save(order);
}
}
// 3. Domain 层
class Order {
private constructor(private props: OrderProps) {}
static create(dto: CreateOrderDTO) { /* ... */ }
canCancel(): boolean { /* ... */ }
}
class OrderDomainService { ... }
interface IOrderRepository {
save(order: Order): Promise<void>;
}
// 4. Infrastructure 层
class OrderRepository implements IOrderRepository {
async save(order: Order) {
const entity = OrderMapper.toDomain(order); // 又要写 Mapper
await prisma.order.create({ data: entity });
}
}
// 还要写依赖注入容器...写一个功能要创建 7+ 个文件,层层转换,Express 项目需要这么重吗?
函数式架构:让代码顺着框架走
Express 的核心是中间件链,天然就是函数组合:
// ✅ 简洁、直接
app.post('/orders',
requireAuth, // 函数 1:认证
validateRequest, // 函数 2:校验
asyncHandler(createOrder) // 函数 3:业务逻辑
);完整架构示例
目录结构
src/
├── routes/ # 路由层:中间件组合
├── services/ # 业务层:纯函数编排
├── domain/ # 领域层:业务规则(纯函数)
├── schemas/ # Zod Schema:输入校验
├── middlewares/ # 中间件:高阶函数
├── utils/ # 工具函数
└── index.ts
观看文目录结构可能感觉和DDD差不多,但实际相比 DDD, 函数式架构大大减少了文件数量。后面我们一层一层展开:
1. Routes 层:组合中间件
对应 DDD 的 Presentation 层(Controller),但更轻量:
- DDD:需要 Controller 类 + 依赖注入
- 函数式:直接组合中间件函数,无需类封装
// routes/order.routes.ts
const router = Router();
// 中间件链:认证 → 校验 → 业务逻辑
router.post('/orders',
requireAuth,
validateRequest(createOrderSchema),
asyncHandler(async (req, res) => {
const order = await orderService.createOrder({
userId: req.user.id,
items: req.body.items
});
res.json(order);
})
);
// ... 其他路由:cancel, get职责:路由定义 + 中间件组合,不写业务逻辑。
2. Services 层:纯函数编排
对应 DDD 的 Application 层(Use Case),但去掉了类的包袱:
- DDD:需要 UseCase 类 + 接口定义 + 依赖注入
- 函数式:纯函数导出,直接调用
Repository 处理方式:不像 DDD 那样定义接口,而是根据需要灵活选择:
- 简单场景:直接在 Service 中操作 Prisma
- 复用场景:将常用的数据库操作提取为函数(轻量级 Repository)
// services/orderService.ts
export const createOrder = async (input: CreateOrderInput) => {
await checkStock(input.items);
const orderDraft = buildOrder(input);
// 直接操作 Prisma
return prisma.$transaction(async (tx) => {
const order = await tx.order.create({ data: orderDraft });
await deductStock(tx, input.items);
return order;
});
};
export const cancelOrder = async (orderId: string, userId: string) => {
const order = await findOrderById(orderId); // 轻量级 Repository 函数
if (!canCancelOrder(order)) throw new Error('订单不能取消');
await prisma.$transaction(async (tx) => {
await tx.order.update({ where: { id: orderId }, data: { status: 'cancelled' } });
await Promise.all([refund(orderId), restoreStock(order.items)]);
});
};职责:编排业务流程、事务管理、调用领域函数。
何时提取数据库操作函数?
数据库操作是否提取到独立函数,遵循三次原则(Rule of Three):
| 场景 | 是否提取 | 示例 |
|---|---|---|
| 只用 1 次 | ❌ 不提取 | 简单的 CRUD,直接写在 Service 中 |
| 用 2-3 次 | ⚠️ 可选 | 视复杂度决定,简单的可以复制 |
| 用 ≥3 次 | ✅ 必须提取 | 提取到 repositories/ 作为共享函数 |
| 逻辑复杂 | ✅ 提取 | 即使只用 1 次,也建议提取提升可读性 |
// ❌ 过度提取
const order = await createOrderInDB(data); // 跳转文件才能看到逻辑
// ✅ 直接写(简单且只用一次)
const order = await prisma.order.create({ data });
// ✅ 提取(多处复用)
const order = await findOrderById(orderId); // 3+ Service 都用3. Domain 层:业务规则(纯函数)
对应 DDD 的 Domain 层,但极度精简:
- DDD:实体类 + 值对象 + 领域服务类 + Repository 接口
- 函数式:纯函数 + TypeScript 类型(必要时自定义领域模型类型)
领域模型类型的选择
简单业务:直接用 Prisma 类型
import { Order } from '@prisma/client';
export const canCancelOrder = (order: Order) =>
order.status === 'pending' || order.status === 'paid';复杂业务:自定义领域模型类型(区分业务概念和数据库结构)
// domain/types/order.ts
export type OrderDraft = {
userId: string;
items: OrderItem[];
totalAmount: number;
status: 'pending';
};
export type OrderItem = {
productId: string;
quantity: number;
price: number;
};
export type Order = OrderDraft & {
id: string;
createdAt: Date;
// ...
};
// domain/order.ts
export const buildOrder = (input: CreateOrderInput): OrderDraft => {
if (input.items.length === 0) throw new Error('订单至少包含一个商品');
return {
userId: input.userId,
items: input.items,
status: 'pending',
totalAmount: calculateTotal(input.items)
};
};
export const canCancelOrder = (order: Order) =>
order.status === 'pending' || order.status === 'paid';
export const cancelOrderState = (order: Order): Order => {
if (!canCancelOrder(order)) throw new Error('订单不能取消');
return { ...order, status: 'cancelled' };
};何时自定义领域模型类型:
- 业务概念与数据库表不一致(如聚合根、值对象)
- 需要在创建前构建数据(
OrderDraftvsOrder) - 有复杂的业务规则和状态转换
关键区别:函数式架构用 TypeScript 类型(type/interface),DDD 用实体类(class,带方法)。类型更轻量,配合纯函数使用。
职责:业务规则校验、状态转换,所有函数都是纯函数(无副作用)。这一层非常薄,只在复杂业务时才需要。
4. 其他层:Middlewares 和 Utils
Middlewares 是函数式架构的特色,承载了横切关注点(认证、校验、错误处理),通过高阶函数实现复用。Utils 提供通用的函数组合工具(pipe、asyncHandler、errorHandler)。
这两层对应 DDD 的 Infrastructure 层的一部分功能,但更轻量:
- 无需实现 Repository 接口
- 直接用 Prisma 操作数据库
- 用高阶函数替代装饰器模式
简化后,我们省略了 DDD 的 Infrastructure 层(Repository 实现 + Mapper),直接在 Service 层调用 Prisma。
数据校验与转换:Zod 最佳实践
函数式架构中,数据流转涉及多个环节的转换。Zod 作为”守门员”,负责校验 Input DTO 并进行简单转换。关于如何用 Zod + Prisma 简化模型转换,避免 Mapper 地狱,可以参考 node-model-simplification。
Zod 在数据流中的位置
HTTP Request
↓
Input DTO (未校验)
↓ [Zod 校验 + 转换] ← Zod 的职责
领域模型/Domain Input
↓ [手动映射/业务逻辑]
DB Entity (Prisma)
↓ [Prisma select]
Response DTO
↓
HTTP Response
各环节的最佳实践
| 转换环节 | 工具 | 职责 |
|---|---|---|
| Input DTO → Domain Input | ✅ Zod | 校验、类型转换、字段重命名、默认值 |
| Domain Input → DB Entity | ⚠️ 手动 | 复杂业务逻辑、关系映射、聚合 |
| DB Entity → Response DTO | ✅ Prisma select | 简单查询(只查需要的字段) |
| DB Entity → Response DTO | ⚠️ 手动 | 复杂场景(计算字段、展平嵌套) |
完整示例
// 1. 定义 Schema(schemas/order.schema.ts)
export const createOrderInputSchema = z.object({
items: z.array(z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive(),
// ... 其他字段
})).min(1, '订单至少包含一个商品')
}).transform((input) => ({
items: input.items,
hasCoupon: !!input.couponCode // 简单转换
}));
export type CreateOrderInput = z.output<typeof createOrderInputSchema>;
// 2. 校验中间件(middlewares/validate.ts)
export const validateRequest = (schema: z.ZodSchema) => {
return async (req, res, next) => {
try {
req.body = schema.parse(req.body);
next();
} catch (error) {
// ... 错误处理
}
};
};
// 3. Route 层:组合中间件
router.post('/orders',
requireAuth,
validateRequest(createOrderInputSchema), // 校验,
asyncHandler(async (req, res) => {
const order = await orderService.createOrder({
...req.body,
userId: req.user.id
});
res.json(order);
})
);
// 4. Service 层:业务逻辑 + 手动映射 + select 返回 Response DTO
export const createOrder = async (input: CreateOrderInput & { userId: string }) => {
await checkStock(input.items);
const totalAmount = input.items.reduce(/* ... */);
return prisma.order.create({
// 手动映射 input -〉 entity. 如果这里比较复杂,可以抽出一个函数。
data: { userId: input.userId, totalAmount, items: { create: input.items } },
// select 返回 Response
select: { id: true, status: true, totalAmount: true /* ... */ }
});
};关键原则
Zod 做什么:
- ✅ 校验必填字段、类型、格式
- ✅ 简单转换(字段重命名、默认值、类型强制转换)
- ✅ 类型推导(
z.infer/z.output)
Zod 不做什么:
- ❌ 复杂业务逻辑(库存检查、价格计算)→ 在 Service/Domain 层
- ❌ 数据库关系映射(一对多、多对多)→ 手动映射
- ❌ 聚合数据查询 → Prisma 或手动构造
总结:Zod 是轻量级的”守门员”,只做输入校验和简单转换。复杂的业务逻辑应该留给 Domain 和 Service 层。
高级模式:函数组合
Pipe 模式:数据流转换
// 将多个纯函数组合成流水线
const validateInput = (input) => { /* ... */ return input; };
const enrichMetadata = (input) => ({ ...input, id: randomUUID(), createdAt: new Date() });
const calculateTotal = (data) => ({ ...data, totalAmount: /* ... */ });
export const buildOrderData = (input) =>
pipe(validateInput, enrichMetadata, calculateTotal)(input);缓存装饰器
// 高阶函数:给任意函数加缓存
export const withCache = (fn, options) => {
const cache = new Map();
return async (...args) => {
const key = options.keyFn(...args);
const cached = cache.get(key);
if (cached && cached.expireAt > Date.now()) return cached.value;
const result = await fn(...args);
cache.set(key, { value: result, expireAt: Date.now() + options.ttl * 1000 });
return result;
};
};
export const getOrderCached = withCache(getOrder, {
keyFn: (id) => `order:${id}`,
ttl: 60
});性能优化
// 并发处理:纯函数天然支持并发
export const processBatchOrders = (inputs) => Promise.all(inputs.map(createOrder));
// Memoize:缓存纯函数结果
const memoize = (fn) => {
const cache = new Map();
return (...args) => {
const key = JSON.stringify(args);
return cache.has(key) ? cache.get(key) : cache.set(key, fn(...args)).get(key);
};
};
export const calculateShippingFee = memoize((weight, distance) => weight * distance * 0.1);总结
函数式架构用纯函数 + TypeScript 类型 + Zod 校验,更符合node.js 体质:
- 中间件链天然就是函数组合
- 纯函数易测试、易复用、易并发
- Zod 校验 + Prisma select,无需手动写 DTO Mapper
- 代码量减少 50-70%(省掉类的样板代码)
- 无需复杂的依赖注入容器
- 按需选择复杂度(而非强制分层)
关键原则:
- 纯函数优先(Domain 层)
- 副作用集中管理(Service 层)
- 编排组装:中间件组合(Routes 层)
- Zod 守门:只做校验和简单转换
- 按需抽象:不过度设计
Express 不是 NestJS,别强行用 OOP。顺着 Express 的设计走,代码会更简洁。