0

#07 — Sổ tay module: cấu trúc, quy ước, mẫu code Phần 1

Tài liệu #4. Tham chiếu: 01-master-plan.md §7 (hexagonal) · 02-domain-model.md (nghiệp vụ) · 03-database-schema.md §17 (ai sở hữu bảng nào). Ngày lập: 13/08/2026 · Trạng thái: Bản nháp 1 — dùng được từ P0 Đối tượng: bất cứ ai viết code trong dự án này, kể cả AI agent.


0. Tài liệu này dùng để làm gì

Doc 02 nói cái gì phải đúng. Doc 03 nói dữ liệu nằm đâu. Tài liệu này nói viết code thế nào để hai điều đó không bị phá vỡ khi có 20 module và 3 người cùng sửa.

Ba câu hỏi nó trả lời dứt điểm:

  1. Tôi vừa được giao một chức năng — nó thuộc module nào, file nào, tầng nào?
  2. Module A cần dữ liệu của module B — tôi được gọi trực tiếp không?
  3. Làm sao biết mình vừa phá ranh giới kiến trúc trước khi review chỉ ra?

Câu 3 có câu trả lời máy móc: composer check báo đỏ. Toàn bộ tài liệu này tồn tại để câu trả lời đó luôn đúng.


1. Cấu trúc repository

C:\ecommer\ecommerce\                      ← đổi tên từ example-app ở P0
├── app/                                   ← chỉ còn "vỏ" Laravel, KHÔNG chứa nghiệp vụ
│   ├── Providers/AppServiceProvider.php
│   └── Support/                            tiện ích thuần kỹ thuật dùng chung
├── bootstrap/
│   ├── app.php                            ← Laravel 11+: thay Http/Console Kernel
│   └── providers.php                       đăng ký ServiceProvider của module
├── config/
├── database/
│   ├── migrations/                        ← CHỈ migration của khung
│   │   └── 2026_08_20_000000_create_foundation.php   ← doc 03 §2.1, timestamp SỚM NHẤT
│   └── seeders/DatabaseSeeder.php
├── modules/                               ← toàn bộ nghiệp vụ nằm đây
│   ├── Shared/                            ← Shared Kernel (doc 02 §3)
│   ├── Catalog/
│   ├── Pricing/
│   ├── Promotion/
│   ├── Inventory/
│   ├── Cart/
│   ├── Checkout/
│   ├── Order/
│   ├── Payment/
│   ├── Fulfilment/
│   ├── Returns/
│   ├── Tax/
│   ├── Customer/
│   ├── Loyalty/
│   ├── Review/
│   ├── Procurement/
│   └── Notification/
├── docs/                                  ← 01..NN
├── routes/
│   ├── web.php                             chỉ include, không định nghĩa route nghiệp vụ
│   └── console.php
├── tests/
│   ├── Architecture/                      ← arch test toàn cục (§6)
│   └── TestCase.php
├── compose.yaml
├── composer.json
├── deptrac.yaml
├── phpstan.neon
├── pint.json
├── rector.php
└── CLAUDE.md

Bất biến của cấu trúc: sau P1, app/ không được có thêm file nghiệp vụ nào. Có arch test kiểm điều này (§6, test số 8).

Về monorepo: giai đoạn P0–P5 chỉ có một repo, một composer.json gốc. Storefront Next.js (nếu làm ở P6) sẽ là apps/storefront/ — khi đó mới cần bàn đến monorepo thật. Không dựng hạ tầng monorepo trước khi có ứng dụng thứ hai.


2. Module là composer package nội bộ

Đây là điểm khiến ranh giới trở thành thật thay vì chỉ là thư mục.

2.1 composer.json gốc

{
    "require": {
        "php": "^8.3",
        "laravel/framework": "^13.25",
        "modules/shared": "*",
        "modules/catalog": "*",
        "modules/inventory": "*",
        "modules/order": "*"
    },
    "repositories": [
        {
            "type": "path",
            "url": "modules/*",
            "options": { "symlink": true }
        }
    ],
    "scripts": {
        "check": [
            "@pint",
            "@stan",
            "@deptrac",
            "@test"
        ],
        "pint":    "pint --test",
        "stan":    "phpstan analyse --memory-limit=1G",
        "deptrac": "deptrac analyse --fail-on-uncovered --report-uncovered",
        "test":    "pest --parallel"
    }
}

"symlink": true là bắt buộc trên Windows — nhưng cần Developer Mode bật hoặc chạy terminal với quyền tạo symlink. Nếu Composer báo không tạo được symlink, nó sẽ copy thay vì link, và thay đổi trong modules/ sẽ không có hiệu lực cho đến khi composer update — đây là lỗi cực khó đoán. Kiểm tra ngay ở P0:

# Sau composer install, kiểm tra vendor/modules/catalog là symlink hay thư mục thật
Get-Item vendor\modules\catalog | Select-Object Name, LinkType, Target

Kỳ vọng LinkType = SymbolicLink. Nếu không, xem §11.3.

2.2 composer.json của một module

modules/Catalog/composer.json:

{
    "name": "modules/catalog",
    "description": "Bounded context: Catalog",
    "type": "library",
    "license": "proprietary",
    "require": {
        "php": "^8.3",
        "modules/shared": "*"
    },
    "autoload": {
        "psr-4": { "Modules\\Catalog\\": "src/" }
    },
    "autoload-dev": {
        "psr-4": { "Modules\\Catalog\\Tests\\": "tests/" }
    },
    "extra": {
        "laravel": {
            "providers": [
                "Modules\\Catalog\\Infrastructure\\CatalogServiceProvider"
            ]
        }
    },
    "minimum-stability": "stable"
}

Điểm quan trọng nhất trong file này là khối require. Nó là bản khai báo tường minh: "module Catalog chỉ được phụ thuộc Shared". Nếu ai đó viết use Modules\Order\... trong Catalog, Composer sẽ không tự động sinh autoload cho nó và lỗi ngay khi chạy — trước cả khi deptrac kịp báo. Đây là tầng cưỡng chế đầu tiên, mạnh hơn bất cứ linter nào.

Quy tắc khai báo require của module:

Module Được require
Shared (không gì — chỉ PHP)
Mọi module khác modules/shared
Checkout modules/shared + contract của Pricing/Promotion/Inventory/Tax

⚠ Khi Checkout cần gọi Pricing, nó không require modules/pricing. Nó require modules/pricing-contract — xem §8.2.


3. Bên trong một module

modules/Catalog/
├── composer.json
├── src/
│   ├── Domain/                    ⛔ KHÔNG import Illuminate\* hay Symfony\*
│   │   ├── Entity/
│   │   │   ├── Product.php                aggregate root
│   │   │   └── Variant.php                entity con
│   │   ├── ValueObject/
│   │   │   └── ProductStatus.php           enum + VO riêng của context
│   │   ├── Event/
│   │   │   └── ProductPublished.php        thì quá khứ, readonly
│   │   ├── Port/                          ← interface RA ngoài
│   │   │   ├── ProductRepository.php
│   │   │   └── SearchIndexer.php
│   │   ├── Policy/
│   │   │   └── PublishingPolicy.php
│   │   ├── Service/                       ← domain service (khi logic không thuộc entity nào)
│   │   │   └── SkuGenerator.php
│   │   └── Exception/
│   │       └── SkuAlreadyExists.php
│   ├── Application/               ⛔ KHÔNG import Eloquent, KHÔNG import HTTP
│   │   ├── Command/
│   │   │   ├── PublishProduct.php          DTO ý định (readonly)
│   │   │   └── PublishProductHandler.php   use case
│   │   ├── Query/
│   │   │   ├── FindProductBySlug.php
│   │   │   └── FindProductBySlugHandler.php
│   │   ├── Dto/
│   │   │   └── ProductData.php             spatie/laravel-data
│   │   └── Listener/
│   │       └── ReindexOnPriceChanged.php   phản ứng event của module khác
│   ├── Infrastructure/            ✅ nơi DUY NHẤT được biết Eloquent/HTTP/queue
│   │   ├── Persistence/Eloquent/
│   │   │   ├── ProductModel.php             Eloquent model
│   │   │   ├── ProductMapper.php            Model ↔ Entity
│   │   │   └── EloquentProductRepository.php
│   │   ├── Search/
│   │   │   ├── PostgresFtsIndexer.php       P1
│   │   │   └── TypesenseIndexer.php         P6
│   │   ├── Acl/                             ← adapter hệ ngoài (doc 02 §12.3)
│   │   └── CatalogServiceProvider.php
│   └── Presentation/
│       ├── Http/Api/V1/
│       │   ├── ProductController.php
│       │   ├── ProductResource.php
│       │   └── Request/PublishProductRequest.php
│       ├── Filament/Resources/ProductResource.php
│       └── Console/ReindexCatalogCommand.php
├── database/
│   ├── migrations/
│   └── factories/ProductFactory.php
├── routes/
│   └── api.php
└── tests/
    ├── Unit/            ← Domain, KHÔNG cần database, chạy trong mili-giây
    ├── Feature/         ← qua HTTP hoặc Application layer, có database
    └── Architecture/    ← rule riêng của module này

3.1 Mỗi tầng được chứa gì

Tầng Được chứa Tuyệt đối KHÔNG
Domain Entity, VO, enum, domain event, interface (Port), Policy, domain service, exception nghiệp vụ Illuminate\*, Eloquent, now(), auth(), config(), request(), SQL, HTTP, log, cache
Application Command/Query + Handler, DTO, Listener, điều phối transaction qua Port Eloquent, Request, Response, Blade, SQL thô
Infrastructure Eloquent model, Repository hiện thực, Mapper, HTTP client, ServiceProvider, adapter ACL, queue job Quy tắc nghiệp vụ (nếu có if về nghiệp vụ ở đây ⇒ nó thuộc Domain)
Presentation Controller, API Resource, FormRequest, Filament Resource, artisan command Truy vấn CSDL trực tiếp, quy tắc nghiệp vụ, gọi Eloquent

3.2 Khi nào KHÔNG dùng đủ 4 tầng

Hexagonal là chi phí, phải trả đúng chỗ (doc 02 §2):

Loại module Cấu trúc
CoreInventory, Order, Payment, Pricing, Promotion, Checkout 4 tầng đầy đủ
SupportingCatalog, Customer, Fulfilment, Returns, Loyalty, Tax, Procurement 4 tầng, nhưng Repository được phép trả Eloquent model cho truy vấn đọc
GenericCMS, Notification, Review Chỉ Infrastructure/ + Presentation/. Laravel thuần. Không Domain, không Application.

⚠ Viết Domain/ValueObject/BannerTitle.php là dấu hiệu bạn đang tiêu công sức sai chỗ.


4. Mẫu code từng tầng

Ví dụ xuyên suốt: xuất bản một sản phẩm (Catalog).

4.1 Domain — Entity

<?php
declare(strict_types=1);

namespace Modules\Catalog\Domain\Entity;

use Modules\Catalog\Domain\Event\ProductPublished;
use Modules\Catalog\Domain\Exception\CannotPublishProduct;
use Modules\Shared\Domain\DomainEvent;
use Modules\Shared\Domain\ProductId;

final class Product
{
    /** @var DomainEvent[] */
    private array $recordedEvents = [];

    /** @param Variant[] $variants */
    public function __construct(
        public readonly ProductId $id,
        public private(set) ProductStatus $status,
        public private(set) array $variants,
        private array $translations,                  // locale => ProductTranslation
        public private(set) ?\DateTimeImmutable $publishedAt = null,
    ) {}

    /**
     * ⚑ C2, C3 (doc 02 §4.2): chỉ xuất bản được khi có variant active
     * và có bản dịch locale mặc định.
     *
     * Thời gian được TRUYỀN VÀO, không gọi now() — nhờ vậy test không cần
     * thao tác đồng hồ hệ thống.
     */
    public function publish(\DateTimeImmutable $at, string $defaultLocale = 'vi'): void
    {
        if ($this->status === ProductStatus::Archived) {
            throw CannotPublishProduct::becauseArchived($this->id);
        }

        if ($this->activeVariants() === []) {
            throw CannotPublishProduct::becauseNoActiveVariant($this->id);   // ⚑ C2
        }

        if (!isset($this->translations[$defaultLocale])) {
            throw CannotPublishProduct::becauseMissingTranslation($this->id, $defaultLocale); // ⚑ C3
        }

        if ($this->status === ProductStatus::Active) {
            return;                          // idempotent: xuất bản lại không phát event lần hai
        }

        $this->status      = ProductStatus::Active;
        $this->publishedAt = $at;

        $this->recordedEvents[] = new ProductPublished(
            productId: $this->id,
            skus: array_map(static fn (Variant $v) => $v->sku->value, $this->activeVariants()),
            occurredAt: $at,
        );
    }

    /** @return Variant[] */
    public function activeVariants(): array
    {
        return array_values(array_filter(
            $this->variants,
            static fn (Variant $v) => $v->isActive(),
        ));
    }

    /** @return DomainEvent[] */
    public function releaseEvents(): array
    {
        $events = $this->recordedEvents;
        $this->recordedEvents = [];

        return $events;
    }
}

Ba điều đáng chú ý:

  1. public private(set) (PHP 8.4) — đọc công khai, sửa chỉ từ trong. Không cần getter mà bất biến vẫn kín.
  2. releaseEvents() — aggregate ghi nhận event, không phát event. Application layer ghi vào outbox trong cùng transaction (§5). Phát trực tiếp từ Domain thì rollback sẽ để lại event về chuyện chưa từng xảy ra.
  3. Idempotent — gọi publish() hai lần không phát event hai lần. Mọi thao tác có thể bị retry (queue, webhook) đều phải như vậy.

4.2 Domain — Value Object & Event

<?php
declare(strict_types=1);

namespace Modules\Catalog\Domain\ValueObject;

enum ProductStatus: string
{
    case Draft    = 'draft';
    case Active   = 'active';
    case Archived = 'archived';

    public function isSellable(): bool
    {
        return $this === self::Active;
    }
}
<?php
declare(strict_types=1);

namespace Modules\Catalog\Domain\Event;

use Modules\Shared\Domain\DomainEvent;
use Modules\Shared\Domain\ProductId;

// Thì quá khứ, readonly, KHÔNG chứa tham chiếu tới aggregate (doc 02 §1.6)
final readonly class ProductPublished implements DomainEvent
{
    /** @param string[] $skus */
    public function __construct(
        public ProductId $productId,
        public array $skus,
        public \DateTimeImmutable $occurredAt,
    ) {}

    public function eventType(): string    { return 'catalog.product.published'; }
    public function eventVersion(): int    { return 1; }
    public function aggregateType(): string { return 'product'; }
    public function aggregateId(): string  { return $this->productId->value; }

    public function payload(): array
    {
        return ['product_id' => $this->productId->value, 'skus' => $this->skus];
    }
}

4.3 Domain — Port

<?php
declare(strict_types=1);

namespace Modules\Catalog\Domain\Port;

use Modules\Catalog\Domain\Entity\Product;
use Modules\Shared\Domain\ProductId;
use Modules\Shared\Domain\Sku;

interface ProductRepository
{
    public function findById(ProductId $id): ?Product;
    public function findBySku(Sku $sku): ?Product;
    public function skuExists(Sku $sku): bool;      // ⚑ C1
    public function save(Product $product): void;
    public function nextIdentity(): ProductId;
}

Port nằm trong Domain, không nằm trong Infrastructure. Đây là toàn bộ ý nghĩa của "đảo ngược phụ thuộc": Domain khai báo nó cần gì, Infrastructure phục tùng. Đặt interface trong Infrastructure là làm ngược và mất hết lợi ích.

4.4 Application — Command & Handler

<?php
declare(strict_types=1);

namespace Modules\Catalog\Application\Command;

final readonly class PublishProduct
{
    public function __construct(
        public string $productId,
        public ?string $actorId = null,
    ) {}
}
<?php
declare(strict_types=1);

namespace Modules\Catalog\Application\Command;

use Modules\Catalog\Domain\Exception\ProductNotFound;
use Modules\Catalog\Domain\Port\ProductRepository;
use Modules\Shared\Application\Port\Clock;
use Modules\Shared\Application\Port\OutboxWriter;
use Modules\Shared\Application\Port\TransactionManager;
use Modules\Shared\Domain\ProductId;

final readonly class PublishProductHandler
{
    public function __construct(
        private ProductRepository $products,
        private OutboxWriter $outbox,
        private TransactionManager $tx,
        private Clock $clock,
    ) {}

    public function handle(PublishProduct $command): void
    {
        // ⚑ Một transaction = một aggregate (doc 02 §1.1)
        $this->tx->transactional(function () use ($command): void {
            $id      = ProductId::fromString($command->productId);
            $product = $this->products->findById($id) ?? throw ProductNotFound::withId($id);

            $product->publish($this->clock->now());

            $this->products->save($product);

            // ⚑ CÙNG transaction với việc lưu aggregate — đây là outbox pattern.
            // Không bao giờ có "sản phẩm đã publish nhưng search không biết".
            $this->outbox->append($product->releaseEvents());
        });
    }
}

Không dùng DB::transaction() hay now() trực tiếp ở đây. TransactionManagerClock là Port trong Shared/Application/Port/, hiện thực trong Infrastructure. Nhờ vậy handler test được không cần Laravel, và test có thể đóng băng thời gian bằng cách truyền FrozenClock.

4.5 Shared — ba Port bắt buộc

<?php
declare(strict_types=1);

namespace Modules\Shared\Application\Port;

interface Clock
{
    public function now(): \DateTimeImmutable;
}

interface TransactionManager
{
    /** @template T  @param callable():T $work  @return T */
    public function transactional(callable $work): mixed;
}

interface OutboxWriter
{
    /** @param \Modules\Shared\Domain\DomainEvent[] $events */
    public function append(array $events): void;
}

Hiện thực (Infrastructure của app, không của module):

<?php
declare(strict_types=1);

namespace App\Support\Adapters;

use Illuminate\Support\Facades\DB;
use Modules\Shared\Application\Port\OutboxWriter;
use Modules\Shared\Domain\DomainEvent;

final class DatabaseOutboxWriter implements OutboxWriter
{
    public function append(array $events): void
    {
        if ($events === []) {
            return;
        }

        $rows = array_map(static fn (DomainEvent $e) => [
            'aggregate_type' => $e->aggregateType(),
            'aggregate_id'   => $e->aggregateId(),
            'event_type'     => $e->eventType(),
            'event_version'  => $e->eventVersion(),
            'payload'        => json_encode($e->payload(), JSON_THROW_ON_ERROR),
            // traceparent để nối OTel xuyên queue (doc 01 §14.2, doc 03 §14)
            'headers'        => json_encode(['traceparent' => Trace::currentParent()], JSON_THROW_ON_ERROR),
        ], $events);

        DB::table('outbox_messages')->insert($rows);
    }
}

4.6 Infrastructure — Repository + Mapper

<?php
declare(strict_types=1);

namespace Modules\Catalog\Infrastructure\Persistence\Eloquent;

use Modules\Catalog\Domain\Entity\Product;
use Modules\Catalog\Domain\Port\ProductRepository;
use Modules\Shared\Domain\ProductId;
use Modules\Shared\Domain\Sku;

final readonly class EloquentProductRepository implements ProductRepository
{
    public function __construct(private ProductMapper $mapper) {}

    public function findById(ProductId $id): ?Product
    {
        $model = ProductModel::query()
            ->with(['variants', 'translations'])
            ->where('public_id', $id->value)
            ->first();

        return $model === null ? null : $this->mapper->toDomain($model);
    }

    public function skuExists(Sku $sku): bool
    {
        return VariantModel::query()->where('sku', $sku->value)->exists();
    }

    public function save(Product $product): void
    {
        $model = ProductModel::query()
            ->where('public_id', $product->id->value)
            ->firstOrNew(['public_id' => $product->id->value]);

        $this->mapper->applyToModel($product, $model);
        $model->save();
    }

    public function nextIdentity(): ProductId
    {
        return ProductId::generate();
    }
}

ProductMapper là chi phí thật của hexagonal. Nó là lý do ta chỉ áp dụng đầy đủ cho module Core (§3.2). Với module Supporting, cho phép Repository trả Eloquent model trực tiếp cho đường đọc và chỉ map cho đường ghi — cân bằng hợp lý giữa thuần khiết và tốc độ làm việc.

4.7 Infrastructure — ServiceProvider

<?php
declare(strict_types=1);

namespace Modules\Catalog\Infrastructure;

use Illuminate\Support\ServiceProvider;
use Modules\Catalog\Domain\Port\ProductRepository;
use Modules\Catalog\Domain\Port\SearchIndexer;
use Modules\Catalog\Infrastructure\Persistence\Eloquent\EloquentProductRepository;
use Modules\Catalog\Infrastructure\Search\PostgresFtsIndexer;

final class CatalogServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // Nối Port (Domain) với Adapter (Infrastructure)
        $this->app->bind(ProductRepository::class, EloquentProductRepository::class);
        $this->app->bind(SearchIndexer::class, PostgresFtsIndexer::class);   // P6: đổi thành Typesense
    }

    public function boot(): void
    {
        $this->loadMigrationsFrom(__DIR__ . '/../../database/migrations');
        $this->loadRoutesFrom(__DIR__ . '/../../routes/api.php');
        $this->loadTranslationsFrom(__DIR__ . '/../../lang', 'catalog');
    }
}

Bẫy Octane (doc 01 §9.1): register() chỉ được bind, tuyệt đối không tạo instance có state, không nhận Request. boot() không được đăng ký macro/directive theo điều kiện — dưới worker mode nó sẽ chạy lại và đăng ký trùng.

4.8 Presentation — Controller

<?php
declare(strict_types=1);

namespace Modules\Catalog\Presentation\Http\Api\V1;

use Illuminate\Http\JsonResponse;
use Modules\Catalog\Application\Command\PublishProduct;
use Modules\Catalog\Application\Command\PublishProductHandler;

final readonly class PublishProductController
{
    public function __construct(private PublishProductHandler $handler) {}

    public function __invoke(string $productId): JsonResponse
    {
        // Controller chỉ làm 3 việc: dịch HTTP → Command, gọi handler, dịch kết quả → HTTP.
        // Không có một dòng nghiệp vụ nào.
        $this->handler->handle(new PublishProduct(
            productId: $productId,
            actorId: (string) auth()->id(),
        ));

        return new JsonResponse(status: 204);
    }
}

Nếu controller dài hơn ~15 dòng, gần như chắc chắn có logic đang nằm sai chỗ.


5. Mẫu chuẩn: transaction + outbox

Đây là mẫu quan trọng nhất trong toàn dự án. Mọi handler thay đổi trạng thái đều theo đúng hình này:

┌─ tx->transactional() ────────────────────────────────┐
│  1. Nạp aggregate qua Repository (Port)              │
│  2. Gọi phương thức nghiệp vụ trên aggregate         │
│     └─ aggregate tự kiểm bất biến, tự ghi nhận event │
│  3. Repository->save(aggregate)                      │
│  4. Outbox->append(aggregate->releaseEvents())       │
└──────────────────────────────────────────────────────┘
                        │ COMMIT
                        ▼
        Outbox relay worker (doc 03 §14.1) đọc và phát đi
                        │
        ┌───────────────┴────────────────┐
        ▼                                ▼
   Listener của module khác        Search reindex, email…

Bốn điều mẫu này bảo đảm:

  1. Không bao giờ có event về một chuyện chưa commit.
  2. Không bao giờ có chuyện đã commit mà event bị mất.
  3. Chỉ đúng một aggregate bị sửa trong một transaction (⚑ doc 02 §1.1).
  4. Đổi hạ tầng phát event (queue → Kafka ở P7) không sửa một dòng nào ở Domain/Application.

Chống mẫu tương ứng — tuyệt đối không làm:

// ✗ SAI: event phát ra ngoài transaction, rollback là mất
$product->publish($now);
$this->products->save($product);
event(new ProductPublished(...));          // ⛔

// ✗ SAI: hai aggregate trong một transaction
DB::transaction(function () {
    $order->confirm($now);   $this->orders->save($order);
    $stock->deduct(1);       $this->stocks->save($stock);   // ⛔ deadlock chờ sẵn
});

// ✗ SAI: dispatch job trong transaction — job có thể chạy TRƯỚC khi commit
DB::transaction(function () use ($order) {
    $this->orders->save($order);
    SendConfirmationEmail::dispatch($order->id);            // ⛔
});

6. Cưỡng chế ranh giới

6.1 deptrac.yaml

deptrac:
  paths:
    - ./modules
    - ./app

  layers:
    - name: Domain
      collectors:
        - type: directory
          value: modules/[A-Za-z]+/src/Domain/.*

    - name: Application
      collectors:
        - type: directory
          value: modules/[A-Za-z]+/src/Application/.*

    - name: Infrastructure
      collectors:
        - type: directory
          value: modules/[A-Za-z]+/src/Infrastructure/.*

    - name: Presentation
      collectors:
        - type: directory
          value: modules/[A-Za-z]+/src/Presentation/.*

    - name: Shared
      collectors:
        - type: directory
          value: modules/Shared/src/.*

    - name: AppShell
      collectors:
        - type: directory
          value: app/.*

    - name: Vendor
      collectors:
        - type: classLike
          value: ^(Illuminate|Symfony|Spatie|Filament|Livewire)\\.*

  ruleset:
    Domain:         [Shared]                                    # ⛔ Domain KHÔNG thấy Vendor
    Application:    [Domain, Shared]                            # ⛔ Application KHÔNG thấy Vendor
    Infrastructure: [Domain, Application, Shared, Vendor]
    Presentation:   [Application, Shared, Vendor]
    Shared:         []
    AppShell:       [Application, Shared, Vendor]
    Vendor:         ~

Về tên collector: deptrac 2.x/3.x dùng classLike; bản 1.x dùng className. Kiểm bằng vendor/bin/deptrac analyse — nếu báo "unknown collector type" thì đổi sang tên còn lại.

⚠ Cấu hình trên chưa chặn được module A gọi module B (cả hai đều là layer Domain). Việc đó do hai thứ khác lo: khối require trong composer.json của module (§2.2) và arch test số 3 (§6.2). Ba tầng cưỡng chế cho một quy tắc — vì đây là quy tắc bị vi phạm nhiều nhất khi làm gấp.

6.2 Pest architecture test

tests/Architecture/BoundaryTest.php:

<?php
declare(strict_types=1);

// 1. Domain thuần PHP
arch('Domain không phụ thuộc framework')
    ->expect('Modules')
    ->toUseNothing()
    ->ignoring(['Modules\Shared'])
    ->group('arch');

arch('Domain không import Illuminate/Symfony')
    ->expect(['Illuminate', 'Symfony'])
    ->not->toBeUsedIn('Modules\Catalog\Domain');

// 2. Eloquent bị giam trong Infrastructure
arch('Eloquent chỉ dùng trong Infrastructure\Persistence')
    ->expect('Illuminate\Database\Eloquent\Model')
    ->toOnlyBeUsedIn('Modules\Catalog\Infrastructure\Persistence');

// 3. Module không gọi chéo nhau
arch('Order không chạm nội tại của module khác')
    ->expect('Modules\Order')
    ->not->toUse([
        'Modules\Catalog\Infrastructure',
        'Modules\Catalog\Domain',
        'Modules\Inventory\Infrastructure',
        'Modules\Inventory\Domain',
    ]);

// 4. Value Object bất biến
arch('Value Object phải readonly')
    ->expect('Modules\Catalog\Domain\ValueObject')
    ->toBeReadonly();

// 5. Domain event bất biến và đúng thì
arch('Domain event phải readonly')
    ->expect('Modules\Catalog\Domain\Event')
    ->toBeReadonly()
    ->toImplement('Modules\Shared\Domain\DomainEvent');

// 6. Port là interface
arch('Port phải là interface')
    ->expect('Modules\Catalog\Domain\Port')
    ->toBeInterfaces();

// 7. Handler không nhận Request
arch('Application không biết HTTP')
    ->expect(['Illuminate\Http\Request', 'Illuminate\Http\Response'])
    ->not->toBeUsedIn('Modules\Catalog\Application');

// 8. app/ không chứa nghiệp vụ (⚑ §1)
arch('app/ chỉ là vỏ')
    ->expect('App')
    ->toOnlyBeUsedIn(['App', 'Modules'])
    ->ignoring('App\Support');

// 9. Không có công cụ debug lọt vào
arch('không dd/dump/ray trong production code')
    ->expect(['dd', 'dump', 'var_dump', 'print_r', 'ray', 'die'])
    ->not->toBeUsed();

// 10. strict_types khắp nơi
arch('mọi file khai báo strict_types')
    ->expect('Modules')
    ->toUseStrictTypes();

// 11. Preset Laravel có sẵn của Pest
arch()->preset()->laravel();
arch()->preset()->security();

⚠ Test 1 (toUseNothing()) rất nghiêm — có thể phải nới bằng ->ignoring() cho DateTimeImmutable, JsonSerializable, các exception SPL. Cứ chạy rồi nới dần theo lỗi thật, đừng nới trước.

6.3 Kiểm tra ranh giới đang thật sự hoạt động

Sau khi cấu hình, cố ý vi phạm một lần để chắc chắn lưới có lỗ hay không:

// modules/Catalog/src/Domain/Entity/Product.php — thêm tạm dòng này
use Illuminate\Support\Facades\DB;    // phải làm CI ĐỎ
composer check     # kỳ vọng: deptrac + arch test đều báo lỗi

Nếu CI vẫn xanh, cấu hình sai — sửa trước khi viết dòng nghiệp vụ đầu tiên. Một lưới an toàn chưa từng được thử là một lưới không tồn tại.


7. Tạo module mới — 12 bước

$M = "Promotion"      # PascalCase
$m = "promotion"      # kebab/lowercase
# Việc
1 Xác nhận nó thực sự là bounded context mới (xem §10 FAQ) — không phải chỉ là một bảng mới
2 Tạo cây thư mục theo §3 (chỉ các tầng cần thiết theo §3.2)
3 Viết modules/$M/composer.json (§2.2), khai require tối thiểu
4 Thêm "modules/$m": "*" vào require của composer.json gốc
5 composer update modules/$m — kiểm tra vendor/modules/$msymlink
6 Viết Infrastructure/${M}ServiceProvider.php, khai trong extra.laravel.providers
7 Viết Domain trước: entity + VO + bất biến từ doc 02, kèm test unit trước khi viết Infrastructure
8 Viết Port, rồi mới viết adapter
9 Migration đặt trong modules/$M/database/migrations/, DDL lấy từ doc 03
10 Cập nhật doc 03 §17 (bảng "ai sở hữu bảng nào")
11 Thêm arch test riêng của module vào modules/$M/tests/Architecture/
12 composer check xanh → mở PR

Bước 7 không được đảo. Viết Eloquent model trước rồi "suy ra" Domain là cách chắc chắn nhất để có một Domain layer chỉ là lớp vỏ vô nghĩa quanh database schema.


8. Giao tiếp chéo module

Chỉ có hai đường hợp lệ. Mọi đường khác là vi phạm.

8.1 Đồng bộ — qua contract package

Khi Checkout cần giá từ Pricing, nó không được biết Pricing tồn tại như thế nào.

modules/PricingContract/                    ← package thứ 3, cực mỏng
├── composer.json         (require: modules/shared)
└── src/
    ├── PriceResolver.php                   interface
    └── ResolvedPrice.php                   readonly DTO
// modules/PricingContract/src/PriceResolver.php
namespace Modules\PricingContract;

interface PriceResolver
{
    public function resolve(
        string $variantId,
        ?string $customerId,
        int $quantity,
        \DateTimeImmutable $at,
    ): ResolvedPrice;
}
  • Checkout/composer.json require modules/pricing-contract (không require modules/pricing).
  • Pricing require modules/pricing-contract và hiện thực interface đó.
  • PricingServiceProvider bind PriceResolver::class → hiện thực của mình.

Nhờ đó Checkout biên dịch được mà không cần Pricing tồn tại — đó chính là tiêu chí đo được cho "đã tách rời". Và khi tách Pricing thành service riêng ở P8, chỉ cần thay adapter bằng HTTP client sau contract đó.

⚠ Chỉ tạo contract package khi thật sự có module khác cần gọi. Đừng tạo sẵn 20 contract package cho 20 module.

8.2 Bất đồng bộ — qua domain event

<?php
declare(strict_types=1);

namespace Modules\Catalog\Application\Listener;

use Modules\Catalog\Domain\Port\SearchIndexer;
use Modules\Shared\Domain\ProductId;

// Nghe event của module khác qua PAYLOAD, không nạp lại aggregate của họ
final readonly class ReindexOnPriceChanged
{
    public function __construct(private SearchIndexer $indexer) {}

    /** @param array{variant_id: string, new_price: array} $payload */
    public function handle(array $payload): void
    {
        $this->indexer->reindexByVariant($payload['variant_id']);
    }
}

⚑ Listener không được gọi PricingRepository để "lấy thêm thông tin". Payload phải đủ (doc 02 §20.2). Nếu payload thiếu, sửa payload — đừng gọi ngược lại.

8.3 Bảng tra nhanh

Tôi muốn… Cách đúng Cách sai
Lấy giá để tính đơn PriceResolver (contract) PriceRule::where(...)
Biết còn hàng không StockChecker (contract) StockLevel::find(...)
Phản ứng khi đơn được thanh toán Nghe payment.captured Gọi PaymentService mỗi 5 giây
Đọc tên sản phẩm để hiển thị đơn cũ Đọc snapshot trong order_items Join sang products
Báo cáo cần dữ liệu nhiều module Read model (doc 02 §21) Join 8 bảng xuyên module

9. Kiểm thử & chất lượng

9.1 Đặt test ở đâu

Loại Vị trí Cần DB? Thời gian mục tiêu
Domain (bất biến, máy trạng thái) modules/$M/tests/Unit/ < 1 ms/test
Application (use case) modules/$M/tests/Feature/ ✅ PostgreSQL thật < 50 ms/test
HTTP/API modules/$M/tests/Feature/Http/ < 100 ms/test
Architecture modules/$M/tests/Architecture/
Đồng thời (⚑ I3) tests/Concurrency/

Không dùng SQLite in-memory (doc 01 §13.1). Lược đồ ở doc 03 dùng jsonb, generated column, index bộ phận, EXCLUDE, CONSTRAINT TRIGGER DEFERRABLE, FOR UPDATE SKIP LOCKED — SQLite không có thứ nào trong số đó. Test xanh trên SQLite rồi vỡ trên production là kịch bản kinh điển.

9.2 Đặt tên test

Tên test viết bằng tiếng Việt, mô tả quy tắc nghiệp vụ, không mô tả code:

// ✓ ĐÚNG — người không đọc code vẫn hiểu
it('không cho xuất bản sản phẩm chưa có biến thể nào đang bán', ...);   // ⚑ C2
it('không cho giữ chỗ vượt quá số khả dụng', ...);                      // ⚑ I3
it('từ chối commit khi bút toán lệch dù chỉ 1 đồng', ...);              // ⚑ Y1

// ✗ SAI — mô tả code, vô giá trị khi test đỏ
it('test publish method', ...);
it('it works', ...);

Test cho bất biến phải ghi mã ⚑ trong tên hoặc comment, để khi test đỏ ta biết ngay bất biến nào của doc 02 §23 vừa bị phá.

9.3 phpstan.neon

includes:
    - vendor/larastan/larastan/extension.neon

parameters:
    level: 6            # P0 khởi điểm; tăng 1 level mỗi sprint đến 10
    paths:
        - app
        - modules
    excludePaths:
        - modules/*/tests/*
    baseline: phpstan-baseline.neon

    # Domain layer phải sạch ngay từ đầu, KHÔNG dùng baseline
    # (cấu hình riêng ở phpstan-domain.neon, chạy level 10)

⚠ Baseline chỉ dùng cho code có sẵn. Code mới tuyệt đối không được thêm dòng nào vào baseline — kiểm bằng cách CI so phpstan-baseline.neon với bản trên main, dài ra thì báo đỏ.

Chạy riêng Domain ở level cao nhất ngay từ P0 (nó thuần PHP nên đạt level 10 rất dễ):

# phpstan-domain.neon
parameters:
    level: 10
    paths:
        - modules/*/src/Domain


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí