1.2 按业务领域组织代码

2026-09-05 13:23 更新

按业务领域组织代码

上一节的单文件版本很适合确认接口契约,但当用户、文章和外部服务同时出现时,按 routers/services/schemas/ 分成几大目录,未必能让修改范围更清楚。修改文章标题规则时,开发者可能要在多个技术目录之间来回查找;同名的 service.py 还容易被误导入。

这里采用按业务领域组织的最小版本:文章相关的路由、接口模型和业务读取逻辑放在 src/posts/,应用入口保留在 src/main.py。这沿用了参考资料对领域包的核心建议,但没有为了“完整结构”提前创建数据库模型、配置、异常和工具文件。文件只有在承担真实职责时才值得出现。

什么时候按领域拆分

两种目录方式都可以成立。小型服务只有一个领域、几条路由时,单文件或按技术类型集中存放都容易理解;当一个单体应用有多个领域,并且每个领域都有自己的路由、数据规则和业务操作时,领域包能把相关变化聚在一起。它不是微服务拆分,也不会自动解决依赖关系,更不是每个项目都必须照搬的固定树形。

本节的结构如下:

ch01_modules/
├── check.py                  # 当前示例的可运行检查
└── src/
    ├── __init__.py
    ├── main.py               # 组装应用、注册路由
    └── posts/
        ├── __init__.py
        ├── router.py         # HTTP 路由和状态码
        ├── schemas.py        # 对外输入/输出模型
        └── service.py        # 文章读取规则和临时数据

从目录名可以直接看出,posts 是业务边界。main.py 只负责组装;router.py 把 HTTP 请求转换成服务调用,并把“没有文章”转换成 404;service.py 不依赖 FastAPI,因此以后换成数据库实现时,路由的职责仍然清楚;schemas.py 描述接口传输的数据形状。

可以用一次具体需求来判断修改位置。假设产品要求“列表只展示已发布文章,标题最长 80 个字符”:如果只是调整标题允许的长度,改 posts/schemas.py 的字段约束;如果是根据发布状态筛选已有数据,改 posts/service.py 的查询规则;只有 URL、查询参数或状态码发生变化时,才改 posts/router.py。本节的内存文章还没有 status 字段,所以不为了演示而偷偷添加它;真正接到该需求时,应先确认数据契约,再让对应层承担变化。

数据库模型和接口模型不是一回事

本节暂时没有数据库,所以不创建 models.py。将来接入 SQLAlchemy 时,models.py 会描述表、列、外键和关系;schemas.py 仍然描述客户端可以提交或看到的字段。两者可能共享字段名,但它们的变化原因不同:数据库迁移需要关注存量数据和约束,接口模型需要关注兼容性和信息暴露。

例如,文章表可能有内部的创建时间、软删除标记或审计字段,PostResponse 未必应该全部返回;客户端提交的 PostCreate 也不应该允许直接填写服务端决定的 author_id。现在先用一个响应模型表达最小只读契约,创建与更新模型会在数据契约章节再展开。

创建重构后的完整文件

下面的代码都属于一个新目录 ch01_modules/。不要把它们和上一节的 ch01_start/main.py 混在同一目录,也不要同时从两个目录导入名为 main 的模块。最稳妥的运行方式是进入 ch01_modules,以 src.main:app 指定应用。

"""第一章领域模块练习项目。"""

from fastapi import FastAPI

from .posts.router import router as posts_router

app = FastAPI(title="文章管理 API(领域模块版)")
app.include_router(posts_router)


@app.get("/health")
def health() -> dict[str, str]:
    return {"status": "ok"}

入口文件显式导入 posts_router 并注册它。这样新增领域时,入口只增加一条组装语句,文章的具体规则不会被复制到入口中。

"""文章领域。"""

from pydantic import BaseModel


class PostResponse(BaseModel):
    id: int
    title: str
    content: str
    author_id: int

from typing import Any


_POSTS: list[dict[str, Any]] = [
    {
        "id": 1,
        "title": "FastAPI 项目从哪里开始",
        "content": "先定义可检查的接口,再决定如何拆分目录。",
        "author_id": 1,
    },
    {
        "id": 2,
        "title": "让错误结果也有约定",
        "content": "找不到文章时返回明确的 404,而不是返回空对象。",
        "author_id": 2,
    },
]


def list_posts() -> list[dict[str, Any]]:
    """返回文章副本,避免调用方直接改动服务内的教学数据。"""
    return [post.copy() for post in _POSTS]


def get_post(post_id: int) -> dict[str, Any] | None:
    for post in _POSTS:
        if post["id"] == post_id:
            return post.copy()
    return None

服务函数只表达“查询文章”的结果:找到就返回数据,找不到就返回 None。它不导入 HTTPException,因为 404 是 HTTP 层如何表达领域结果的问题。将这两个层次混在一起,会让同一服务函数更难被后台任务或测试复用。

from fastapi import APIRouter, HTTPException

from . import service
from .schemas import PostResponse

router = APIRouter(prefix="/posts", tags=["posts"])


@router.get("", response_model=list[PostResponse])
def list_posts() -> list[dict[str, object]]:
    return service.list_posts()


@router.get("/{post_id}", response_model=PostResponse)
def get_post(post_id: int) -> dict[str, object]:
    post = service.get_post(post_id)
    if post is None:
        raise HTTPException(status_code=404, detail="文章不存在")
    return post

这里的路由仍然很薄,但不是没有职责:它定义 URL、响应模型和 HTTP 状态码;它把服务的 None 转换为客户端能理解的 404。response_model 让 FastAPI 按公开模型处理返回值,后续增加内部字段时,接口边界不会自动把所有字段暴露出去。

从正确的工作目录运行

安装命令与上一节相同。把上述文件保存后,在 ch01_modules 目录执行:

cd ch01_modules
python -m uvicorn src.main:app --reload

这里使用 src.main:app 而不是 main:app,因为 main.py 位于 src 包中;相对导入 from .posts.router ... 也只有在按包导入时才有明确的父包。另开终端运行以下检查。它仍然覆盖上一节的健康、列表、详情和 404 行为,因此重构的目标是保持接口行为不变。

from fastapi.testclient import TestClient

from src.main import app


def main() -> None:
    with TestClient(app) as client:
        health = client.get("/health")
        assert health.status_code == 200
        assert health.json() == {"status": "ok"}

        posts = client.get("/posts")
        assert posts.status_code == 200
        items = posts.json()
        assert items
        assert all("title" in item for item in items)

        detail = client.get("/posts/1")
        assert detail.status_code == 200
        assert detail.json()["title"] == "FastAPI 项目从哪里开始"

        missing = client.get("/posts/999")
        assert missing.status_code == 404

    print("领域模块版检查通过")


if __name__ == "__main__":
    main()

ch01_modules 目录直接执行:

python check.py

如果出现 attempted relative import with no known parent package,通常是直接执行了 python src/main.py。不要把包内入口当作独立脚本运行,使用 python -m uvicorn src.main:app 或从 check.py 导入 src.main。如果返回结果少了字段,先检查 response_modelPostResponse 是否与服务返回的数据一致。

当前只有一个文章领域,所以没有创建全局 utils.py、空的 dependencies.py 或数据库模型。等确实出现用户读取、身份判断或数据库连接时,再按职责新增文件;目录数量本身不是可维护性的指标。

下一节会在这个目录上增加只读的 users 服务,并用显式导入连接文章和作者;同时会展示一个故意不可执行的循环导入例子,说明为什么入口层应当负责组装而不是被业务模块反向调用。

来源与相邻小节

阅读相邻小节时,请在教程目录中选择对应标题。

以上内容是否对您有帮助:
在线笔记
App下载
App下载

扫描二维码

下载编程狮App

公众号
微信公众号

编程狮公众号