断断续续用了好几年的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' };
};

何时自定义领域模型类型

  • 业务概念与数据库表不一致(如聚合根、值对象)
  • 需要在创建前构建数据(OrderDraft vs Order
  • 有复杂的业务规则和状态转换

关键区别:函数式架构用 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 InputZod校验、类型转换、字段重命名、默认值
Domain Input → DB Entity⚠️ 手动复杂业务逻辑、关系映射、聚合
DB Entity → Response DTOPrisma 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%(省掉类的样板代码)
  • 无需复杂的依赖注入容器
  • 按需选择复杂度(而非强制分层)

关键原则

  1. 纯函数优先(Domain 层)
  2. 副作用集中管理(Service 层)
  3. 编排组装:中间件组合(Routes 层)
  4. Zod 守门:只做校验和简单转换
  5. 按需抽象:不过度设计

Express 不是 NestJS,别强行用 OOP。顺着 Express 的设计走,代码会更简洁。