当我第一次看到 Phoenix 生成的代码结构时,有点困惑:为什么不是 models/controllers/,而是 accounts/catalog/?这不是按功能划分,而是按业务划分。

前情回顾

在前两篇中,我们经历了:

  • 混沌时代:代码没有边界,一切混在一起
  • MVC 时代:技术分层(Model-View-Controller),但业务逻辑散落各处

MVC 解决了「怎么分层」的问题,但留下了一个更难的问题:业务逻辑应该怎么组织?

今天,我们来看 Elixir Phoenix 的答案:Context

一个发人深省的目录结构

传统 MVC 项目的目录结构:

app/
├── models/
│   ├── user.py
│   ├── order.py
│   └── product.py
├── views/
│   ├── user_views.py
│   ├── order_views.py
│   └── product_views.py
└── controllers/
    ├── user_controller.py
    ├── order_controller.py
    └── product_controller.py

Phoenix 项目的目录结构:

lib/my_app/
├── accounts.ex         # 「账户」Context 模块
├── accounts/           # 「账户」相关的 Schema
│   └── user.ex
├── catalog.ex          # 「商品目录」Context 模块
├── catalog/
│   └── product.ex
├── orders.ex           # 「订单」Context 模块
└── orders/
    └── order.ex

看出区别了吗?

传统 MVC:按技术类型分(models、views、controllers) Phoenix:按业务领域分(accounts、catalog、orders)

这不只是文件夹命名的不同,是思维方式的转变

什么是 Context?

Phoenix 官方文档对 Context 的定义:

Context 是暴露和组织相关功能的模块。

听起来很抽象。让我用一个类比解释。

类比:公司的部门

想象一家电商公司:

部门职责对外接口
用户部门管理用户注册、登录、权限「帮我注册个用户」「这个用户是 VIP 吗?」
商品部门管理商品、库存、价格「给我看看商品列表」「这个商品还有货吗?」
订单部门管理订单、支付、物流「帮我创建个订单」「这个订单发货了吗?」

每个部门:

  • 有自己的内部运作方式(你不需要知道)
  • 对外提供明确的服务接口(你只需要调用)

这就是 Context 的本质:按业务边界划分的模块,对外暴露清晰的 API。

代码层面的 Context

看看 Phoenix 的 Accounts Context:

defmodule MyApp.Accounts do
  @moduledoc """
  账户上下文。处理用户相关的所有业务逻辑。
  """

  alias MyApp.Accounts.User
  alias MyApp.Repo

  # ===== 查询 =====

  def list_users do
    Repo.all(User)
  end

  def get_user!(id), do: Repo.get!(User, id)

  def get_user(id), do: Repo.get(User, id)

  def get_user_by_email(email), do: Repo.get_by(User, email: email)

  # ===== 变更 =====

  def create_user(attrs \\ %{}) do
    %User{}
    |> User.changeset(attrs)
    |> Repo.insert()
  end

  def update_user(%User{} = user, attrs) do
    user
    |> User.changeset(attrs)
    |> Repo.update()
  end

  def delete_user(%User{} = user) do
    Repo.delete(user)
  end

  # ===== Changeset(用于表单) =====

  def change_user(%User{} = user, attrs \\ %{}) do
    User.changeset(user, attrs)
  end
end

关键点

  1. 公开 API 是业务语言list_userscreate_userget_user_by_email,不是技术语言
  2. 实现细节被隐藏:外部不知道用了什么数据库,不知道内部如何查询
  3. 边界清晰:所有用户相关的操作都在这里,不散落到其他地方
  4. change_user 用于表单:返回 changeset 供前端表单绑定和验证

Controller 变薄了

有了 Context,Controller 变得极其简单:

defmodule MyAppWeb.UserController do
  use MyAppWeb, :controller

  alias MyApp.Accounts

  def index(conn, _params) do
    users = Accounts.list_users()
    render(conn, :index, users: users)
  end

  def new(conn, _params) do
    changeset = Accounts.change_user(%Accounts.User{})
    render(conn, :new, changeset: changeset)
  end

  def create(conn, %{"user" => user_params}) do
    case Accounts.create_user(user_params) do
      {:ok, user} ->
        conn
        |> put_flash(:info, "用户创建成功")
        |> redirect(to: ~p"/users/#{user}")

      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, :new, changeset: changeset)
    end
  end
end

Controller 现在只做四件事:

  1. 接收 HTTP 请求参数
  2. 调用 Context 的业务方法
  3. 处理成功/失败分支
  4. 返回 HTTP 响应(渲染或重定向)

不再有业务逻辑。 业务逻辑全在 Context 里。

注意几个 Phoenix 的惯例:

  • render(conn, :index, ...) 使用原子而非字符串指定模板
  • ~p"/users/#{user}" 是 Phoenix 1.7+ 的路由 sigil,编译时验证路径
  • change_user/1 返回空 changeset 供 new 动作的表单使用

Context 的核心价值

价值 1:业务边界清晰

当产品经理说「用户下单」,开发者知道:

  • Orders Context 找 create_order/1
  • 不用翻遍整个项目

当新人入职,他可以问:「这个项目有哪些业务领域?」答案就是:看 lib/my_app/ 下有哪些 Context 模块(如 accounts.exorders.ex)。

价值 2:依赖方向可控

Controller → Context → Schema/Repo
  View

依赖是单向的。Context 不知道 Controller 的存在。Schema 不知道 Context 的存在。

这意味着:

  • 换一个 Web 框架?只需要重写 Controller 和 View
  • 换一个数据库?只需要改 Context 内部实现
  • 业务逻辑不受影响

价值 3:可测试性大增

测试 Context,不需要启动 HTTP 服务器:

defmodule MyApp.AccountsTest do
  use MyApp.DataCase

  alias MyApp.Accounts

  describe "users" do
    @valid_attrs %{name: "Alice", email: "alice@example.com"}
    @invalid_attrs %{name: nil, email: nil}

    test "list_users/0 returns all users" do
      {:ok, user} = Accounts.create_user(@valid_attrs)
      assert Accounts.list_users() == [user]
    end

    test "create_user/1 with valid data creates a user" do
      assert {:ok, %Accounts.User{} = user} = Accounts.create_user(@valid_attrs)
      assert user.name == "Alice"
      assert user.email == "alice@example.com"
    end

    test "create_user/1 with invalid data returns error changeset" do
      assert {:error, %Ecto.Changeset{}} = Accounts.create_user(@invalid_attrs)
    end
  end
end

注意 use MyApp.DataCase——这是 Phoenix 生成的测试辅助模块,会自动处理数据库沙箱隔离。

测试的是业务逻辑,不是 HTTP 路由。

跨 Context 的调用

有时候,一个业务操作涉及多个 Context。比如「创建订单」需要:

  • 检查用户是否存在(Accounts)
  • 检查商品库存(Catalog)
  • 创建订单(Orders)

Phoenix 官方文档明确说明:Context 之间可以互相调用,这是被鼓励的做法。关键是通过公开 API 调用,而不是直接访问对方的内部实现。

方案 1:在 Orders Context 中调用其他 Context

defmodule MyApp.Orders do
  alias MyApp.{Accounts, Catalog}
  alias MyApp.Orders.Order

  def create_order(attrs) do
    # 调用其他 Context 的公开 API
    with {:ok, user} <- fetch_user(attrs.user_id),
         {:ok, product} <- fetch_product(attrs.product_id),
         :ok <- check_stock(product, attrs.quantity) do
      %Order{}
      |> Order.changeset(%{
        user_id: user.id,
        product_id: product.id,
        quantity: attrs.quantity,
        total: calculate_total(product, attrs.quantity)
      })
      |> Repo.insert()
    end
  end

  defp fetch_user(user_id) do
    case Accounts.get_user(user_id) do
      nil -> {:error, :user_not_found}
      user -> {:ok, user}
    end
  end

  defp fetch_product(product_id) do
    case Catalog.get_product(product_id) do
      nil -> {:error, :product_not_found}
      product -> {:ok, product}
    end
  end

  defp check_stock(product, quantity) do
    if Catalog.in_stock?(product, quantity),
      do: :ok,
      else: {:error, :out_of_stock}
  end

  defp calculate_total(product, quantity) do
    Decimal.mult(product.price, quantity)
  end
end

这是官方推荐的方式。Orders Context 通过公开 API 调用 Accounts 和 Catalog。

方案 2:Controller 简单协调

如果跨 Context 的逻辑很简单,Controller 也可以直接协调:

def create(conn, %{"order" => order_params}) do
  user = Accounts.get_user!(order_params["user_id"])
  product = Catalog.get_product!(order_params["product_id"])

  case Orders.create_order(user, product, order_params) do
    {:ok, order} ->
      conn
      |> put_flash(:info, "订单创建成功")
      |> redirect(to: ~p"/orders/#{order}")

    {:error, %Ecto.Changeset{} = changeset} ->
      render(conn, :new, changeset: changeset)
  end
end

关键原则:无论哪种方式,Context 之间只通过公开函数通信,不直接访问对方的 Schema 或 Repo。

从 Context 到 DDD

用了一段时间 Phoenix Context 后,我突然意识到:这就是 DDD(领域驱动设计)的雏形!

Phoenix 概念DDD 概念
ContextBounded Context(限界上下文)
SchemaEntity(实体)
Context 模块Application Service(应用服务)
跨 Context 协调Domain Service(领域服务)

Phoenix 没有刻意宣传 DDD,但它的设计自然地引导你走向 DDD 的思维方式

Context 设计的原则

原则 1:按业务能力划分,不按数据划分

错误

lib/my_app/
├── users/      # User 表相关
├── orders/     # Order 表相关
└── products/   # Product 表相关

这只是把 models/ 换了个名字。

正确

lib/my_app/
├── accounts/       # 用户注册、认证、授权
├── catalog/        # 商品浏览、搜索、分类
├── orders/         # 下单、支付、履约
└── inventory/      # 库存管理、预警

按「业务能力」划分。一个 Context 可能涉及多个表,一个表也可能被多个 Context 使用。

原则 2:Context 之间松耦合

Context 之间通过公开 API 通信,不直接访问对方的内部:

# ❌ 错误:直接访问另一个 Context 的 Repo
def create_order(user_id, ...) do
  user = MyApp.Repo.get!(MyApp.Accounts.User, user_id)
  # ...
end

# ✅ 正确:调用 Context 的公开 API
def create_order(user_id, ...) do
  user = MyApp.Accounts.get_user!(user_id)
  # ...
end

原则 3:Context 内部高内聚

一个 Context 内部的代码应该紧密相关。如果发现一个 Context 变得很大,考虑拆分。

信号

  • Context 文件超过 500 行
  • Context 有多个不相关的功能
  • 团队中不同人负责同一个 Context 的不同部分

真实项目的演进

让我分享一个真实项目从 MVC 到 Context 的演进:

阶段 1:传统 MVC

web/
├── controllers/
├── models/
└── views/

阶段 2:引入 Service 层

lib/my_app/
├── services/
│   ├── user_service.ex
│   └── order_service.ex
web/
├── controllers/
└── views/

问题:Service 之间互相调用,依赖混乱。

阶段 3:引入 Context

lib/my_app/
├── accounts.ex          # Context 入口
├── accounts/
│   ├── user.ex          # Schema
│   └── credential.ex    # Schema
├── orders.ex
└── orders/
    └── order.ex
lib/my_app_web/
├── controllers/
└── components/

改变

  • 代码按业务领域组织
  • 每个 Context 有明确的边界
  • 依赖方向清晰

常见问题

Q:一个 Schema 可以属于多个 Context 吗?

A:可以,但要小心。

有时候一个表(比如 users)被多个 Context 使用:

  • Accounts Context:用户注册、登录
  • Billing Context:用户的付费信息
  • Admin Context:用户管理

方案:

  1. 共享 Schema:都用 MyApp.Accounts.User
  2. 各自定义 SchemaAccounts.UserBilling.CustomerAdmin.ManagedUser

DDD 更推荐方案 2,每个 Context 有自己的视角。但 Phoenix 实践中,方案 1 更常见。

Q:Context 之间可以调用吗?

A:可以,这是被鼓励的做法。

Phoenix 官方文档明确支持 Context 之间的调用,关键是:

  • 通过公开 API:不直接访问对方的 Schema 或 Repo
  • 避免循环依赖:如果 A 调用 B,B 就不应该调用 A
  • 复杂场景考虑异步:用事件/消息解耦

Q:Context 怎么测试?

A:单元测试 Context,集成测试跨 Context 流程。

# 单元测试:测试单个 Context
defmodule MyApp.AccountsTest do
  use MyApp.DataCase
  alias MyApp.Accounts
  # ...
end

# 集成测试:测试跨 Context 流程
defmodule MyApp.OrdersTest do
  use MyApp.DataCase

  test "创建订单需要有效用户和商品" do
    {:ok, user} = Accounts.create_user(%{name: "Alice", email: "a@b.com"})
    {:ok, product} = Catalog.create_product(%{name: "Book", price: 29.99})

    assert {:ok, order} = Orders.create_order(%{
      user_id: user.id,
      product_id: product.id,
      quantity: 1
    })
  end
end

总结

Context 的核心价值

  • 业务边界而非技术层次组织代码
  • 每个 Context 是一个自治的模块
  • 对外暴露清晰的 API,隐藏内部实现
  • 为走向 DDD 打下基础

从 MVC 到 Context 的转变

维度MVCContext
组织方式按技术类型(models, views)按业务领域(accounts, orders)
关注点技术实现业务能力
边界模糊(Service 互相调用)清晰(通过公开 API)
可发现性要找功能,需要翻多个文件夹功能就在对应的 Context 里

核心教训

技术分层是程序员的思维,业务分层是领域专家的思维。

Context 教会我们用业务的视角看代码。这是走向 DDD 的第一步。

下一篇,我们来看完整的 DDD——当业务足够复杂,Context 还不够,我们需要实体、值对象、聚合根、领域事件…


上一篇:MVC 启蒙:框架带来的秩序

下一篇:DDD 觉醒:让代码说业务的语言

本系列:

  1. 混沌时代:当代码没有架构
  2. MVC 启蒙:框架带来的秩序
  3. Context 之道:从技术分层到业务分层(本篇)
  4. DDD 觉醒:让代码说业务的语言
  5. 边界的艺术:六边形与洋葱
  6. 单体的边界:何时该拆?
  7. 微服务与云原生:分布式的代价
  8. 没有银弹:架构决策的本质