alchemy-domain

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Building with @alchemy.run/domain

使用 @alchemy.run/domain 构建

@alchemy.run/domain
models a backend as typed messages and pure state transitions. Commands are decided by aggregates, which record events. Views fold events into read models. Policies react to events. Sagas run long processes. Projections write outside the model, and queries serve what they wrote. The same model runs in memory for tests and on Cloudflare Durable Objects in production.
Docs: https://alchemy.run/domain, with every page listed in https://alchemy.run/llms.txt. Every section here and in the reference files ends with a More link to the docs page that covers it in depth. A complete reference project:
examples/cloudflare-domain
in the alchemy repo (a bank with a foldkit app).
@alchemy.run/domain
将后端建模为类型化消息和纯粹的状态转换。Commands 由 aggregates 决策,而 aggregates 记录 events。Views 将 events 归并为 read models。Policies 响应 events。Sagas 执行长时间运行的流程。Projections 将数据写入模型之外,queries 则提供它们写入的数据。同一模型在测试中以内存方式运行,在生产环境中运行于 Cloudflare Durable Objects。

Workflow

工作流程

Work in this order. Each step has a reference file with templates.
  1. Break the requirements down into building blocks. See references/modeling.md.
  2. Lay out the monorepo: a domain package, a server package, a web package, with one kind-suffixed file per block. See references/layout.md.
  3. Write each block from its template. See references/building-blocks.md.
  4. Specify behavior with stories before and while you implement it. See references/testing.md.
  5. Host the model on Cloudflare from the server package, export its traces, and deploy with one alchemy stack. See references/hosting.md.
  6. Drive it from foldkit in the web package. See references/frontend.md.
按以下顺序执行。每个步骤都有一个包含模板的参考文件。
  1. 将需求拆解为 building blocks。参见 references/modeling.md。
  2. 搭建 monorepo 布局:一个 domain package、一个 server package 和一个 web package,每个 block 使用一个带 kind 后缀的文件。参见 references/layout.md。
  3. 根据模板编写每个 block。参见 references/building-blocks.md。
  4. 在实现前后通过 stories 明确行为。参见 references/testing.md。
  5. 在 server package 中将模型托管到 Cloudflare,导出其 traces,并使用一个 alchemy stack 完成部署。参见 references/hosting.md。
  6. 在 web package 中通过 foldkit 驱动模型。参见 references/frontend.md。

From requirements to building blocks

从需求到 building blocks

Read the requirements as a story of facts, and name each kind of thing you find:
  • A fact the business records, named in the past tense (
    MoneyWithdrawn
    ): an Event.
  • A request to change something, named
    <Verb><Noun>
    (
    WithdrawMoney
    ): a Command.
  • A way a request is refused (
    InsufficientFunds
    ): a Rejection.
  • A thing whose rules must hold in one transaction (one account's balance): an Aggregate. It decides commands against its state.
  • A question someone asks (balance, statement, dashboard): a View, keyed by what it's about.
  • "Whenever X happens, do Y": a Policy.
  • A process with steps, waits and undo (move money: debit, credit, refund on failure): a Saga, started by a policy.
  • Something outside the model you call (fraud scoring, payments): an External System.
  • Writes to a store outside the model (a ledger table): a Projection. An ordered, readable log per key (account cards): a Stream that a projection appends to.
  • A question across keys, answered from that store (an account's ledger, paged; large withdrawals across all accounts, largest first): a Query. Never a hand-written route.
  • Who can do and see what: an Api listing commands and reads, with middleware (sessions, MFA) and each block authorizing itself: a command in
    decide
    , a view or stream in its Layer's
    validate
    , a query in its handler.
A rule that spans aggregates never becomes a cross-aggregate transaction. Give the invariant its own aggregate, keyed by what it protects (
UsernameClaim
keyed by username). Or reserve first and commit after, in a policy or saga, with a deadline for anything that waits.
将需求视为由事实组成的叙事,并为你发现的每类事物命名:
  • 业务记录的一项事实,使用过去时命名(
    MoneyWithdrawn
    ):一个 Event。
  • 更改某事的一项请求,命名为
    <Verb><Noun>
    (
    WithdrawMoney
    ):一个 Command。
  • 拒绝请求的一种方式(
    InsufficientFunds
    ):一个 Rejection。
  • 其规则必须在单个 transaction 中成立的事物(例如一个账户的余额):一个 Aggregate。它根据自身 state 决策 commands。
  • 某人提出的一个问题(余额、对账单、仪表盘):一个 View,按其主题设置 key。
  • “每当 X 发生,就执行 Y”:一个 Policy。
  • 包含步骤、等待和撤销操作的过程(转移资金:扣款、入账,失败时退款):一个 Saga,由 policy 启动。
  • 模型之外所调用的系统(欺诈评分、支付):一个 External System。
  • 向模型之外的 store 写入数据(账本表):一个 Projection。按 key 存储的有序可读日志(账户卡片):一个 Stream,由 projection 向其追加内容。
  • 跨 key 提出的问题,并从该 store 获得答案(分页查询某个账户的账本;按金额从大到小查询所有账户的大额取款):一个 Query。绝不编写手写 route。
  • 谁可以执行哪些操作、查看哪些数据:一个列出 commands 和读取操作的 Api,并带有 middleware(sessions、MFA)。每个 block 自行执行授权:command 在
    decide
    中授权,view 或 stream 在其 Layer 的
    validate
    中授权,query 在其 handler 中授权。
跨越 aggregates 的规则绝不变成 cross-aggregate transaction。为不变量创建独立的 aggregate,并以它所保护的对象作为 key(例如,以 username 为 key 的
UsernameClaim
)。或者在 policy 或 saga 中先预留、后提交,并为所有等待中的操作设置 deadline。

Conventions

约定

  • One block per file, named
    <Noun>.<kind>.ts
    .
    The kinds are
    .aggregate
    ,
    .command
    ,
    .view
    ,
    .policy
    ,
    .saga
    ,
    .projection
    ,
    .stream
    ,
    .query
    ,
    .external
    ,
    .api
    and
    .model
    . Plus
    .middleware
    ,
    .context
    and
    .rejection
    . An aggregate's id lives in
    <Aggregate>.id.ts
    next to it. Plain values (
    Money.ts
    ) and helpers keep plain names.
  • A command file holds the command, the events it records, and the rejections only it returns. A rejection or event used by several commands gets its own file.
  • A block's implementation sits in its file (
    TransferExecutionLive
    in
    TransferExecution.policy.ts
    ), unless it touches infrastructure. Then it lives in the server package.
  • Folders are subdomains (
    Account/
    ,
    Transfer/
    ). The model, Apis, middleware and request context sit at the package root.
  • The domain package imports only
    @alchemy.run/domain
    and
    effect
    .
    Cloudflare, Stripe, databases and HTTP session handling belong to the server package.
  • One domain package per model (bounded context). A second model is a second domain package; one server can host several.
  • 每个 block 单独一个文件,命名为
    <Noun>.<kind>.ts
    。
    kind 包括
    .aggregate
    、
    .command
    、
    .view
    、
    .policy
    、
    .saga
    、
    .projection
    、
    .stream
    、
    .query
    、
    .external
    、
    .api
    和
    .model
    ,此外还有
    .middleware
    、
    .context
    和
    .rejection
    。aggregate 的 id 位于其旁边的
    <Aggregate>.id.ts
    中。普通值(
    Money.ts
    )和 helpers 保持不带 kind 后缀的普通名称。
  • command 文件包含该 command、它记录的 events,以及仅由它返回的 rejections。 被多个 commands 使用的 rejection 或 event 应单独放入一个文件。
  • block 的实现位于其文件中(例如
    TransferExecutionLive
    位于
    TransferExecution.policy.ts
    ),除非它涉及基础设施;此时应放在 server package 中。
  • 文件夹表示 subdomains(
    Account/
    、
    Transfer/
    )。model、Apis、middleware 和 request context 位于 package 根目录。
  • domain package 仅导入
    @alchemy.run/domain
    和
    effect
    。
    Cloudflare、Stripe、数据库和 HTTP session handling 属于 server package。
  • 每个 model 使用一个 domain package(bounded context)。第二个 model 对应第二个 domain package;一个 server 可以托管多个 model。
更多:项目布局。

Rules that keep a model correct

保持模型正确的规则

  • decide
    is pure, or an
    Effect.fn
    that reads only what its command's middleware provides (
    yield* CurrentActor
    ). It returns events or a rejection; it never performs I/O.
  • evolve
    only applies an event to state. It never decides anything. It and
    reply
    are total: they cover every state and never fail, because
    decide
    already refused what can't happen. Keep a state the event can't follow (
    s._tag === "Open" ? { ...s, balance: e.balanceAfter } : s
    ), never a guard that throws.
  • Events carry the outcome
    decide
    worked out (
    balanceAfter
    ), so
    evolve
    copies it instead of repeating a rule.
  • Every rejection a command can return is listed in its
    rejects
    . Callers handle each one with
    Effect.catchTag
    .
  • Serve a store a projection writes through a
    Domain.Query
    on an Api, never a hand-written route: its handler checks the caller and scopes the rows it reads.
  • Clients never read an aggregate. Read through a view (one key, live), a stream, or a query; a query's handler may read an aggregate's latest state with
    yield* Account
    then
    accounts.get(ref)
    .
  • Policies and projections run at least once. Make their effects idempotent: commands sent from a handler get deterministic ids, and stores key rows by
    stream:seq
    .
  • Don't wait for a saga in a policy.
    start
    it, keyed by the aggregate it's about, and react to the events its steps record.
  • A handler resolves its dependencies once in
    toLayer
    and requires nothing per call.
  • Build one Layer and provide it once (
    Layer.mergeAll
    ,
    Layer.provide
    ). Never chain
    Effect.provide
    .
  • Never
    throw
    . Fail with tagged errors (
    Domain.Rejection
    ,
    Schema.TaggedError
    ).
  • Tracing is automatic. Record payload values only with
    Effect.annotateCurrentSpan
    in a handler: it lands on the engine's span for that handler. Make secrets
    Schema.Redacted
    , so they never reach a trace or a log.
  • Test what the model does, through stories: events, replies, rejections, views and saga outcomes. Don't test a saga's internal steps.
  • decide
    必须是纯粹的,或者是一个只读取其 command 的 middleware 所提供内容的
    Effect.fn
    (
    yield* CurrentActor
    )。它返回 events 或 rejection,绝不执行 I/O。
  • evolve
    只将 event 应用到 state,绝不进行任何决策。它与
    reply
    都是 total 的:完整覆盖每一种 state 且绝不失败,因为
    decide
    已经拒绝不可能发生的情况。对于 event 无法跟随的 state,保持原状(
    s._tag === "Open" ? { ...s, balance: e.balanceAfter } : s
    ),绝不要使用会抛出异常的 guard。
  • Events 携带
    decide
    已计算出的结果(
    balanceAfter
    ),因此
    evolve
    应直接复制该结果,而不是重复规则。
  • command 可能返回的每一种 rejection 都必须列在其
    rejects
    中。调用方使用
    Effect.catchTag
    处理每一种 rejection。
  • 为 projection 写入的 store 提供服务时,应通过 Api 上的
    Domain.Query
    完成,绝不编写手写 route:其 handler 检查 caller,并限定所读取 rows 的范围。
  • Clients 绝不读取 aggregate。应通过 view(单个 key、实时数据)、stream 或 query 读取;query 的 handler 可以通过
    yield* Account
    读取 aggregate 的最新 state,然后调用
    accounts.get(ref)
    。
  • Policies 和 projections 至少执行一次。确保其 effects 具备幂等性:handler 中发送的 commands 使用确定性 ids,stores 使用
    stream:seq
    作为 rows 的 key。
  • 不要在 policy 中等待 saga 完成。应启动 saga,以其关联的 aggregate 为 key,并通过其各步骤记录的 events 作出响应。
  • Handler 应在
    toLayer
    中一次性解析依赖,每次调用时不再需要解析任何依赖。
  • 构建一个 Layer 并一次性提供它(
    Layer.mergeAll
    、
    Layer.provide
    )。绝不串联
    Effect.provide
    。
  • 绝不
    throw
    。应使用带 tag 的 errors 失败(
    Domain.Rejection
    、
    Schema.TaggedError
    )。
  • Tracing 自动完成。仅在 handler 中使用
    Effect.annotateCurrentSpan
    记录 payload 值:它会落到该 handler 对应的 engine span 上。将 secrets 设为
    Schema.Redacted
    ,确保它们绝不会进入 trace 或 log。
  • 通过 stories 测试模型的行为,包括 events、replies、rejections、views 和 saga outcomes。不要测试 saga 的内部步骤。

Checklist for a change

变更检查清单

  1. Name the new facts, commands and rejections. Put each command in its own
    <Command>.command.ts
    with its events and rejections.
  2. Add the command to its aggregate's
    commands
    , with a
    decide
    branch for it and an
    evolve
    branch for each event.
  3. Update the views, policies and projections that care about the new events.
  4. Expose it through an Api, if a client sends or reads it, with the middleware it needs, and give it a
    { description }
    as its last argument.
  5. Write stories for the happy path, every rejection, every policy reaction, and every query's authorization.
  6. Run the domain package's tests, with one that expects
    Domain.verify(Model)
    to be empty. Then type-check the monorepo (
    tsc -b
    ) and run the server and web tests.
  1. 为新的 facts、commands 和 rejections 命名。将每个 command 放入各自的
    <Command>.command.ts
    ,并包含其 events 和 rejections。
  2. 将 command 添加到对应 aggregate 的
    commands
    中,为它添加
    decide
    分支,并为每个 event 添加
    evolve
    分支。
  3. 更新关注新 events 的 views、policies 和 projections。
  4. 如果 client 会发送或读取该功能,请通过 Api 暴露它,并提供所需的 middleware,同时将
    { description }
    作为最后一个参数。
  5. 为 happy path、每一种 rejection、每一种 policy reaction 和每个 query 的授权编写 stories。
  6. 运行 domain package 的测试,其中一个测试应期望
    Domain.verify(Model)
    的结果为空。然后对 monorepo 进行类型检查(
    tsc -b
    ),并运行 server 和 web 测试。