这份文档是 server 的架构与边界设计说明,不再承担日常执行级后端规范的唯一入口。
后端执行级治理真值现在收口到 server/AGENTS.md:
server/AGENTS.md负责日常实现约束,例如 module 生命周期、internal/moduleapi与 contract 边界、DI 与 wiring、Go 代码组织、Ent/migration 与 backend validationserver/AGENTS.md也负责 server agent 的任务生命周期模板,例如 startup preflight 后的 task classification、boundary decision、implementation path、validation 与 closeout 记录格式- 本文档负责解释为什么采用 module-oriented modular monolith backend、为什么保持轻量 DI,以及为什么这些边界服务于长期可扩展性
如果实现级规则与本文档的表述需要分层,执行时以 server/AGENTS.md 为准;若该执行级规则改变了模块系统、DI 职责或后端长期边界,再同步更新本文档。
术语说明:
- 当前 backend 的 canonical 业务能力单元是
module - 当前 canonical 物理路径已经迁到
module/modules,跨模块稳定边界也已经迁到internal/moduleapi - 除非明确讨论现有包路径,否则都应按 compile-time module 语义理解
这份文档只回答两件事:
- 模块之间如何协作
- 为什么引入轻量 DI,以及它具体负责什么
目标不是引入一个“万能容器”,而是让平台在模块数量增加时仍保持清晰、可测、可维护。
补充一点:当前仓库不仅需要“能实现后端功能”,还需要一条对 agent 和人工贡献者都稳定的 server 开发路径。
因此制度化后的分层是:
server/AGENTS.md- 回答“后端任务现在应该怎么做”
- 包括 startup preflight 之后的 task classification、boundary decision、implementation path、validation 与 closeout 模板
- 本文档
- 回答“为什么后端任务必须这么做”
- 用架构理由支撑 compile-time registry、显式 CLI、module-owned persistence、stable capability 边界与轻量 DI
模块之间禁止直接依赖彼此内部实现。
统一交互方式:
- 平台核心提供基础设施能力
- 模块通过稳定 capability contract 暴露公开服务
- 其它模块通过接口解析依赖
典型例子:
auth模块公开AuthService与后续AuthSessionService这类认证生命周期 capabilityuser模块公开稳定用户身份与用户管理 capability,而不是把 token/session 细节长期留在userrbac模块依赖UserService或稳定身份查询接口,而不是依赖modules/user/service的具体 structuser只对rbac暴露稳定用户能力,例如用户存在性检查、基础身份查询、删除前约束检查rbac处理user_roles写入时通过该稳定接口校验user_id,而不是 importuser的 Ent 包、repository 或其它私有持久化实现saved-view仅通过SavedViewService持久化调用方私有的列表状态,不依赖user;需要该能力的业务模块显式依赖saved-view并继续拥有筛选状态与路由鉴权语义auth不拥有 role、permission、resource metadata;rbac不拥有 login、token、session、cookie
这样做的收益:
- 模块边界清晰
- 单元测试更容易替换实现
- 后续拆分包结构时影响更小
容器只负责四类事情:
- 注册单例服务
- 解析服务
- 注册模块公开服务
- 统一释放资源
容器不负责:
- 包扫描
- 自动发现 provider
- 通过 tag 注入字段
- 隐式构造依赖图
- 复杂的 request scope / session scope
这意味着代码仍然保持 Go 风格的显式装配,只是把“装配入口”集中管理。
当前长期治理方向补充如下:
- 保持 compile-time modular monolith,而不是演进为 runtime plugin platform
- 当前以
module.Spec、module.Builder与 compile-time generated registry 作为 module registry,生命周期与接线命名也按Module语义收口 - 不引入 runtime filesystem scan、runtime plugin discovery、hot-load 或 generalized reflection plugin system
- 启动仍然要求 deterministic wiring 和单体进程语义
这也是为什么执行级治理必须坚持这些制度:
graft serve只负责 runtime 启动,不隐式执行 migrationgraft migrate up继续作为 schema 变更的显式入口Register / Boot / Shutdown必须职责分离,而不是把声明、启动、结构修改混在一个阶段- 模块之间优先通过
moduleapi或稳定contract协作,而不是互相 import 内部实现 - 新的业务真相必须留在
modules/<name>/**,而不是回流internal/**或已退场的internal/ent/**
如果这些规则不制度化,compile-time modular monolith 很快就会退化为“仍在单体里运行、但边界已经不可审计”的共享代码库。
这套方向对长期多工作树的约束也必须明确:
- 模块日常实现优先留在
modules/<name>/**这类 module-owned 边界 - 这里的“zero-shared”是指功能开发工作树尽量不共同持有业务实现面,不是要求所有 tracked file 绝对不共享
- 临时 server feature worktree 的任务范围默认围绕
server/modules/<name>/**,但目录不形成长期占用 internal/moduleapi/**与internal/contract/**只承载稳定共享契约,不作为长期“先放这里再说”的缓冲层internal/moduleregistry/generated.go可以保留为唯一集中 compile-time 接线热点,但它应是机械生成产物,而不是新的手写中心化模块清单internal/moduleregistry/generated.go当前保持 tracked;它和其他生成热点在开发者集成工作区串行生成并收口cmd/graft/**、AGENTS.md、ai-plan/**、migration 入口调整与共享 registry wiring 变更需要明确的 bounded integration slice,不形成长期 feature worktree 的 standing ownership- 若某个方向仍需要频繁同时修改 module-owned 代码和 core wiring / core schema,开发者应先在集成工作区收口 authority,再继续派发独立任务
- bounded scope forbids unrelated expansion, not required authority repair
- 若 authority 明确位于 module descriptor、shared contract、OpenAPI source 或 compile-time wiring,则允许通过最小必要跨边界切片同步修复 authority,而不是默认把 drift 留给下游兼容
推荐抽象出两个能力:
type Registry interface {
RegisterSingleton(key any, provider Provider) error
RegisterModuleService(module string, key any, provider Provider) error
}type Resolver interface {
Resolve(key any) (any, error)
MustResolve(key any) any
}如果实现时使用泛型,可以再包装成更易用的 typed API,但内部模型仍应保持“显式注册 + 显式解析”。
模块上下文不应继续无限膨胀。
建议分成三类能力:
- 配置
- 日志
- i18n
- auth-owned credential / session repository and user-owned profile repository
- HTTP 路由根
- 事件总线
- 菜单注册器
- 权限注册器
- 定时任务注册器
- Service Registry
- Service Resolver
这样可以避免把所有内容都塞进一个巨大的 Context 结构体。
长期并行开发方向补充约束:
module.Context不应无限扩张为“万能业务依赖袋”- 业务模块私有 repository 依赖更适合在 Builder 阶段装配,而不是长期保留中心化
store.Factory聚合入口 - capability 必须在 Builder 阶段注册,生命周期稳定,不暴露 repository、ORM entity、Ent client、storeent 实现或模块内部 struct
- capability 只允许暴露:
- cross-module business ability
- dev/reset hook
- stable query/service contract
当前 MVP 收敛阶段对这些能力补充约束:
-
auth作为独立模块拥有认证与会话生命周期 capability;后续 Phase 1~3 通过moduleapi迁出 token/session/cookie/refresh store 与/auth/*运行时所有权 -
auth是AuthService、AuthSessionService、AuthFlowService与AuthCredentialManagementService的唯一容器注册者;user只注册UserIdentityProvider与UserBootstrapProvider,不得提供认证装配 bundle 或认证持久化。 -
user继续拥有用户资料与用户管理资源;如保留管理员按用户维度的 session 治理入口,也只能调用authcapability,而不是反向持有 auth 持久化 -
OpenAPI spec covered 不等于 runtime migrated;shared contract 已存在时,module ownership 仍需单独迁移与验证
-
event bus采用server/internal/eventbus下的最小进程内实现;它保持同步发布、顺序派发和错误回传,作为模块间即时观察者通知,不承担后台投递职责 -
通用异步事件基础设施位于
server/internal/event,由Runtime持有并统一注入;它通过稳定的EventPublisher、EventHandler与 dispatcher 边界承载后台消费,不替换或隐式改变eventbus的语义 -
模块只能依赖其稳定接口,不直接依赖具体实现
-
scheduler作为运行期调度能力,应通过独立接口注入,而不是让业务模块直接依赖robfig/cron -
i18n 能力统一经由
server/internal/i18nfacade 注入;当前阶段先补强自管 registry,而不是在模块侧直接绑定未来可能引入的go-i18n -
locale resource 的物理 ownership 可以位于
server/modules/<name>/locales/*.yaml或server/internal/<owner>/locales/*.yaml, 但 YAML 解析、校验、duplicate 检查、registry construction 与Freeze仍只属于server/internal/i18n -
compile-time module descriptor 可以携带 locale resource provider 或 locale resource list,用于让 runtime 在模块
Register前统一预注册 owner-local resources;这不等于允许模块自持 loader 或平行 registry -
realtime hub 与 ticket service 属于 core 注入能力;业务模块只能消费稳定的
publish / subscribe / ticket issue边界,不得各自维护第二套长连接基础设施 -
platform-network是平台出站网络策略的唯一 owner。它通过OutboundNetworkProvider暴露当前有效策略,并通过OutboundHTTPClientFactory暴露NewOutboundHTTPClient(ctx, options ...OutboundHTTPClientOption);消费者只能使用该 factory,不能自行new http.Transport、构造未受策略约束的http.Client,也不能读取或修改HTTP_PROXY、HTTPS_PROXY、NO_PROXY环境变量。 -
OutboundHTTPClientOption只表达调用方的 HTTP 客户端行为,例如 timeout、TLS、User-Agent、retry、tracing 或 metrics; 代理、NO_PROXY、受信任 CA 与后续 mTLS 等平台网络策略始终由OutboundNetworkProvider决定,不能回写或扩张network.outbound的职责。 -
网络诊断通过可注册的
OutboundNetworkDiagnosticTargetcapability 暴露:target 自己声明稳定Name、本地化DisplayName并执行受控ExecuteOutboundDiagnostic(ctx)。诊断路由只能选择已注册 target,不能接收管理员提供的 URL;首个 target 是 Update 模块注册的 Platform Update Service。 -
需要长期后台采样的模块能力,例如容器 stats collector,应通过模块
Boot生命周期启动并在Shutdown阶段停止,而不是在 HTTP handler 内临时采样
Register 阶段只做声明式工作:
- 注册 HTTP 路由
- 注册菜单
- 注册权限点
- 注册 message bundles / message keys
- 注册任务定义
- 注册公开服务
Register 阶段不要做:
- 长时间初始化
- 启动 goroutine
- 执行耗时任务
- 依赖其它模块已经 Boot 完成的逻辑
Boot 阶段才做真正的运行时启动。
这里的制度化原因不是形式主义,而是为了保持开发流程可审计:
Register只做声明,才能在 closeout 中追溯 route、menu、permission、message、service 是否有清晰 ownerBoot不做 schema 修改,才能保证 schema 变化继续只经过显式 migration 链Shutdown明确回收边界,才能让 runtime 生命周期、测试和 smoke 验证口径一致
补充说明:
- 同步
eventbus订阅和异步EventHandler注册都属于Register阶段的声明式工作;注册面在进入Boot前冻结,避免运行期隐式追加消费者 - 异步 dispatcher 在
Boot阶段启动 worker;Shutdown时Runtime先停止接收新异步事件,再在有界关闭上下文中 drain 或标记未完成项,随后关闭依赖资源。模块不得自行启动不受 Runtime 管理的事件 goroutine scheduler读取cron registry并真正启动任务属于Boot阶段audit的 HTTP middleware 注册属于Register阶段,event 订阅也应在这一阶段声明完成- i18n message 注册属于
Register阶段;runtime 必须在进入Boot前冻结 i18n 注册面,并把 duplicate-key 视为声明期问题而不是运行期兜底 - 若 locale resource 由 module 或 internal owner package 自己
go:embed,runtime 必须在对应模块Register前完成统一 预注册;模块内的registerMessages()只允许校验 key 已存在,不得变成资源加载入口 - 模块通过
module.Context.LifecycleContext获取当前阶段上下文;Shutdown阶段由 runtime 注入独立的有界关闭 ctx,避免直接复用已取消的runCtx
跨模块公开服务建议统一放在 internal/moduleapi 或等价稳定位置。
规范如下:
- 暴露接口,不暴露实现细节
- 接口尽量面向业务能力,而不是数据库细节
- 平台共用 typed contract 放
server/internal/contract,模块私有稳定契约放server/modules/<module>/contract, 真正跨模块公开的稳定契约继续放server/internal/moduleapi - 模块只允许依赖:
server/internal/moduleapi/**server/internal/contract/**
- 其它模块公开 capability contract 或 stable DTO contract
- 禁止直接依赖其它模块的
service/**、storeent/**、ent/schema/**、migration 目录 - 模块上下文与公开接口不直接暴露
*ent.Client或其它具体 ORM 句柄 - 数据访问通过 repository / store factory 边界下沉到模块内部
- 返回值尽量稳定,不把 Ent entity 直接作为跨模块返回类型
user_roles这类最终归rbac拥有的持久化细节,必须留在rbac自己的 schema / repository / migration / test 边界内,不通过usercapability 反向泄露实现细节user_roles的跨模块协作边界应保持在user_id / role_id标识符级别,而不是继续依赖跨模块 Ent edge 或共享 ORM 真相permission code、event name、message key、跨模块状态枚举等高风险契约,应遵循 契约治理与魔法值治理规范 的 canonical ownership、lifecycle 与 compatibility 规则- 跨模块注册、发布、解析边界优先消费 typed contract,而不是继续以裸字符串作为长期真值
- 同一语义的跨模块 contract 只允许一个 canonical definition;兼容期 alias 只能用于过渡,不得演变为平行标准
- 当前 locale 范围只收敛到
zh-CN/en-US;新增模块 message contract 不得假定仓库已进入多语言自由扩展阶段
这套边界同时服务于 server agent 的 boundary decision:
- 如果能力是平台级公共语义,就进入
internal/contract/** - 如果能力是跨模块稳定业务协作,就进入
internal/moduleapi/** - 如果能力只属于单个模块的 route fragment、permission code、message key 或菜单语义,就留在该模块
contract/** - 如果某项协作仍需要直接 import 对方 repository、handler、storeent 或 schema,说明边界尚未设计完成,不应直接进入实现
示例:
- 好:
GetUserByID(ctx, id) (UserSummary, error) - 好:
CheckUserExists(ctx, userID) error - 好:
CheckUserDeletionConstraints(ctx, userID) error - 差:
GetEntClient() - 差:
AssignRoleViaUserRepo() - 差:
GetUserRepo() *UserRepository
当前 MVP 收敛阶段新增的边界约束:
event bus的公开接口只表达Subscribe / Publish / handler 注册这类最小能力- 不提前暴露 ack、retry、dead-letter、consumer-group、partition 等分布式 MQ 语义
- 通用异步事件基础设施是与
eventbus并存的项目级能力,不以审计、日志或任一业务模块命名;业务模块仅依赖Event、EventPublisher与EventHandler抽象 - 异步事件的公共外壳只包含通用标识、类型、来源、版本、载荷、元数据和时间/关联信息;业务语义留在模块自有 payload DTO,投递状态、重试次数与错误信息属于 delivery 记录而非业务 Event 字段
DeliveryBestEffort使用有界内存 buffer、worker pool、有界重试和优雅关闭;队列饱和和关闭超时必须显式反馈或记录,不能静默丢弃DeliveryDurable使用 core-owned PostgreSQL Outbox:事件 envelope 和当前注册 consumer 的(event_id, consumer_id)delivery 在同一事务写入;worker 用租约、FOR UPDATE SKIP LOCKED和至少一次投递完成重启及多实例恢复。业务状态变更需要 durable event 时,必须复用同一 SQL transaction 调用注入的TransactionalPublisher.PublishTx,不得在提交后以 Publisher 补写形成 dual-write 缺口- Redis Stream、RabbitMQ、Kafka 等外部 transport 仅在真实规模和运维能力证明需要后,通过
EventPublisher/dispatcher adapter 引入;业务模块不得直接依赖具体 MQ 客户端 audit的跨模块边界优先暴露“写审计记录”或“发送审计事件”的稳定能力,而不是暴露底层仓储scheduler的跨模块边界优先暴露RegisterJob / RemoveJob / Start / Stop这类仓库内稳定接口- 业务模块禁止直接 import
github.com/robfig/cron/v3 platform-network对跨模块公开的OutboundNetworkProvider、OutboundHTTPClientFactory与OutboundNetworkDiagnosticTargetregistry 必须位于internal/moduleapi。它们返回策略值、配置过的 client 或受控诊断 结果,不暴露具体http.Transport、System Config store 或网络模块私有实现。首期只有update消费 factory; Marketplace、Registry、Helm、AI、Webhook 与 OAuth/OIDC 等后续主动 HTTP 消费者直接复用该 capability,不新增模块级代理 配置。- 权限声明的稳定元数据(如
permission code、category)由权限注册声明侧提供 canonical truth,持久化层只负责保存,不在仓储或 migration 默认值里二次推断
在 server ownership 还处于从共享热点继续下沉到 module-owned 边界的阶段,额外补充以下约束:
server/internal/ent/**目前只允许作为过渡兼容层存在,不得继续新增业务真相- 只要 import、runtime、test 或 generation 仍依赖
server/internal/ent/**,它就还是共享治理热点,而不是可安全独占的 module-owned 面 - 只有在这些依赖完全清除后,
server/internal/ent/**才允许物理删除 - 当前治理允许 fresh DB rebuild;不要求为了历史 mixed migration 继续维护长期兼容回放
- 这不改变
graft migrate up作为显式迁移入口的规则,只是明确当前 ownership checkpoint 可以基于新库重建来验证目标边界
建议把模块分两类:
- 核心模块:失败时阻止应用启动
- 可选模块:失败时记录错误并跳过
第一阶段默认这些属于核心模块:
userrbac
这些在更广能力面上仍可保持可选,但在当前 MVP 闭环收敛阶段需要优先补齐实现:
auditscheduler
- 重复注册同一模块
- 缺失依赖模块
- 循环依赖
- 模块公开服务未注册
- 模块请求服务但服务不存在
- 同一服务被重复注册
Register -> Boot -> Shutdown顺序错误- 模块关闭时资源未释放
- event handler panic 是否被 recover
- event handler error 是否被稳定记录
auditmiddleware 与 event 审计路径是否都可用scheduler是否能按显式生命周期启动、停止与移除任务
为支持长期多工作树并行开发,同时保留未来三方插件生态的可演进边界,当前设计补充以下约束:
module.Spec是当前 compile-time 模块元数据基础- capability interface 是未来跨模块调用边界
internal/moduleapi/**与internal/contract/**是未来唯一稳定公开 API 面- 模块内部实现必须与稳定公开边界严格分离
当前不实现:
- runtime plugin loading
- external plugin marketplace
- hot reload lifecycle
- sandbox execution
- distributed plugin protocol
- microservice decomposition
运行时资源、capability visibility 与 composition declaration 的具体规则见
运行时组合与资源治理设计.md。module.Context 继续只是 Module lifecycle 的显式注入表面;
它不得演变为能够动态派生所有服务可见性、自动重启依赖或接管 Task/Agent 生命周期的全局 Context。
这样做的目的,是让未来若需要“类似 Jenkins 的三方插件生态”时,仍有稳定的元数据、契约与 capability 边界可复用; 但在当前阶段,仓库仍然只是 compile-time modular monolith。
这个项目需要 DI,但只需要“轻量、显式、可调试”的 DI。
判断标准很简单:
- 如果一个新模块接入时,依赖注册与解析路径清晰,那容器设计就是对的
- 如果实现者开始依赖反射魔法、扫描和隐式行为,说明容器已经做过头了
- 如果为了赶 MVP 闭环而把
event bus、审计、调度直接泄漏为底层实现细节,说明边界已经被破坏