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 = 改变含义的场景

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?"
Context不只是命名空间——它会改变词汇的含义。在结账场景中,“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

Preload与Join的权衡

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关联,使用Join预加载的内存占用可能是单独预加载的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 vs 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用于所有操作
  • 使用Join预加载has-many关联
  • 多租户应用中使用CTE但未显式设置前缀
  • 使用pgbouncer但未设置
    prepare: :unnamed
  • 测试时假设Cachex/GenServers共享sandbox事务
  • 未清理空字节就接受用户输入
出现以上任何一种情况?重新阅读“陷阱”部分。