引言:那些年我们写过的"面条代码"
痛点场景: 你一定经历过这样的噩梦:系统最初用 MySQL 存储数据,后来为了性能要迁移到 MongoDB。结果你发现,业务代码里密密麻麻全是对 MySQL 驱动的直接调用。或者,老板突发奇想,要求把原本的 Web 页面功能,原封不动地搬到一个新的命令行工具(CLI)里,你却发现业务逻辑和 HTTP 的 Request / Response 对象死死绑定在一起。
"牵一发而动全身",修改一行代码,整个系统崩溃。这是因为我们的核心业务逻辑被外部框架、数据库和 UI 强行"绑架"了。
解决方案: 这时候,六边形架构(Hexagonal Architecture) 闪亮登场。它的核心理念极其简单:将核心业务逻辑与外部依赖彻底隔离。它能让你的业务代码变得像插座一样通用,无论外接的是哪种数据库或哪个前端框架,核心系统都能从容应对。
概念拆解:餐厅后厨的生存哲学 (The "What" & "Why")
生活化类比:米其林餐厅的运作
别被"六边形"这个高大上的名字吓到,它其实也被称为端口与适配器模式(Ports and Adapters)。我们用"去餐厅点餐"来理解它:
想象一家顶级的米其林餐厅,它的核心竞争力是"后厨大厨的烹饪手艺"(核心业务逻辑)。
-
输入端(Driving) :大厨根本不在乎客人是通过服务员点餐、通过美团外卖下单,还是打电话预定。大厨只认一样东西:标准化的点餐单(输入端口 Input Port) 。服务员和外卖App在这里就是输入适配器(Input Adapter),负责把各种乱七八糟的请求翻译成大厨能看懂的点餐单。
-
输出端(Driven) :大厨做菜需要土豆。他不会自己跑去菜市场买,他只会对采购员下达指令:"给我拿两个土豆"(输出端口 Output Port) 。至于采购员是从门口超市买的,还是从远洋货轮上空运的(输出适配器 Output Adapter,如 MySQL、Redis、第三方 API),大厨毫不关心。
工作流图解:洋葱般的结构
如果画个图,六边形架构就像一个洋葱,分为内、中、外三层:
-
最内层(Domain):纯粹的业务实体和规则(大厨的手艺)。这里没有任何外部框架的代码。
-
中间层(Ports):接口定义层(点餐单和采购单)。它规定了外部如何与核心交互,以及核心如何向外部要数据。
-
最外层(Adapters):具体的实现(服务员、外卖App、采购员)。比如 Spring Boot 控制器、REST API、MyBatis Mapper。
核心原则:依赖只能从外向内! 外层可以调用内层的接口,但内层绝对不能知道外层的任何细节。
动手实战:用 TypeScript 烤一个"六边形"MVP (The "How")
让我们用 TypeScript 写一个极简的"创建用户"功能,感受一下六边形架构的魅力。
1. 核心领域与端口(Domain & Ports)
首先,我们定义核心逻辑和它需要的"契约"。这部分代码绝对不能 引入任何外部库(比如 express 或 mongoose)。
TypeScript
// 1. 领域模型 (Domain Model) - 纯纯的业务对象
export class User {
constructor(public readonly id: string, public name: string, public email: string) {}
}
// 2. 输出端口 (Output Port) - 核心业务向外要数据的"采购单"
export interface UserRepositoryPort {
save(user: User): Promise<void>;
findByEmail(email: string): Promise<User | null>;
}
// 3. 输入端口 (Input Port) - 外部调用核心业务的"点餐单"
export interface CreateUserUseCase {
execute(name: string, email: string): Promise<User>;
}
2. 应用服务(Application Service)
接下来,实现核心业务逻辑。它实现了输入端口,并调用输出端口。
TypeScript
// 4. 核心业务逻辑实现
export class UserService implements CreateUserUseCase {
// 依赖注入:我不在乎你传给我的是 MySQL 还是 MongoDB,只要实现了接口就行!
constructor(private readonly userRepository: UserRepositoryPort) {}
async execute(name: string, email: string): Promise<User> {
const existingUser = await this.userRepository.findByEmail(email);
if (existingUser) {
throw new Error("邮箱已被注册!"); // 纯粹的业务异常
}
const newUser = new User(Date.now().toString(), name, email);
await this.userRepository.save(newUser); // 调用输出端口
return newUser;
}
}
3. 适配器层(Adapters)
最后,我们编写外围代码,连接真实的世界。
TypeScript
// 5. 输出适配器 (Output Adapter) - 真正连接数据库的地方
// 假设这里用的是内存数据库,随时可以换成 MongoUserRepository
export class InMemoryUserRepository implements UserRepositoryPort {
private users: User[] = [];
async save(user: User): Promise<void> {
this.users.push(user);
console.log(`[Database] 用户 ${user.name} 已保存到内存数据库。`);
}
async findByEmail(email: string): Promise<User | null> {
return this.users.find(u => u.email === email) || null;
}
}
// 6. 组装运行 (类似框架的 Controller / CLI 入口)
async function main() {
// 装配阶段:将适配器插入端口
const repository = new InMemoryUserRepository();
const userService = new UserService(repository);
// 模拟一个 HTTP 请求进来
console.log("--> 收到前端请求:创建用户张三");
try {
const user = await userService.execute("张三", "zhangsan@example.com");
console.log("<-- 响应前端:创建成功", user);
} catch (error) {
console.error("<-- 响应前端:创建失败", error.message);
}
}
main();
代码解析: 为什么这么写?你会发现 UserService 里没有任何数据库的影子。如果明天老板说要把用户数据存到 Redis,你只需要新建一个 RedisUserRepository 实现 UserRepositoryPort 接口,然后在组装阶段替换掉 InMemoryUserRepository 即可。核心业务代码一行都不用改!
进阶深潜:新手防坑指南 (Deep Dive)
常见陷阱
⚠️ 在领域实体里写 ORM 注解 很多新手会在
User类上加上@Entity或@Table(name="users")。千万别这么干!这就等于让大厨在菜谱上写满了采购员的进货路线。领域对象必须是纯洁的,数据库持久化对象(Entity/Model)应该在 Adapter 层单独定义,并在 Adapter 里进行转换映射(Mapper)。
最佳实践
-
测试驱动开发(TDD)的绝佳搭档:由于核心逻辑(Domain & Application)只依赖接口(Ports),你可以非常轻松地 Mock 掉数据库,实现秒级的单元测试。
-
切忌过度设计 :六边形架构会增加类的数量(多了一堆接口和转换代码)。如果你的项目只是一个简单的、生命周期很短的 CRUD 脚本,传统的 MVC 三层架构可能更高效。好钢要用在刀刃(复杂核心业务)上。
总结与延伸 (Conclusion)
核心总结:六边形架构通过"端口与适配器"的机制,建立了一堵防火墙,让你的核心业务逻辑摆脱了底层技术选型的束缚,实现了真正的可插拔、可测试、可维护。