关于 alchemy-domain
这是一套使用 `@alchemy.run/domain` 构建后端的工作流技能,以类型化消息和状态转换建模。它指导开发者从需求分析、领域建模和故事测试,推进到 Cloudflare 托管及 foldkit 前端。
主要功能
- 01需求拆解为领域模型
- 02类型化领域构件
- 03基于故事的行为测试
- 04Cloudflare 托管指导
- 05foldkit 前端工作流
适用场景
- 01建模命令与业务事件
- 02通过故事测试领域行为
- 03在 Cloudflare 上托管模型
- 04用 foldkit Web 应用驱动模型
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ alchemy-run/ alchemy --skill alchemy-domain安装后在 Claude Code 中输入/alchemy-domain确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
references/building-blocks.mdfrontend.mdhosting.mdlayout.mdmodeling.mdtesting.md详细介绍
使用 @alchemy.run/domain 构建
@alchemy.run/domain 将后端建模为类型化消息和纯状态转换。命令由聚合决定,并由聚合记录事件。视图将事件折叠为读取模型。策略对事件作出响应。Saga 负责运行长期流程。投影将数据写入模型之外,查询则提供写入的数据。同一模型既可在内存中运行以供测试,也可在生产环境中运行于 Cloudflare Durable Objects。
文档:https://alchemy.run/domain,所有页面都列在 https://alchemy.run/llms.txt 中。这里的每个章节和参考文件中的每个章节末尾都有一个 More 链接,指向深入介绍该主题的文档页面。完整的参考项目:alchemy 仓库中的 examples/cloudflare-domain(一个带有 foldkit 应用的银行)。
工作流
按以下顺序进行。每个步骤都有一个包含模板的参考文件。
- 将需求拆解为构建模块。参见 references/modeling.md。
- 规划 monorepo:一个领域包、一个服务端包和一个 Web 包,每个构建模块对应一个带有类型后缀的文件。参见 references/layout.md。
- 根据模板编写每个构建模块。参见 references/building-blocks.md。
- 在实现之前及实现过程中,用故事描述行为。参见 references/testing.md。
- 从服务端包在 Cloudflare 上托管模型,导出其追踪数据,并通过一个 alchemy stack 部署。参见 references/hosting.md。
- 在 Web 包中通过 foldkit 驱动模型。参见 references/frontend.md。
更多内容:教程,第 1 部分。
从需求到构建模块
将需求视为一连串事实来阅读,并为找到的每种事物命名:
- 业务记录的事实,用过去时命名(
MoneyWithdrawn):一个 事件。 - 请求更改某项内容的请求,用
<动词><名词>命名(WithdrawMoney):一个 命令。 - 拒绝请求的理由(
InsufficientFunds):一个 拒绝。 - 规则必须在单个事务中成立的事物(某个账户的余额):一个 聚合。它根据自身状态决定如何处理命令。
- 某人提出的问题(余额、账单、仪表板):一个 视图,按其所涉及的对象作为键。
- “每当 X 发生,就执行 Y”:一个 策略。
- 包含步骤、等待和撤销操作的流程(转账:扣款、入账、失败时退款):一个由策略启动的 Saga。
- 调用的模型外部事物(欺诈评分、支付):一个 外部系统。
- 写入模型外部存储的内容(分类账表):一个 投影。按键排序且可读取的日志(账户卡片)是投影追加写入的 流。
- 跨键查询并从该存储获取答案(按页查看某个账户的分类账;按金额从大到小查看所有账户的大额取款):一个 查询。绝不要手写路由。
- 谁能执行什么、查看什么:一个列出命令和读取操作的 Api,并带有中间件(会话、MFA),且每个构建模块都自行授权:在
decide中授权命令,在其 Layer 的validate中授权视图或流,在处理器中授权查询。
跨聚合的规则绝不能变成跨聚合事务。为不变量建立专属聚合,并以其保护的对象作为键(例如以用户名为键的 UsernameClaim)。或者先预留再提交,并在策略或 Saga 中为所有等待操作设置截止时间。
更多内容:什么是领域驱动设计?、领域模型。
约定
- 每个文件一个代码块,命名为
<Noun>.<kind>.ts。 类型包括.aggregate、.command、.view、.policy、.saga、.projection、.stream、.query、.external、.api和.model,以及.middleware、.context和.rejection。聚合的 ID 存放在其旁边的<Aggregate>.id.ts中。普通值(Money.ts)和辅助工具使用普通名称。 - 命令文件包含命令、它记录的事件,以及仅由它返回的拒绝。 多个命令共用的拒绝或事件应放在各自的文件中。
- 代码块的实现放在其文件中(
TransferExecution位于TransferExecution.policy.ts),除非涉及基础设施;这种情况下,实现在服务端包中。 - 文件夹对应子域(
Account/、Transfer/)。模型、Apis、中间件和请求上下文放在包根目录。 - 领域包只导入
@alchemy.run/domain和effect。 Cloudflare、Stripe、数据库和 HTTP 会话处理都属于服务端包。 - 每个模型(有界上下文)对应一个领域包。 第二个模型应使用第二个领域包;一个服务端可以承载多个领域包。
更多信息:项目布局。
确保模型正确的规则
decide是纯函数,或者是只读取命令中间件所提供内容(yield* CurrentActor)的Effect.fn。它返回事件或拒绝,不执行任何 I/O。evolve只将事件应用于状态,不做任何决策。它和reply都是全函数:覆盖所有状态且永不失败,因为decide已经拒绝了不可能发生的情况。对于事件无法作用的状态,应保持原样(s._tag === "Open" ? { ...s, balance: e.balanceAfter } : s),切勿使用会抛出异常的守卫。- 事件携带
decide得出的结果(balanceAfter),因此evolve直接复制该值,而不是重复执行规则。 - 命令可能返回的每种拒绝都应列在其
rejects中。调用方使用Effect.catchTag处理每种拒绝。 - 为投影写入的数据存储提供服务时,应通过 Api 上的
Domain.Query,而不是手写路由:其处理器会检查调用方身份,并限定所读取的行。 - 客户端永远不会读取聚合。应通过视图(单个键、实时)、流或查询来读取;查询的处理器可以使用
yield* Account获取聚合的最新状态,然后调用accounts.get(ref)。 - 策略和投影至少会运行一次。确保其副作用具备幂等性:处理器发出的命令使用确定性 ID,存储中的行以
stream:seq为键。 - 不要在策略中等待 saga。启动 saga(
start),并以其关联的聚合为键,然后对其各步骤记录的事件做出响应。 - 处理器在
toLayer中解析一次依赖项,且每次调用都不需要额外依赖。 - 构建一个 Layer,并只提供一次(
Layer.mergeAll、Layer.provide)。切勿链式调用Effect.provide。 - 切勿使用
throw。使用带标签的错误(Domain.Rejection、Schema.TaggedError)表示失败。 - 跟踪会自动完成。仅在处理器中使用
Effect.annotateCurrentSpan记录负载值:这些值会写入该处理器对应的引擎 span。使用Schema.Redacted标记机密,使其永远不会出现在跟踪或日志中。 - 通过故事测试模型的行为:事件、回复、拒绝、视图和 saga 结果。不要测试 saga 的内部步骤。
更多信息:领域模型:由聚合做出决策,并一次性提交所有内容、Cloudflare:依赖这些保障。
变更检查清单
- 命名新的事实、命令和拒绝。将每条命令分别放入自己的
<Command>.command.ts中,并包含其事件和拒绝。 - 将命令添加到其聚合的
commands中,为其添加decide分支,并为每个事件添加evolve分支。 - 更新关注这些新事件的视图、策略和投影。
- 如果客户端会发送或读取该命令,则通过 Api 暴露该命令,并添加所需的中间件,最后一个参数设为
{ description }。 - 为成功路径、每种拒绝、每种策略响应,以及每个查询的授权编写故事。
- 运行领域包的测试,其中一项测试应断言
Domain.verify(Model)结果为空。然后对整个 monorepo 进行类型检查(tsc -b),并运行服务端和 Web 测试。