ecto-thinking

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Ecto Thinking

Ecto思维模式

Mental shifts for Ecto and data layer design. These insights challenge typical ORM patterns.
针对Ecto和数据层设计的思维转变。这些见解挑战了典型的ORM模式。

Context = Setting That Changes Meaning

上下文 = 改变语义的场景

Context isn't just a namespace—it changes what words mean. "Product" means different things in Checkout (SKU, name), Billing (SKU, cost), and Fulfillment (SKU, warehouse). Each bounded context may have its OWN Product schema/table.
Think top-down: Subdomain → Context → Entity. Not "What context does Product belong to?" but "What is a Product in this business domain?"
上下文不仅仅是一个命名空间——它会改变词汇的含义。“Product”在结账域(SKU、名称)、账单域(SKU、成本)和履约域(SKU、仓库)中代表不同的事物。每个限界上下文都可能拥有自己的Product schema/表。
自上而下思考: 子域 → 上下文 → 实体。不要问“Product属于哪个上下文?”,而是问“在这个业务域中,Product是什么?”

Cross-Context References: IDs, Not Associations

跨上下文引用:使用ID,而非关联

elixir
schema "cart_items" do
  field :product_id, :integer  # Reference by ID
  # NOT: belongs_to :product, Catalog.Product
end
Query through the context, not across associations. Keeps contexts independent and testable.
elixir
schema "cart_items" do
  field :product_id, :integer  # 通过ID引用
  # 不要这样写:belongs_to :product, Catalog.Product
end
通过上下文进行查询,而非跨关联查询。保持上下文独立且可测试。

DDD Patterns as Pipelines

将DDD模式作为管道使用

elixir
def create_product(params) do
  params
  |> Products.build()       # Factory: unstructured → domain
  |> Products.validate()    # Aggregate: enforce invariants
  |> Products.insert()      # Repository: persist
end
Use events (as data structs) to compose bounded contexts with minimal coupling.
elixir
def create_product(params) do
  params
  |> Products.build()       # 工厂:非结构化数据 → 域对象
  |> Products.validate()    # 聚合:强制约束不变量
  |> Products.insert()      # 仓库:持久化
end
使用事件(作为数据结构体)来组合限界上下文,实现最小耦合。

Schema ≠ Database Table

Schema ≠ 数据库表

Use CaseApproach
Database tableStandard
schema/2
Form validation only
embedded_schema/1
API request/responseEmbedded schema or schemaless
使用场景实现方式
数据库表标准
schema/2
仅表单验证
embedded_schema/1
API请求/响应嵌入式schema或无schema

Multiple Changesets per Schema

单个Schema对应多个Changeset

elixir
def registration_changeset(user, attrs)  # Full validation + password
def profile_changeset(user, attrs)       # Name, bio only
def admin_changeset(user, attrs)         # Role, verified_at
Different operations = different changesets.
elixir
def registration_changeset(user, attrs)  # 完整验证 + 密码处理
def profile_changeset(user, attrs)       # 仅处理名称、简介
def admin_changeset(user, attrs)         # 处理角色、验证时间
不同操作对应不同的changeset。

Multi-Tenancy: Composite Foreign Keys

多租户:复合外键

elixir
add :post_id, references(:posts, with: [org_id: :org_id], match: :full)
Use
prepare_query/3
for automatic scoping. Raise if
org_id
missing.
elixir
add :post_id, references(:posts, with: [org_id: :org_id], match: :full)
使用
prepare_query/3
实现自动作用域限定。如果缺少
org_id
则抛出异常。

Preload vs Join Trade-offs

预加载与关联查询的权衡

ApproachBest For
Separate preloadsHas-many with many records (less memory)
Join preloadsBelongs-to, has-one (single query)
Join preloads can use 10x more memory for has-many.
方式适用场景
分离预加载包含大量记录的has-many关联(内存占用更少)
关联查询预加载belongs-to、has-one关联(单次查询)
对于has-many关联,关联查询预加载的内存占用可能是分离预加载的10倍。

CRUD Contexts Are Fine

CRUD上下文是可行的

"If you have a CRUD bounded context, go for it. No need to add complexity."
Use generators for simple cases. Add DDD patterns only when business logic demands it.
“如果你有一个CRUD限界上下文,直接用就好。无需增加复杂度。”
简单场景下使用生成器。仅当业务逻辑需要时才添加DDD模式。

Gotchas from Core Team

核心团队提醒的陷阱

CTE Queries Don't Inherit Schema Prefix

CTE查询不会继承Schema前缀

In multi-tenant apps, CTEs don't get the parent query's prefix.
Fix: Explicitly set prefix:
%{recursive_query | prefix: "tenant"}
在多租户应用中,CTE不会继承父查询的前缀。
修复方案: 显式设置前缀:
%{recursive_query | prefix: "tenant"}

Parameterized Queries ≠ Prepared Statements

参数化查询 ≠ 预编译语句

  • Parameterized queries:
    WHERE id = $1
    — always used by Ecto
  • Prepared statements: Query plan cached by name — can be disabled
pgbouncer: Use
prepare: :unnamed
(disables prepared statements, keeps parameterized queries).
  • 参数化查询:
    WHERE id = $1
    — Ecto始终使用该方式
  • 预编译语句: 按名称缓存查询计划 — 可禁用
pgbouncer: 使用
prepare: :unnamed
(禁用预编译语句,保留参数化查询)。

pool_count vs pool_size

pool_count与pool_size的区别

More pools with fewer connections = better for benchmarks. But with mixed fast/slow queries, a single larger pool gives better latency.
Rule:
pool_count
for uniform workloads, larger
pool_size
for real apps.
更多连接池搭配更少连接数 → 基准测试表现更好。 当存在快慢混合查询时,单个更大的连接池能提供更好的延迟。
规则: 均匀工作负载使用
pool_count
,实际应用使用更大的
pool_size

Sandbox Mode Doesn't Work With External Processes

Sandbox模式不适用于外部进程

Cachex, separate GenServers, or anything outside the test process won't share the sandbox transaction.
Fix: Make the external service use the test process, or accept it's not in the same transaction.
Cachex、独立GenServers或任何测试进程之外的组件不会共享sandbox事务。
修复方案: 让外部服务使用测试进程,或者接受它不在同一事务中。

Null Bytes Crash Postgres

空字节会导致PostgreSQL崩溃

PostgreSQL rejects null bytes even though they're valid UTF-8.
Fix: Sanitize at boundaries:
String.replace(string, "\x00", "")
尽管空字节是有效的UTF-8字符,但PostgreSQL会拒绝它们。
修复方案: 在边界处进行清理:
String.replace(string, "\\x00", "")

preload_order for Association Sorting

使用preload_order进行关联排序

elixir
has_many :comments, Comment, preload_order: [desc: :inserted_at]
Note: Doesn't work for
through
associations.
elixir
has_many :comments, Comment, preload_order: [desc: :inserted_at]
注意:不适用于
through
关联。

Runtime Migrations Use List API

运行时迁移使用列表API

elixir
Ecto.Migrator.run(Repo, [{0, Migration1}, {1, Migration2}], :up, opts)
elixir
Ecto.Migrator.run(Repo, [{0, Migration1}, {1, Migration2}], :up, opts)

Idioms

惯用写法

  • Prefer
    Repo.insert/1
    over
    Repo.insert!/1
    —handle
    {:ok, _}
    /
    {:error, _}
    explicitly
  • Use
    Repo.transact/1
    (Ecto 3.12+) for simple transactions instead of
    Ecto.Multi
  • 优先使用
    Repo.insert/1
    而非
    Repo.insert!/1
    ——显式处理
    {:ok, _}
    /
    {:error, _}
    结果
  • 简单事务使用
    Repo.transact/1
    (Ecto 3.12+)替代
    Ecto.Multi

Red Flags - STOP and Reconsider

危险信号——立即重新审视

  • belongs_to pointing to another context's schema
  • Single changeset for all operations
  • Preloading has-many with join
  • CTEs in multi-tenant apps without explicit prefix
  • Using pgbouncer without
    prepare: :unnamed
  • Testing with Cachex/GenServers assuming sandbox shares transactions
  • Accepting user input without null byte sanitization
Any of these? Re-read the Gotchas section.
  • belongs_to指向其他上下文的schema
  • 单个changeset用于所有操作
  • 使用关联查询预加载has-many关联
  • 多租户应用中使用CTE但未显式设置前缀
  • 使用pgbouncer但未设置
    prepare: :unnamed
  • 测试时假设Cachex/GenServers会共享sandbox事务
  • 未清理空字节就接受用户输入
出现以上任何一种情况?重新阅读“陷阱”部分。