在六边形架构里,模块之间到底该怎么“谈恋爱”?别再瞎调用了!
你是不是也正在尝试用六边形架构(Hexagonal Architecture)来搭建你的应用?是不是觉得它把业务逻辑和外部技术(比如数据库、API、UI)彻底隔离开了,特别清爽?但当你真的动手做项目,尤其是单体应用里拆出好几个模块——比如用户模块、文章模块、分类模块——问题就来了:文章模块需要查用户信息,分类模块也需要验证用户是否存在,那它们该怎么跟用户模块“沟通”?难道直接调用对方的内部类?那岂不是又耦合了?
别急,今天我们就来彻底讲清楚:在六边形架构下,模块之间到底该怎么安全、松耦合地互动。
首先,咱们得先搞明白六边形架构的核心思想。它不是教你怎么拆分业务模块的,而是告诉你:你的核心业务逻辑(Domain Logic)必须像一座孤岛,外面的世界——不管是前端界面、HTTP接口、命令行工具,还是数据库、文件系统、消息队列——都只能通过“港口”(Ports)和“适配器”(Adapters)来跟它打交道。
换句话说,你的业务代码不能直接依赖任何具体的技术实现。
驱动层(Drivers)负责把外部请求“推”进来,驱动业务逻辑;驱动层可以是Web控制器、CLI命令、消息消费者;
而被驱动层(Driven)则是业务逻辑“拉”出去用的资源,比如数据库访问、第三方API调用。
关键点来了:所有依赖方向必须是“向内”的——驱动层和被驱动层都依赖业务核心,而不是反过来。
那问题来了:如果我的业务核心本身又被拆成了多个模块(比如用户、文章、分类),它们之间该怎么协作?
这时候很多人会误以为六边形架构规定了模块内部怎么组织,其实不是。六边形只管“边界”——即你的核心领域对外暴露什么接口,外部怎么接入。至于你内部是单块还是模块化单体(Modular Monolith),那是你自己的事。
但一旦你选择模块化,就必须遵守同样的原则:模块之间不能直接调用对方的内部实现,而要通过“端口”来沟通。
举个具体例子:假设文章模块(Post)需要验证某个用户是否存在,或者获取用户的简要信息(比如昵称、头像)。按照六边形的思路,文章模块不应该直接调用用户模块的某个Service或Repository。相反,它应该定义一个“用户查询端口”(UserLookupPort),里面声明两个方法:userExists(userId) 和 getUserSummary(userId)。这个端口就是文章模块对外部世界(包括其他业务模块)提出的需求契约。注意,这个端口定义在文章模块的“应用层”或“领域层”,属于业务逻辑的一部分。
然后,谁来实现这个端口?
答案是:由用户模块提供一个“适配器”(Adapter)。这个适配器可以叫 UserLookupAdapter,它实现了 UserLookupPort 接口,并在内部调用用户模块自己的服务(比如 UserService)来完成实际逻辑。这个适配器通常放在应用的“基础设施层”(Infrastructure Layer),并通过依赖注入(DI)框架(比如 Spring Boot 或 NestJS)注入到文章模块的应用服务中。
这样一来,文章模块完全不知道用户数据是从数据库查的、缓存拿的,还是通过 HTTP 调另一个微服务拿的。它只关心“我需要一个能查用户信息的端口”,而具体实现由外部适配器提供。这种设计保证了模块之间的解耦:哪怕你明天把用户模块重构成独立微服务,只要适配器换成 HTTP 客户端实现,文章模块的代码一行都不用改。
那如果不止文章模块,分类模块、评论模块、通知模块都需要查用户信息呢?难道每个模块都定义自己的 UserLookupPort?其实没必要。你可以把通用的用户查询端口抽象成一个共享的“契约模块”(比如叫 user-api 或 user-contract),里面只包含接口和 DTO(数据传输对象),不包含任何实现。这样,所有需要用户数据的模块都依赖这个契约模块,而用户模块负责提供实现。这种做法在大型单体应用中非常常见,既能复用接口,又避免了循环依赖。
还有一种更高级的解耦方式:事件驱动:如果你的系统对实时性要求不高,或者只需要读取用户数据(而不是强一致性校验),可以考虑用领域事件(Domain Events)。比如,当用户注册或更新信息时,用户模块发布一个 UserCreated 或 UserUpdated 事件。其他模块(如文章、分类)监听这些事件,并在本地维护一个“用户只读视图”(User Read Model)——可能是一张专门的数据库表,也可能是 Elasticsearch 索引。这样,文章模块查用户信息时,直接查自己的本地视图,完全不需要调用用户模块。这种模式极大降低了模块间的运行时耦合,特别适合高并发、读多写少的场景。
当然,事件驱动也有代价:数据最终一致性、额外的存储开销、事件回放复杂度。所以,对于需要强一致性的操作(比如“只有作者才能编辑文章”),还是得通过同步调用端口来完成。这时候,你可以设计一个“协调服务”(Orchestrator Service),它同时调用文章模块和用户模块的端口,在同一个事务上下文中完成校验和操作。
再来说说技术实现:
在 Spring Boot 中,你可以把 UserLookupPort 定义为一个接口,放在 post 模块的 domain 或 application 包下;
然后在 user 模块的 infrastructure 包里实现它,并用 @Component 或 @Service 注解标记。
通过 Spring 的自动装配,PostApplicationService 就能直接注入这个端口。
而在 NestJS 中,你可以把 UserLookupPort 定义为一个抽象类或接口,用户模块导出一个 Provider(比如 UserLookupProvider),文章模块通过模块导入(Module Import)来使用它。
如果你的团队希望彻底隔离,甚至可以用 DreamFactory 这样的工具,为用户模块快速生成一个轻量级的 HTTP 读接口,其他模块通过 REST 调用——虽然这听起来像微服务,但在单体架构里,它只是另一种“适配器”而已。
最后强调一个铁律:永远不要让一个模块直接访问另一个模块的内部类、实体或仓库。哪怕它们都在同一个代码库里。一旦你开始 new UserService() 或直接调用 UserRepository.findById(),你就破坏了六边形架构的边界,未来的重构成本会指数级上升。
记住,模块之间的唯一合法通道是:端口(Port) + 适配器(Adapter) 或 领域事件(Domain Event)。
(这个通道实际就是构建一个场景用例上下文Context)
总结一下:六边形架构不是限制你拆模块,而是教你如何安全地拆。模块之间不“调用”,只“依赖端口”;不“知道实现”,只“声明需求”。通过依赖倒置、接口隔离和事件驱动,你可以在保持单体应用开发效率的同时,获得接近微服务的松耦合优势。这才是现代软件架构的真正智慧。