ecto-thinking
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseEcto 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
endQuery 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
endUse 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 Case | Approach |
|---|---|
| Database table | Standard |
| Form validation only | |
| API request/response | Embedded schema or schemaless |
| 用例 | 实现方式 |
|---|---|
| 数据库表 | 标准 |
| 仅表单验证 | |
| 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_atDifferent 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 for automatic scoping. Raise if missing.
prepare_query/3org_idelixir
add :post_id, references(:posts, with: [org_id: :org_id], match: :full)使用实现自动范围限定。如果缺少则抛出异常。
prepare_query/3org_idPreload vs Join Trade-offs
Preload与Join的权衡
| Approach | Best For |
|---|---|
| Separate preloads | Has-many with many records (less memory) |
| Join preloads | Belongs-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: — always used by Ecto
WHERE id = $1 - Prepared statements: Query plan cached by name — can be disabled
pgbouncer: Use (disables prepared statements, keeps parameterized queries).
prepare: :unnamed- 参数化查询: — Ecto始终使用这种方式
WHERE id = $1 - 预准备语句: 按名称缓存查询计划 — 可以禁用
pgbouncer: 使用(禁用预准备语句,保留参数化查询)。
prepare: :unnamedpool_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: for uniform workloads, larger for real apps.
pool_countpool_size更多连接池+更少连接数:在基准测试中表现更好。但在混合快慢查询的场景中,单个更大的连接池能提供更好的延迟。
规则: 均匀负载用,实际应用用更大的。
pool_countpool_sizeSandbox 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 associations.
throughelixir
has_many :comments, Comment, preload_order: [desc: :inserted_at]注意:对关联无效。
throughRuntime 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 over
Repo.insert/1—handleRepo.insert!/1/{:ok, _}explicitly{:error, _} - Use (Ecto 3.12+) for simple transactions instead of
Repo.transact/1Ecto.Multi
- 优先使用而非
Repo.insert/1——显式处理Repo.insert!/1/{:ok, _}结果{:error, _} - 简单事务使用(Ecto 3.12+)替代
Repo.transact/1Ecto.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事务
- 未清理空字节就接受用户输入
出现以上任何一种情况?重新阅读“陷阱”部分。