把限界上下文引入现有 Laravel 应用的实战手册
打开一个运行了五年的 Laravel 应用,看看 app/Models/:有一个 User,还有 UserProfile、UserAddress、UserNotificationPreference、UserSubscription、UserBillingContact、UserLoyaltyAccount、UserReferral。另外 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 的回答:
- 拥有:
users、user_profiles、user_sessions、password_reset_tokens。写入仅限 Identity。 - 消费: 正常运行中不从其它上下文读任何数据。
- 发布:
user_registered、user_email_changed、user_deactivated。
Billing 的回答:
- 拥有:
subscriptions、invoices、invoice_items、payment_methods、billing_contacts。 - 消费: 用户身份基本信息(姓名、邮箱、主用语言)——只读快照。
- 发布:
subscription_started、subscription_cancelled、invoice_paid、invoice_overdue。
Loyalty 的回答:
- 拥有:
loyalty_accounts、point_transactions、reward_redemptions、referral_codes。 - 消费: 用户身份基本信息、来自 Commerce 的订单金额。
- 发布:
points_awarded、reward_redeemed、referral_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/ 存放真正属于多个上下文的代码——Money 或 EmailAddress 这类值对象、通用契约、基础异常类。Shared/ 的铁律是:任何需要牵涉到某个具体业务领域的东西都不属于这里。
命名空间在 composer.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
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)中注册:
'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
// 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
// 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
// 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 各自的业务逻辑。
跨上下文事件的监听器注册,放在消费方模块的服务提供者里:
// 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
// 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
// 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
// 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
// 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\UserSummary 与 App\Shared\Contracts\Identity\Queries\GetUserSummary,它不依赖 App\Modules\Identity\Models\User。Identity 明天就可以把 User 改名成 Account、重构字段、拆分表——只要 GetUserSummaryHandler 仍然返回一个合法的 UserSummary,Billing 就不会坏。
查询总线和处理器的注册放在一个集中的位置:
// 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
// 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:
$overdue = Invoice::overdue()
->join('users', 'users.id', '=', 'invoices.user_id')
->select('invoices.*', 'users.email', 'users.name')
->get();但任何从 Billing 改写用户记录的尝试都会立即抛异常。边界在运行时被强制。
做法二,事件驱动的本地投影
每个上下文维护自己的一张小表,里面只存它关心的那几列用户字段,通过事件更新:
// 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();
});// 更新投影表的监听器
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 是专门做这件事的静态分析工具。安装:
composer require --dev qossmic/deptrac-shim配置(deptrac.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:
- Frameworkruleset 段是真正的"牙齿":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:
# .github/workflows/ci.yml
- name: Check architecture
run: vendor/bin/deptrac --no-progress --no-interactionDeptrac 失败会阻止 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
// 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
// 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\InvoicePolicy、App\Modules\Identity\Http\Requests\UpdateProfileRequest、App\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 的 hasMany、belongsTo 等在这套结构里还能用吗?
在同一个上下文内完全可以,而且鼓励使用。User 有很多 UserSession,都在 Identity 内。Invoice 属于 Subscription,都在 Billing 内。被禁止的是 Invoice 跨模块 belongsTo 到 User,因为这会形成硬依赖。跨上下文读的场景使用做法一(只读模型)——它同样允许 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 查询场景,兼顾性能
规则固化:
# 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
- 每周都发布到生产,不做一次性切换