一、为什么需要清晰的接口边界?

很多团队做前后端分离时,习惯直接把数据库表结构暴露成接口。比如用户表有id, name, email, password, created_at,后端就直接给前端返回这些字段,前端想改密码就发PUT /api/user带上所有字段。这种设计看似省事,但一旦业务逻辑复杂起来,问题就来了。

举个例子:线下餐馆点菜。厨师直接对着顾客的原始需求炒菜,顾客说“我要一份番茄炒蛋不要葱”,厨师就得从这句话里自己琢磨“蛋要几个?番茄要不要去皮?”。如果顾客换了个说法,厨师又得重新理解。而干净的做法是:前厅服务员(控制器)把顾客需求整理成标准单据(用例输入),传给后厨(领域层)。后厨只关心“一份标准番茄炒蛋,去葱花”,不管单据从哪来、怎么送。这就是Clean Architecture的核心理念——让业务逻辑(领域层)不知道自己在被HTTP调用。

前后端分离的API设计也一样。如果我们直接把HTTP请求里的数据塞给业务逻辑,业务逻辑就变成了“HTTP处理逻辑”,无法脱离网络框架测试,也无法轻松替换成别的传输方式(比如消息队列)。所以我们需要一条明确的边界:控制器负责解析HTTP请求,转换成领域理解的输入对象,然后调用用例;用例返回结果后,控制器再转换成HTTP响应。

二、核心概念:用例与接口的映射

2.1 什么是用例?

用例(Use Case)是业务系统中的一项完整功能。比如“用户注册”是一个用例,“修改密码”是另一个。用例只关心“做什么”,不关心“怎么传输”。在Clean Architecture中,用例属于应用层,它依赖领域层的实体和接口,但不依赖外部框架。

每个用例通常有一个输入数据对象(Request)和一个输出数据对象(Response)。输入对象包含用例需要的数据,输出对象包含用例执行后的结果。这些对象是纯粹的数据结构,不沾任何HTTP相关的东西。

2.2 如何将用例暴露为REST API?

把用例映射成REST接口,关键在于“一对一”或“一对多”?通常一个用例对应一个HTTP端点。比如“用户注册”对应POST /api/users,“获取用户信息”对应GET /api/users/:id。控制器从请求中提取参数,组装成用例输入对象,然后执行用例,最后把用例输出对象序列化成JSON返回。

注意:一个HTTP端点可能涉及多个用例吗?有可能,比如复杂查询需要调用多个用例,但站在Clean Architecture角度,那应该拆成组合用例或者引入一个编排层,而不是让控制器同时调多个用例。控制器的职责是薄薄一层,只做转换。

三、实战示例:以用户注册功能为例

下文所有代码使用技术栈:JavaScript + Node.js + Express

3.1 领域层:注册用例

我们先定义用例输入和输出对象,这些是纯JavaScript对象,不含任何请求/响应依赖。

// src/application/use-cases/register-user.use-case.js

/**
 * 注册用例的输入数据
 * @typedef {Object} RegisterUserInput
 * @property {string} name - 用户名
 * @property {string} email - 邮箱
 * @property {string} password - 明文密码
 */

/**
 * 注册用例的输出数据
 * @typedef {Object} RegisterUserOutput
 * @property {string} id - 用户ID
 * @property {string} name - 用户名
 * @property {string} email - 邮箱
 * @property {Date} createdAt - 创建时间
 */

/**
 * 注册用例
 * 它只依赖领域接口(比如用户仓储),不依赖HTTP
 */
class RegisterUserUseCase {
  /**
   * @param {Object} dependencies
   * @param {import('../../domain/repositories/user-repository')} dependencies.userRepository
   * @param {import('../../domain/services/password-hasher')} dependencies.passwordHasher
   */
  constructor({ userRepository, passwordHasher }) {
    this.userRepository = userRepository;
    this.passwordHasher = passwordHasher;
  }

  /**
   * 执行注册
   * @param {RegisterUserInput} input
   * @returns {Promise<RegisterUserOutput>}
   */
  async execute(input) {
    // 1. 验证数据(简单示例,实际应抛出自定义错误)
    if (!input.email || !input.password) {
      throw new Error('邮箱和密码不能为空'); // 真实项目中应使用领域异常
    }

    // 2. 检查邮箱是否已存在
    const existingUser = await this.userRepository.findByEmail(input.email);
    if (existingUser) {
      throw new Error('邮箱已被注册');
    }

    // 3. 加密密码
    const hashedPassword = await this.passwordHasher.hash(input.password);

    // 4. 创建用户实体
    const user = {
      name: input.name,
      email: input.email,
      password: hashedPassword,
      createdAt: new Date()
    };

    // 5. 保存用户
    const savedUser = await this.userRepository.save(user);

    // 6. 返回输出对象(不包含密码)
    return {
      id: savedUser.id,
      name: savedUser.name,
      email: savedUser.email,
      createdAt: savedUser.createdAt
    };
  }
}

module.exports = RegisterUserUseCase;

3.2 控制器层:处理HTTP请求

控制器负责接收Express的reqres,从中提取数据传给用例,并把用例结果格式化成HTTP响应。

// src/interface-adapters/controllers/register-user.controller.js

/**
 * 注册用户控制器
 * 职责:从HTTP请求提取数据,调用用例,构造HTTP响应
 */
class RegisterUserController {
  /**
   * @param {import('../../application/use-cases/register-user.use-case')} registerUserUseCase
   */
  constructor(registerUserUseCase) {
    this.registerUserUseCase = registerUserUseCase;
  }

  /**
   * Express中间件风格的处理函数
   */
  async handle(req, res) {
    try {
      // 1. 从请求体中提取数据,构造用例输入
      const input = {
        name: req.body.name,
        email: req.body.email,
        password: req.body.password
      };

      // 2. 调用用例(领域层完全不知道HTTP存在)
      const output = await this.registerUserUseCase.execute(input);

      // 3. 将用例输出转换为HTTP响应
      res.status(201).json({
        success: true,
        data: output
      });
    } catch (error) {
      // 简单的错误处理:区分业务错误和系统错误
      if (error.message === '邮箱已被注册' || error.message === '邮箱和密码不能为空') {
        return res.status(400).json({
          success: false,
          error: error.message
        });
      }

      console.error('注册失败:', error);
      res.status(500).json({
        success: false,
        error: '服务器内部错误'
      });
    }
  }
}

module.exports = RegisterUserController;

3.3 路由层:定义端点

路由层只是把HTTP方法和路径绑定到控制器的处理方法上。

// src/infrastructure/http/routes/user.routes.js

const express = require('express');
const RegisterUserController = require('../../../interface-adapters/controllers/register-user.controller');

/**
 * 用户相关路由
 * 注意:这里不直接引用用例,而是通过依赖注入获取控制器实例
 * 实际项目中应该用IoC容器管理依赖
 */
function createUserRoutes(registerUserUseCase) {
  const router = express.Router();
  const controller = new RegisterUserController(registerUserUseCase);

  /**
   * POST /api/users - 注册用户
   */
  router.post('/users', (req, res) => controller.handle(req, res));

  return router;
}

module.exports = createUserRoutes;

3.4 完整应用组装

最后在Express入口文件里,组装所有依赖并启动服务。

// src/app.js

const express = require('express');
const createUserRoutes = require('./infrastructure/http/routes/user.routes');
const RegisterUserUseCase = require('./application/use-cases/register-user.use-case');
// 假设已经有对应的仓储和密码哈希器实现
const InMemoryUserRepository = require('./infrastructure/persistence/in-memory-user-repository');
const BcryptPasswordHasher = require('./infrastructure/services/bcrypt-password-hasher');

const app = express();
app.use(express.json());

// 实例化领域依赖
const userRepository = new InMemoryUserRepository();
const passwordHasher = new BcryptPasswordHasher();
const registerUserUseCase = new RegisterUserUseCase({ userRepository, passwordHasher });

// 挂载路由
app.use('/api', createUserRoutes(registerUserUseCase));

// 启动服务器
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

通过这个示例可以看到,RegisterUserUseCase里完全没有reqresstatusjson这些词,它只和纯JavaScript对象打交道。即使以后要把注册功能改成通过gRPC或者消息队列调用,用例本身不需要改任何代码,只需要新增一个对应的控制器即可。

四、应用场景分析

这种设计特别适合大型项目或者需要长期维护的系统。比如:

  • 微服务架构:每个服务内部都是Clean Architecture,HTTP只作为入口之一,还可以通过事件驱动调用用例。
  • 多端支持:同样的用例可以被Web端、移动端、第三方开放API调用,只需要为每个端写不同的控制器,但用例复用。
  • 单元测试:测试用例时不需要启动HTTP服务器,直接构造输入对象调用execute方法即可,速度极快。

不适合的场景:

  • 非常简单的CRUD应用,比如只有一张表的增删改查,过度分层反而增加代码量。
  • 快速原型开发,追求迭代速度时,先精简架构后期再重构。

五、技术优缺点

优点:

  • 业务逻辑与框架解耦:更换Web框架(从Express到Koa甚至Fastify)只需要改控制器和路由,领域代码不动。
  • 可测试性强:用例可以脱离网络层独立测试。
  • 清晰的责任边界:新成员一看就知道该在哪添加代码——修改业务逻辑进用例,修改API格式进控制器。
  • 更容易进行代码审查和变更管理。

缺点:

  • 初期代码量多:每个用例都要写输入/输出对象、控制器、路由,比直接写router.post('/api/users', (req,res)=>{...})多几步。
  • 需要团队熟悉Clean Architecture的概念,学习成本较高。
  • 对于极简单的接口,可能感觉“杀鸡用牛刀”。

六、注意事项

  1. 不要过度抽象:只有真正存在多种传输方式或技术栈替换需求时,才需要严格隔离。如果项目永远不会换框架,适当简化控制器层是可以接受的。
  2. 错误处理分离:用例抛出的错误应该是业务错误(如“邮箱重复”),而不是HTTP状态码。控制器负责把这些业务错误映射成合适的HTTP响应(比如400、409等)。
  3. 输入验证位置:简单的格式验证(如邮箱格式)可以放在控制器层,因为那属于网络传输层的校验;但业务校验(如余额是否充足)必须放在用例中。不要把业务规则泄露到控制器。
  4. DTO(数据传输对象)的选择:用例输入输出尽量使用简单的Plain Object,不要使用ORM实体或数据库模型,避免领域层依赖数据框架。
  5. 事务管理:如果用例需要跨多个仓储操作,事务通常应该由用例控制,但数据库事务往往依赖特定框架(如TypeORM的transaction)。此时可以定义一个事务接口(ITransactionManager)在领域层,然后在基础设施层实现,这样领域层仍然保持透明。

七、文章总结

前后端分离的接口设计并不是写几个REST端点那么简单。Clean Architecture告诉我们,把HTTP请求和响应的转换限制在控制器层,让领域层专注于业务逻辑,是让代码保持灵活、可测试、可演化的关键。通过用例来定义API边界,每个端点对应一个明确的业务功能,控制器只做“翻译官”的工作。虽然初期需要多写一些胶水代码,但长期来看,当需求频繁变更、团队扩大时,这种投入绝对值得。

记住一点:不要让HTTP请求像病毒一样感染你的业务逻辑。保持领域层纯净,你的系统才能健康生长。