CatchAdmin PHP 后台管理框架 Logo CatchAdmin

把限界上下文引入现有 Laravel 应用的实战手册

打开一个运行了五年的 Laravel 应用,看看 app/Models/:有一个 User,还有 UserProfileUserAddressUserNotificationPreferenceUserSubscriptionUserBillingContactUserLoyaltyAccountUserReferral。另外 47 个模型以各种方式调用 User 的方法,因为 User 已经变成了应用记录"这个人"所有信息的总线。

UserController 有 800 行,处理资料更新、地址变更、订阅续费、积分兑换、推荐计划等所有事项。测试都挤在 tests/Feature/UserTest.php 里,2,400 行还在涨。每次有人给 User 加一个新的关联,代码评审都要更久——不是变更复杂,而是动一下 User 就等于动了整个应用里其它每一块都会依赖的东西。

这时"微服务"这个词常常会出现在讨论里。它几乎总是错的词。真正需要的是边界——在单体内部立起的墙,把计费关注点、身份关注点、积分关注点彼此隔开。这些墙就是领域驱动设计里的限界上下文(Bounded Context),把它们引入现有 Laravel 应用其实相当可操作。不需要重写框架、不需要抽服务、不需要停机,也用不到 Kafka。只需要一点纪律、一些目录调整,以及"不要再让 User 背更多职责"的一致态度。

本文是一份可执行手册。它从一个真实的 Laravel 应用的混乱开始,结束于干净分离的上下文,并展示中间的每一步——PHP、路由、测试,以及最难的那一部分:让这件事真正落地的团队对话。

TL;DR 速览

整个迁移可用九句话概括:

  • 限界上下文是代码中的一块区域,拥有自己的模型、服务、语言和规则——"Billing 里的 User"和"Identity 里的 User"是两回事。
  • 目标不是微服务,而是无法相互随意引用、只能通过定义良好的接口交互的目录。
  • 从领域讨论而非既有代码结构中识别 2–4 个上下文。"业务怎么谈论这件事"比"代码怎么组织这件事"更好。
  • 技术改动主要是把文件移入 app/Modules/{Context}/,并用事件派发或查询总线替换跨上下文 import。
  • Eloquent 在限界上下文里工作良好——只要给每个上下文绑定自己的命名空间,并禁止跨上下文关联。
  • 最难的是 users 这类共享表。解决思路:每个上下文拥有自己的只读投影模型,由一个指定"所有者"上下文负责写入。
  • 过程是渐进式的。一次一个上下文,持续发布,不做重写。
  • 静态分析(Deptrac、带命名空间的 PHPStan)把人会忘记的边界用工具固化下来。
  • 终态并不是"完美隔离",而是边界足够强,一个上下文不需要读别的上下文就能被理解

本文要点

  • 如何从业务实际的运作方式识别上下文,而不是从代码现有结构倒推——因为现有结构反映的是十年妥协的结果,不是领域现实。
  • 在 Laravel 中真正能工作的目录结构,包括路由、服务提供者、命名空间配置的多上下文组织方式,不与框架搏斗。
  • 共享 users 表的处理模式:五个上下文都需要读、但只有一个该写,而且不能为此每请求多一次网络调用。
  • 上下文间通信:通过领域事件(fire-and-forget 反应)与查询总线(同步数据读取),两者的 Laravel 具体实现。
  • 用 Deptrac 固化架构规则:让初级开发者无法误从 App\Modules\Identity\Controllers\UserController 引用 App\Modules\Billing\Services\InvoiceService
  • 测试策略:不需要启动整个框架的每上下文单元测试,以及确保边界不被破坏的契约测试。
  • 按周推进的迁移节奏:让团队以渐进方式落地这套结构,同时不影响正常交付。

代码现状

在动手之前,看看多数 Laravel 应用在五年之后的真实样貌:

app/
├── Console/
├── Exceptions/
├── Http/
│   ├── Controllers/
│   │   ├── Api/
│   │   │   ├── UserController.php          (817 行)
│   │   │   ├── OrderController.php         (1,245 行)
│   │   │   ├── SubscriptionController.php  (623 行)
│   │   │   ├── NotificationController.php
│   │   │   ├── InvoiceController.php
│   │   │   ├── LoyaltyController.php
│   │   │   └── ... 另外 40 个
│   │   └── Auth/
│   ├── Middleware/
│   └── Requests/
├── Models/
│   ├── User.php                (412 行,23 个关系)
│   ├── Order.php
│   ├── Product.php
│   ├── Subscription.php
│   ├── Invoice.php
│   ├── LoyaltyPoint.php
│   └── ... 另外 60 个
├── Services/
│   ├── UserService.php
│   ├── BillingService.php
│   ├── NotificationService.php
│   └── ... 一大堆
└── Providers/

每个文件按技术类型组织——控制器和控制器在一起,模型和模型在一起——而不是按业务关注点组织。想找"和计费相关的一切"只能靠全文搜索。新人上手要六周,因为这套结构并没有传达应用到底在做什么。

上下文其实都在,只是被隐藏、纠缠、非正式化地埋在里面。团队里应该有人能毫不犹豫地说"业务大致分身份、计费、订单、通知、积分"。从这里开始。

从业务里发现上下文

这类迁移最大的错误是从代码开始。既有结构记录的是历史妥协,而不是领域地图。盯着它看再久,边界也不会自己浮现,因为代码里本来就没有。

起手问题应当是:业务围绕产品,实际怎么组织自己?一种结构化的回答方式是做一次轻量的事件风暴工作坊(event storming)——把产品经理、工程师、运营人员放在同一间会议室(或同一场视频会议)里两小时,列出所有业务事件,再把它们分组。

典型 SaaS 应用的产出大致如下:

  • 注册事件,产生用户账号。归属:身份团队。
  • 订阅开始事件,产生订阅记录与第一张账单。归属:计费团队。
  • 下单事件,产生订单记录与履约工单。归属:电商团队。
  • 工单提交事件,可能触发客户联系或升级。归属:客户成功。
  • 推荐兑现事件,给推荐人积分并在被推荐人下一张账单上打折。归属:增长团队。

每一组事件,以及围绕它们的名词与策略,都是一个候选限界上下文。对于中等规模应用,这份清单一般在 3–7 个之间。有人列出 15 个,是过度切分的信号;只有一个巨大的"核心"上下文外加一个小小的"通知"上下文,则是拆得还不够。

示例应用的最小候选集合:

  • Identity——用户账号、认证、资料、角色
  • Billing——订阅、账单、支付方式、扣款
  • Commerce——商品目录、订单、履约
  • Loyalty——积分、奖励、推荐
  • Notifications——邮件、短信、推送路由

这是代码未来要生长进去的地图。但此刻还不改任何代码——下一步是确定每个上下文各自拥有什么。

为每个上下文定义边界

每个上下文都要显式回答三个问题:

  • 它拥有哪些数据? 含义:它对哪些表或字段具备排他写入权?
  • 它消费但不拥有哪些数据? 含义:它从别的上下文读哪些内容?
  • 它会对外发布哪些事件? 含义:当内部发生变化时,它向外界宣布了什么事实?

示例应用中 Identity 的回答:

  • 拥有: usersuser_profilesuser_sessionspassword_reset_tokens。写入仅限 Identity。
  • 消费: 正常运行中不从其它上下文读任何数据。
  • 发布: user_registereduser_email_changeduser_deactivated

Billing 的回答:

  • 拥有: subscriptionsinvoicesinvoice_itemspayment_methodsbilling_contacts
  • 消费: 用户身份基本信息(姓名、邮箱、主用语言)——只读快照。
  • 发布: subscription_startedsubscription_cancelledinvoice_paidinvoice_overdue

Loyalty 的回答:

  • 拥有: loyalty_accountspoint_transactionsreward_redemptionsreferral_codes
  • 消费: 用户身份基本信息、来自 Commerce 的订单金额。
  • 发布: points_awardedreward_redeemedreferral_attributed

这个练习会逼出一些决策。users 表只能属于唯一一个上下文——如果 Billing 和 Loyalty 都想写入,边界就是假的。一般 Identity 胜出(它是最基础的概念),其它上下文通过快照或查询总线获得只读访问。

某些列也可能要搬家。users.loyalty_tier 大概率应当搬到 Loyalty 拥有的 loyalty_accounts 表上,而不是留在 users 上。把这些识别放在设计阶段(动手搬代码之前)完成,可以避免把老错误复刻到新结构里。

目录结构

Laravel 并未规定模块结构,框架的灵活度使任何组织方式几乎都可行,但有些模式确实更顺手。下面这套是 Laravel 社区里围绕限界上下文逐渐形成的常见布局:

app/
├── Console/               # 框架层基础设施保持原位
├── Exceptions/
├── Http/
│   ├── Middleware/        # 通用中间件保持原位
│   └── Kernel.php
├── Modules/               # 每个限界上下文都是一个模块
│   ├── Identity/
│   │   ├── Http/
│   │   │   ├── Controllers/
│   │   │   │   └── UserController.php
│   │   │   ├── Requests/
│   │   │   └── Resources/
│   │   ├── Models/
│   │   │   ├── User.php
│   │   │   └── UserProfile.php
│   │   ├── Services/
│   │   │   └── RegistrationService.php
│   │   ├── Events/
│   │   │   ├── UserRegistered.php
│   │   │   └── UserEmailChanged.php
│   │   ├── Listeners/        # 只订阅"自己"的事件
│   │   ├── Policies/
│   │   ├── Providers/
│   │   │   └── IdentityServiceProvider.php
│   │   ├── Routes/
│   │   │   ├── api.php
│   │   │   └── web.php
│   │   └── Database/
│   │       ├── Migrations/
│   │       └── Factories/
│   ├── Billing/
│   │   ├── ... 同样结构
│   ├── Commerce/
│   │   ├── ... 同样结构
│   ├── Loyalty/
│   │   ├── ... 同样结构
│   └── Notifications/
│       ├── ... 同样结构
├── Providers/             # 框架层服务提供者
│   └── AppServiceProvider.php
└── Shared/                # 真正跨上下文的共享代码
    ├── Contracts/
    ├── ValueObjects/
    └── Exceptions/

每个模块都镜像了熟悉的 Laravel 结构,只是被限制在其上下文之内。Shared/ 存放真正属于多个上下文的代码——MoneyEmailAddress 这类值对象、通用契约、基础异常类。Shared/ 的铁律是:任何需要牵涉到某个具体业务领域的东西都不属于这里

命名空间在 composer.json 中配置:

json
{
    "autoload": {
        "psr-4": {
            "App\\": "app/",
            "App\\Modules\\Identity\\": "app/Modules/Identity/",
            "App\\Modules\\Billing\\": "app/Modules/Billing/",
            "App\\Modules\\Commerce\\": "app/Modules/Commerce/",
            "App\\Modules\\Loyalty\\": "app/Modules/Loyalty/",
            "App\\Modules\\Notifications\\": "app/Modules/Notifications/",
            "App\\Shared\\": "app/Shared/"
        }
    }
}

执行 composer dump-autoload 之后,命名空间生效。

每上下文的服务提供者

每个模块都有自己的服务提供者,负责注册该模块的路由、视图、迁移与绑定。这一层让模块在 Laravel 应用内部像"迷你应用"一样运作。

php
<?php

declare(strict_types=1);

namespace App\Modules\Identity\Providers;

use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;

final class IdentityServiceProvider extends ServiceProvider
{
    /**
     * 该模块契约的具体绑定。
     * 其它模块只应引用 Shared 下的 Contract 类。
     */
    private const BINDINGS = [
        \App\Shared\Contracts\Identity\UserDirectory::class
            => \App\Modules\Identity\Services\UserDirectoryService::class,
        \App\Shared\Contracts\Identity\PasswordHasher::class
            => \App\Modules\Identity\Services\Argon2idPasswordHasher::class,
    ];

    public function register(): void
    {
        foreach (self::BINDINGS as $abstract => $concrete) {
            $this->app->bind($abstract, $concrete);
        }

        $this->mergeConfigFrom(__DIR__ . '/../config/identity.php', 'identity');
    }

    public function boot(): void
    {
        $this->loadRoutes();
        $this->loadMigrations();
        $this->loadFactories();
        $this->registerListeners();
    }

    private function loadRoutes(): void
    {
        Route::middleware('api')
            ->prefix('api')
            ->name('identity.api.')
            ->group(__DIR__ . '/../Routes/api.php');

        Route::middleware('web')
            ->name('identity.')
            ->group(__DIR__ . '/../Routes/web.php');
    }

    private function loadMigrations(): void
    {
        $this->loadMigrationsFrom(__DIR__ . '/../Database/Migrations');
    }

    private function loadFactories(): void
    {
        if (! $this->app->environment('production')) {
            \Illuminate\Database\Eloquent\Factories\Factory::guessFactoryNamesUsing(
                fn(string $modelName) => str_replace(
                    ['App\\Modules\\', '\\Models\\'],
                    ['App\\Modules\\', '\\Database\\Factories\\'],
                    $modelName
                ) . 'Factory'
            );
        }
    }

    private function registerListeners(): void
    {
        // 只监听自己的事件,跨上下文事件走中介者模式
        \Illuminate\Support\Facades\Event::listen(
            \App\Modules\Identity\Events\UserRegistered::class,
            \App\Modules\Identity\Listeners\SendWelcomeEmailSignal::class,
        );
    }
}

模块在 config/app.php(或 Laravel 11+ 的 bootstrap/providers.php)中注册:

php
'providers' => [
    // Laravel 自带的 provider
    Illuminate\Auth\AuthServiceProvider::class,
    // ...

    // 模块 provider
    App\Modules\Identity\Providers\IdentityServiceProvider::class,
    App\Modules\Billing\Providers\BillingServiceProvider::class,
    App\Modules\Commerce\Providers\CommerceServiceProvider::class,
    App\Modules\Loyalty\Providers\LoyaltyServiceProvider::class,
    App\Modules\Notifications\Providers\NotificationsServiceProvider::class,

    // 应用级 provider(保持极简)
    App\Providers\AppServiceProvider::class,
],

每个模块现在都是自洽的——路由、迁移、工厂、事件绑定全部住在模块自身的服务提供者里。应用层的 AppServiceProvider 变成一个薄薄的协调者,而不再是什么都往里塞的垃圾桶。

上下文之间的通信,首选事件

上下文之间最常见的通信方式是领域事件。一个上下文说"这件事现在是事实",别的上下文感兴趣就响应,fire-and-forget,无紧耦合。

Identity 发布 UserRegistered,Billing、Loyalty、Notifications 分别监听并响应:

php
<?php
// app/Modules/Identity/Events/UserRegistered.php

namespace App\Modules\Identity\Events;

use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

final class UserRegistered
{
    use Dispatchable, SerializesModels;

    public function __construct(
        public readonly int $userId,
        public readonly string $email,
        public readonly string $name,
        public readonly \DateTimeImmutable $registeredAt,
    ) {}
}

注意事件携带的是"其它上下文可能关心的数据快照"——小而不可变,而不是整个 User 模型。这一设计是刻意的。其它上下文都不应直接加载 User,而是从事件携带的快照里工作。

Identity 在注册成功后派发事件:

php
<?php
// app/Modules/Identity/Services/RegistrationService.php

namespace App\Modules\Identity\Services;

use App\Modules\Identity\Events\UserRegistered;
use App\Modules\Identity\Models\User;
use App\Shared\Contracts\Identity\PasswordHasher;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;

final class RegistrationService
{
    public function __construct(
        private readonly PasswordHasher $hasher,
    ) {}

    public function register(string $email, string $password, string $name): User
    {
        return DB::transaction(function () use ($email, $password, $name) {
            $user = User::create([
                'email'    => $email,
                'password' => $this->hasher->hash($password),
                'name'     => $name,
            ]);

            Event::dispatch(new UserRegistered(
                userId:       $user->id,
                email:        $user->email,
                name:         $user->name,
                registeredAt: $user->created_at->toDateTimeImmutable(),
            ));

            return $user;
        });
    }
}

其它上下文的监听器订阅这条事件:

php
<?php
// app/Modules/Loyalty/Listeners/CreateLoyaltyAccountForNewUser.php

namespace App\Modules\Loyalty\Listeners;

use App\Modules\Identity\Events\UserRegistered;
use App\Modules\Loyalty\Models\LoyaltyAccount;
use Illuminate\Contracts\Queue\ShouldQueue;

final class CreateLoyaltyAccountForNewUser implements ShouldQueue
{
    public function handle(UserRegistered $event): void
    {
        LoyaltyAccount::create([
            'user_id'        => $event->userId,
            'tier'           => 'bronze',
            'points_balance' => 0,
            'enrolled_at'    => $event->registeredAt,
        ]);
    }
}

Billing 里类似的监听器(创建计费档案)、Notifications 里类似的监听器(发送欢迎邮件)也按相同形态组织。每个监听器都住在自己的模块里,订阅的是 Identity 的事件,但执行的是 Loyalty、Billing、Notifications 各自的业务逻辑。

跨上下文事件的监听器注册,放在消费方模块的服务提供者里:

php
// app/Modules/Loyalty/Providers/LoyaltyServiceProvider.php
private function registerListeners(): void
{
    Event::listen(
        \App\Modules\Identity\Events\UserRegistered::class,
        \App\Modules\Loyalty\Listeners\CreateLoyaltyAccountForNewUser::class,
    );

    Event::listen(
        \App\Modules\Commerce\Events\OrderCompleted::class,
        \App\Modules\Loyalty\Listeners\AwardPointsForOrder::class,
    );
}

这是唯一被允许的跨上下文引用——Loyalty 模块 import 了 Identity 的事件类,因为 Identity 的事件类是它的公共 API。事件类实际上就是契约。

需要同步数据时,使用查询总线

事件擅长"响应某件事",但不适合"某个上下文在同一次请求内需要读另一个上下文的数据"这种场景。Billing 服务要在账单 PDF 上显示用户姓名——它现在就需要这份数据,不能"稍后"。

直接的冲动是从 Billing 这边 import Identity\Models\User 去查。这样边界就破了。正确模式是查询总线(query bus):一层薄的抽象,专门用来"请回答这个问题"。

最小查询总线实现:

php
<?php
// app/Shared/QueryBus/Query.php
namespace App\Shared\QueryBus;

interface Query {}

// app/Shared/QueryBus/QueryHandler.php
namespace App\Shared\QueryBus;

interface QueryHandler
{
    public function handle(Query $query): mixed;
}

// app/Shared/QueryBus/QueryBus.php
namespace App\Shared\QueryBus;

use Illuminate\Contracts\Container\Container;

final class QueryBus
{
    public function __construct(
        private readonly Container $container,
        private readonly array $handlers,   // query 类 => handler 类
    ) {}

    public function ask(Query $query): mixed
    {
        $queryClass = $query::class;

        if (! isset($this->handlers[$queryClass])) {
            throw new \RuntimeException("No handler registered for query $queryClass");
        }

        /** @var QueryHandler $handler */
        $handler = $this->container->make($this->handlers[$queryClass]);

        return $handler->handle($query);
    }
}

Identity 通过契约对外暴露可被询问的查询:

php
<?php
// app/Shared/Contracts/Identity/Queries/GetUserSummary.php
// (放在 Shared 下,因为是跨上下文契约)
namespace App\Shared\Contracts\Identity\Queries;

use App\Shared\QueryBus\Query;

final class GetUserSummary implements Query
{
    public function __construct(
        public readonly int $userId,
    ) {}
}

// app/Shared/Contracts/Identity/UserSummary.php
namespace App\Shared\Contracts\Identity;

final class UserSummary
{
    public function __construct(
        public readonly int $userId,
        public readonly string $email,
        public readonly string $name,
        public readonly ?string $preferredLanguage,
        public readonly bool $isActive,
    ) {}
}

Identity 模块负责提供处理器:

php
<?php
// app/Modules/Identity/QueryHandlers/GetUserSummaryHandler.php
namespace App\Modules\Identity\QueryHandlers;

use App\Modules\Identity\Models\User;
use App\Shared\Contracts\Identity\Queries\GetUserSummary;
use App\Shared\Contracts\Identity\UserSummary;
use App\Shared\QueryBus\Query;
use App\Shared\QueryBus\QueryHandler;

final class GetUserSummaryHandler implements QueryHandler
{
    public function handle(Query $query): UserSummary
    {
        assert($query instanceof GetUserSummary);

        $user = User::findOrFail($query->userId);

        return new UserSummary(
            userId:            $user->id,
            email:             $user->email,
            name:              $user->name,
            preferredLanguage: $user->preferred_language,
            isActive:          $user->is_active,
        );
    }
}

Billing 现在无需触碰 Identity 的模型就能取到用户数据:

php
<?php
// app/Modules/Billing/Services/InvoiceRenderer.php
namespace App\Modules\Billing\Services;

use App\Modules\Billing\Models\Invoice;
use App\Shared\Contracts\Identity\Queries\GetUserSummary;
use App\Shared\QueryBus\QueryBus;

final class InvoiceRenderer
{
    public function __construct(
        private readonly QueryBus $queryBus,
    ) {}

    public function render(Invoice $invoice): array
    {
        // 通过查询总线向 Identity 问客户数据,不直接 import User
        $user = $this->queryBus->ask(new GetUserSummary($invoice->user_id));

        return [
            'invoice_number' => $invoice->number,
            'customer_name'  => $user->name,
            'customer_email' => $user->email,
            'line_items'     => $invoice->items->map(...)->all(),
            'total'          => $invoice->total,
        ];
    }
}

这样达成的效果是:Billing 依赖的是 App\Shared\Contracts\Identity\UserSummaryApp\Shared\Contracts\Identity\Queries\GetUserSummary,它不依赖 App\Modules\Identity\Models\User。Identity 明天就可以把 User 改名成 Account、重构字段、拆分表——只要 GetUserSummaryHandler 仍然返回一个合法的 UserSummary,Billing 就不会坏。

查询总线和处理器的注册放在一个集中的位置:

php
// bootstrap/app.php 或单独的 QueryBusServiceProvider
$this->app->singleton(\App\Shared\QueryBus\QueryBus::class, function ($app) {
    return new \App\Shared\QueryBus\QueryBus(
        $app,
        handlers: [
            \App\Shared\Contracts\Identity\Queries\GetUserSummary::class
                => \App\Modules\Identity\QueryHandlers\GetUserSummaryHandler::class,

            \App\Shared\Contracts\Commerce\Queries\GetOrderTotals::class
                => \App\Modules\Commerce\QueryHandlers\GetOrderTotalsHandler::class,

            // ...
        ],
    );
});

共享 users 表的难题

这是整套架构里最硬的现实问题:每个上下文都需要读用户数据。事件和查询给出松耦合,但有时出于性能考虑就是需要和 users 在数据库里做 JOIN。正确模式是什么?

三种做法,按解耦程度递增排列:

做法一,每上下文只读模型

每个需要用户数据的上下文都建立自己的 Eloquent 模型,指向同一张 users 表,但视之为只读

php
<?php
// app/Modules/Billing/Models/UserReadModel.php
namespace App\Modules\Billing\Models;

use Illuminate\Database\Eloquent\Model;

final class UserReadModel extends Model
{
    protected $table = 'users';
    protected $guarded = [];

    /**
     * 本模型只读。Billing 绝不写入 users 表。
     * 用断言守住这条红线。
     */
    public static function boot(): void
    {
        parent::boot();

        static::saving(function () {
            throw new \LogicException(
                'Billing 模块不能写 users 表,请派发 Identity 事件或使用查询总线。'
            );
        });

        static::deleting(function () {
            throw new \LogicException(
                'Billing 模块不能删除用户,请派发 Identity 事件。'
            );
        });
    }
}

Billing 内的查询现在可以为报表和性能考虑和 users 做 JOIN:

php
$overdue = Invoice::overdue()
    ->join('users', 'users.id', '=', 'invoices.user_id')
    ->select('invoices.*', 'users.email', 'users.name')
    ->get();

但任何从 Billing 改写用户记录的尝试都会立即抛异常。边界在运行时被强制。

做法二,事件驱动的本地投影

每个上下文维护自己的一张小表,里面只存它关心的那几列用户字段,通过事件更新:

php
// app/Modules/Billing/Database/Migrations/...create_billing_customer_projection.php
Schema::create('billing_customer_projection', function (Blueprint $table) {
    $table->unsignedBigInteger('user_id')->primary();
    $table->string('email');
    $table->string('name');
    $table->string('preferred_language')->nullable();
    $table->boolean('is_active')->default(true);
    $table->timestamps();
});
php
// 更新投影表的监听器
final class UpdateBillingCustomerProjection
{
    public function handleRegistered(UserRegistered $event): void
    {
        BillingCustomerProjection::updateOrCreate(
            ['user_id' => $event->userId],
            [
                'email' => $event->email,
                'name'  => $event->name,
                // ...
            ]
        );
    }

    public function handleEmailChanged(UserEmailChanged $event): void
    {
        BillingCustomerProjection::where('user_id', $event->userId)
            ->update(['email' => $event->newEmail]);
    }
}

这是完全事件驱动的解耦。Billing 有自己的数据,由 Identity 的事件驱动更新,JOIN 只查自己的表。代价是最终一致性——Identity 改了用户邮箱、Billing 的投影还没来得及接收事件的那个短暂窗口。

做法三,直接用查询总线

对于低频数据需求(每请求查几次),直接走查询总线即可。读路径延迟更高,但架构最简单。

对多数从单体迁过来的 Laravel 应用而言,做法一(只读模型)是务实的起点。之后如果需要把上下文真正抽成独立服务,做法二就变得有吸引力。做法三最干净,但在数据密集查询下性能最弱。

关键洞察是:写路径必须单一所有者(Identity 写 users),但读路径可以在每个上下文里复制以满足查询性能。

用 Deptrac 固化边界

人会忘事。"就这一次,从 Billing 控制器里引一下 App\Modules\Identity\Models\User 省事"的冲动是真实、持续的,最后一定会有人让步。唯一可靠的约束是自动化。

Deptrac 是专门做这件事的静态分析工具。安装:

bash
composer require --dev qossmic/deptrac-shim

配置(deptrac.yaml):

yaml
deptrac:
  paths:
    - ./app
  exclude_files:
    - '#.*test.*#i'
  layers:
    - name: Identity
      collectors:
        - type: classLike
          value: '^App\\Modules\\Identity\\.*'
    - name: Billing
      collectors:
        - type: classLike
          value: '^App\\Modules\\Billing\\.*'
    - name: Commerce
      collectors:
        - type: classLike
          value: '^App\\Modules\\Commerce\\.*'
    - name: Loyalty
      collectors:
        - type: classLike
          value: '^App\\Modules\\Loyalty\\.*'
    - name: Notifications
      collectors:
        - type: classLike
          value: '^App\\Modules\\Notifications\\.*'
    - name: Shared
      collectors:
        - type: classLike
          value: '^App\\Shared\\.*'
    - name: Framework
      collectors:
        - type: classLike
          value: '^(Illuminate|Symfony|Carbon)\\.*'
  ruleset:
    Identity:
      - Shared
      - Framework
    Billing:
      - Shared
      - Framework
    Commerce:
      - Shared
      - Framework
    Loyalty:
      - Shared
      - Framework
    Notifications:
      - Shared
      - Framework
    Shared:
      - Framework

ruleset 段是真正的"牙齿":Identity 只能依赖 Shared 和 Framework 层的代码,Billing 同样。没有模块可以直接依赖别的模块,任何跨模块通信都必须走 Shared(契约、事件、查询接口)。

运行 vendor/bin/deptrac 会得到报告:

+-----------+--------+
| Layer     | Status |
+-----------+--------+
| Identity  | OK     |
| Billing   | FAIL   |
| Commerce  | OK     |
| Loyalty   | OK     |
+-----------+--------+

Billing has 1 violation:
  App\Modules\Billing\Services\InvoiceRenderer -> App\Modules\Identity\Models\User
  at InvoiceRenderer.php:42

这条违规正是本文一直警告的"Billing 里直接 import Identity 的 User"。Deptrac 在 CI 中捕获它,合并之前就会拦下来。

接入 CI:

yaml
# .github/workflows/ci.yml
- name: Check architecture
  run: vendor/bin/deptrac --no-progress --no-interaction

Deptrac 失败会阻止 PR 合并。开发者会很快学到跨模块 import 不被允许,反馈即时且具体。

迁移节奏

这套方案不需要一次性全部落地。约三个月的节奏通常如下:

第 1–2 周,发现与设计。 组织事件风暴工作坊,得出上下文清单,以及每个上下文各自拥有、消费、发布的内容。把这些写进仓库里一份短 markdown 文档。不改任何代码。

第 3–4 周,首个上下文脚手架。 选一个最干净的边界——通常是 Notifications,因为它大多是反应式的、容易隔离。在 app/Modules/Notifications/ 下建目录结构、服务提供者、路由文件。把既有通知代码搬进去,更新 composer 自动加载,确认没有破坏,发布上线。

第 5–8 周,抽第二个上下文。 选 Billing 或 Commerce,看当前代码里哪个更自洽。过程相同:建目录、搬文件、改命名空间、注册服务提供者。此轮迁移中遇到的痛点会为剩余上下文的设计提供经验。

第 9–12 周,剩余上下文。 此时模式已经稳定,每个上下文大约一周——脚手架部分可复制,真正的工作集中在代码搬迁和跨上下文通信的重构。

始终推进,接入 Deptrac。 在第一个上下文抽出来之后再接入 Deptrac,而不是更早。在彻底的单体上开启架构约束是灾难,每个文件都违反每条规则。等到至少有一个干净模块之后再引入,让它随迁移一起成长。

每一周的增量都会发布到生产环境。没有"大揭幕"式的一次性切换。应用每周长得略有不同,三个月后已经完全重组,但整个过程里从未出现专门为"这次重构"腾出来的迭代

测试策略

限界上下文让测试在两个方向上更有意义。

第一,单元测试变得更聚焦。Identity 里 RegistrationService 的测试可以专注于注册行为,不必 mock 十几个外部系统。服务的依赖要么都在 Identity 内部,要么来自 Shared/Contracts/ 的显式注入,测试 setup 小而干净。

php
<?php
// app/Modules/Identity/Tests/Unit/RegistrationServiceTest.php
namespace App\Modules\Identity\Tests\Unit;

use App\Modules\Identity\Events\UserRegistered;
use App\Modules\Identity\Models\User;
use App\Modules\Identity\Services\RegistrationService;
use App\Shared\Contracts\Identity\PasswordHasher;
use Illuminate\Support\Facades\Event;
use Tests\TestCase;

final class RegistrationServiceTest extends TestCase
{
    public function test_successful_registration_creates_user_and_dispatches_event(): void
    {
        Event::fake();

        $hasher = $this->mock(PasswordHasher::class);
        $hasher->shouldReceive('hash')->andReturn('hashed-password');

        $service = $this->app->make(RegistrationService::class);

        $user = $service->register('alice@example.com', 'password', 'Alice');

        $this->assertInstanceOf(User::class, $user);
        $this->assertEquals('alice@example.com', $user->email);

        Event::assertDispatched(UserRegistered::class, fn($event) =>
            $event->userId === $user->id
            && $event->email === 'alice@example.com'
        );
    }
}

第二,契约测试成为验证跨上下文约定的主力。一条契约测试检查"Billing 通过查询总线向 Identity 发的问题,拿回的形态是否仍然合法":

php
<?php
// app/Modules/Billing/Tests/Integration/IdentityContractTest.php
namespace App\Modules\Billing\Tests\Integration;

use App\Modules\Identity\Models\User;
use App\Shared\Contracts\Identity\Queries\GetUserSummary;
use App\Shared\Contracts\Identity\UserSummary;
use App\Shared\QueryBus\QueryBus;
use Tests\TestCase;

final class IdentityContractTest extends TestCase
{
    public function test_get_user_summary_returns_expected_shape(): void
    {
        $user = User::factory()->create([
            'email' => 'alice@example.com',
            'name'  => 'Alice',
        ]);

        /** @var QueryBus $bus */
        $bus = $this->app->make(QueryBus::class);

        $summary = $bus->ask(new GetUserSummary($user->id));

        $this->assertInstanceOf(UserSummary::class, $summary);
        $this->assertEquals('alice@example.com', $summary->email);
        $this->assertEquals('Alice', $summary->name);
    }
}

一旦 Identity 改动 UserSummary 的构造方式破坏了这个契约,这条测试会失败——它住在 Billing 模块里,于是 Billing 团队第一时间收到"该更新集成"的信号。

简明 Q&A

Q:这套结构和 Laravel Octane、Horizon 等长驻进程方案兼容吗?
兼容,但有一个注意事项。Octane 会在请求之间缓存服务容器状态,也就是单例会长期存在。对无状态处理器的查询总线而言,这反而更快更好。若确实存在请求级状态(在设计良好的总线里并不常见),需要通过 Octane 的 octane:flush-cache 钩子或作用域实例显式处理。

Q:Policies、Form Request、API Resource 这些 Laravel 内建设施怎么融入?
各归各的模块。App\Modules\Billing\Policies\InvoicePolicyApp\Modules\Identity\Http\Requests\UpdateProfileRequestApp\Modules\Commerce\Http\Resources\OrderResource。框架不关心类住哪儿,只要能被自动加载即可。Laravel 的魔法(比如基于模型名的策略自动发现)只要在模块内遵循约定,依然有效。

Q:users 认证 guard 怎么办?它读 users 表,而 Auth 到处都在被用。
认证是真正跨切面的关注点。Laravel 的 Auth facade 通过 Eloquent provider 读 users,需要一个 Eloquent 模型类。这个模型应该是 App\Modules\Identity\Models\User(或 Identity 选定的其它名字),认证配置指向它。其它上下文通过 auth()->user() 取到当前用户的身份——但只使用其 ID,额外数据通过查询总线取。不要把这个 Eloquent 实例跨上下文传递。

Q:Eloquent 的 hasManybelongsTo 等在这套结构里还能用吗?
同一个上下文内完全可以,而且鼓励使用。User 有很多 UserSession,都在 Identity 内。Invoice 属于 Subscription,都在 Billing 内。被禁止的是 Invoice 跨模块 belongsToUser,因为这会形成硬依赖。跨上下文读的场景使用做法一(只读模型)——它同样允许 Eloquent 关系,只是带着只读保护。

Q:多大规模的代码库做这件事才合算?
大约在 5 万 – 10 万行应用代码之间,或团队超过 5 名工程师的时候。再小的话,这套仪式感的代价高过收益——小代码库通常可以整个被理解。真正需要引入限界上下文的信号是:新人上手超过 4–6 周,或 PR 经常跨 8 个以上不相关区域。

Q:这和真正抽成微服务相比如何?
单体里的限界上下文是安全抽服务的前提,不是替代。当单体具备清晰、被 Deptrac 固化、通过事件与查询通信的上下文边界时,抽服务就变成"把事件监听器换成 HTTP 调用,把查询总线换成 API 调用"。没有限界上下文的微服务拆分,则是一整年在解开隐式依赖。

Q:真正跨所有上下文的东西——日志、指标、特性开关——放哪里?
那正是 Shared/ 存在的意义。日志接口、指标客户端、特性开关服务,都属于这里。Shared/ 的铁律"不触及任何具体业务概念"能守住这层的纯净度。如果它带着业务逻辑,就应该归到某个特定上下文。

结语

单体的陷阱不在于代码本身写得不好,而在于它的结构完全不承载业务信息。五年下来,每一个新特性都需要翻阅十几个毫不相关区域的代码,才能搞清楚这件事会影响什么——代码库慢慢变成一个披着技术外衣的项目管理问题。

限界上下文,即使只在单个 Laravel 应用里落地,也能让结构重新承载意义。入职到 Billing 上下文的开发者不需要读身份相关代码就能理解计费流程;改动订阅流程被自然包住在 Billing 内部,任何关心它的人都有定义良好的事件可以订阅;新特性有显而易见的归属——归给它所属领域的上下文,而不是"哪里还有空位"。

可贵的是这件事其实足够可操作。不重写、不换框架、不上服务网格。只要目录、命名空间、一个查询总线、几个事件、一个静态分析器。三个月的渐进式工作,每一步都在发布到生产,最终代码库焕然一新,中途却从未出现过"系统坏了"的时刻。

团队沟通的难度其实大于代码改动本身。事件风暴工作坊要求产品同学出现并愿意一起出声思考;上下文边界需要反复争辩"这段逻辑归谁管";"共享 users 表"的问题没有完美答案,只有需要被显式讨论的权衡。这些对话最终比代码改动本身还贡献更多——它产出的是共享理解

选一条最干净的边界,抽出一个上下文,发布上线,再做下一个。三个月之后,那个原本是一堆散乱 app/ 的应用,已经变成一组各自可理解的模块。"微服务"这个词不再出现在会议里,因为真正的底层问题——"代码库难以推理"——已经被解决,而且没有为此承担微服务的额外成本。

目录承载含义,事件承载事实,查询承载问题。 这就是整套模式。剩下的只是纪律。

收束闭环

一位开发者加入团队。他打开 app/Modules/Billing/,花一上午读完这个模块里所有的代码:控制器、服务、事件、查询处理器、投影监听器。中午时他已经明白 Billing 是如何工作的。他不需要知道 Identity、Commerce、Loyalty 如何实现,只需要知道他们发布的事件和暴露的查询。

下午,他开出第一个 PR:给"账单即将到期"新增一条提醒。改动只涉及 Billing 里的三个文件——一个服务、一条路由、一次事件派发。Deptrac 通过,单元测试通过,契约测试通过。PR 两小时内合并发布。

他没体会到五年前这套代码长什么样,也不用体会。系统之所以让他能这样工作,是因为某个三个月的周期里,另一群人愿意把墙一面一面立起来。

这就是全部意义所在。

速查卡

目录结构:

app/
├── Modules/
│   ├── Identity/       (拥有 users、sessions、auth)
│   ├── Billing/        (拥有 subscriptions、invoices)
│   ├── Commerce/       (拥有 orders、products)
│   ├── Loyalty/        (拥有 points、referrals)
│   └── Notifications/  (拥有 email/sms/push 分发)
└── Shared/             (契约、值对象、通用工具)

三条铁律:

  • 每张数据库表只能由一个模块写入(写路径单一所有者)。
  • 跨模块通信走事件(fire-and-forget)或查询总线(同步读)。
  • 模块 Z 永远不能 import App\Modules\X\Models\Y,必须走 App\Shared\Contracts\ 的契约。

每模块内部结构:

Modules/Identity/
├── Http/Controllers/
├── Models/
├── Services/
├── Events/
├── Listeners/
├── QueryHandlers/
├── Policies/
├── Providers/IdentityServiceProvider.php
├── Routes/api.php
└── Database/Migrations/

通信模式:

  • 领域事件——对外宣告"这件事发生了",别的上下文按需响应
  • 查询总线——对外回答"X 关于 Y 的什么问题",同步读
  • 只读模型——跨上下文 JOIN 查询场景,兼顾性能

规则固化:

yaml
# deptrac.yaml —— 每个模块只能看到 Shared 与 Framework
ruleset:
  Identity: [Shared, Framework]
  Billing: [Shared, Framework]
  Commerce: [Shared, Framework]
  # ...

迁移节奏:

  • 第 1–2 周:事件风暴工作坊,确立上下文,写进文档
  • 第 3–4 周:第一个上下文脚手架(一般是 Notifications——最简单)
  • 第 5–8 周:第二个上下文,踩完主要坑,固化模式
  • 第 9–12 周:剩余上下文依次抽离
  • 第一个干净模块就位后接入 Deptrac 到 CI
  • 每周都发布到生产,不做一次性切换
本作品采用《CC 协议》,转载必须注明作者和本文链接