Кожен проєкт із шарами має одну й ту саму проблему: домовленість «домен не залежить від фреймворку» живе в голові автора і в README. Через три місяці новий колега імпортує Illuminate\Support\Str у value object, бо так зручніше, і ніхто не помічає. Архітектурні тести перетворюють домовленість на червоний CI. У цій статті реальний набір із phpukraine: чотири тести, які тримають межі модульного моноліту на Laravel.
Що саме ми забороняємо
phpukraine побудований як модульний моноліт: src/Hiring, src/Content, src/ContentOps, src/Seo, кожен з шарами Domain, Application, Infrastructure, плюс src/Shared для спільних дрібниць на кшталт відмінювання числівників і годинника. Laravel живе в app/: контролери, Livewire, провайдери, Filament.
Правила прості:
- Domain не знає про Laravel. Жодного
Illuminate, жодногоApp\. АгрегатJobабо value objectSalaryмають працювати в чистому PHP. - Application не знає про Infrastructure. Use case залежить від порту
JobRepository, а не відEloquentJobRepository. - Веб-шар не лізе в Infrastructure напряму. Контролер отримує інтерфейс через контейнер і не знає, що за ним Eloquent.
- Shared не знає нікого. Спільне ядро не залежить ні від фреймворку, ні від контекстів.
Це і є текст тестів, майже дослівно.
Тести
Pest має вбудовані архітектурні тести з Pest 2, і вони читаються як речення:
arch('domain layers stay framework-free')
->expect(['PhpUkraine\Hiring\Domain', 'PhpUkraine\ContentOps\Domain', 'PhpUkraine\Seo\Domain'])
->not->toUse(['Illuminate', 'Livewire', 'App']);
arch('shared kernel stays framework-free')
->expect('PhpUkraine\Shared')
->not->toUse(['Illuminate', 'Livewire', 'App', 'PhpUkraine\Hiring', 'PhpUkraine\ContentOps']);
arch('application layers do not reach into infrastructure or the web layer')
->expect(['PhpUkraine\Hiring\Application', 'PhpUkraine\ContentOps\Application'])
->not->toUse(['PhpUkraine\Hiring\Infrastructure', 'PhpUkraine\ContentOps\Infrastructure', 'App\Http', 'App\Livewire', 'Livewire']);
arch('the web layer never touches infrastructure directly')
->expect(['App\Http', 'App\Livewire'])
->not->toUse(['PhpUkraine\Hiring\Infrastructure', 'PhpUkraine\ContentOps\Infrastructure']);
Плюс два дрібних, які економлять нерви на review:
arch('domain code uses strict types')
->expect('PhpUkraine')
->toUseStrictTypes();
arch('no debugging leftovers')
->expect(['dd', 'dump', 'ray', 'var_dump'])
->not->toBeUsed();
Шість тестів виконуються за секунду і запускаються разом із рештою сьюту. Коли хтось додає use Illuminate\Support\Facades\DB у доменний клас, CI каже точно, який файл і яке правило.
Що ці тести змусили змінити
Найцінніше в архітектурних тестах не те, що вони ловлять, а те, як вони змінюють дизайн. Кілька прикладів із проєкту.
Годинник. Домен рахує, чи вакансія прострочена. Спокуса написати now() або Carbon::now() величезна, але обидва варіанти тягнуть Laravel у домен. Тест падає, і зʼявляється інтерфейс Clock у Shared з двома реалізаціями: SystemClock для продакшену і FrozenClock для тестів. Побічний ефект: тести на життєвий цикл вакансії стали детермінованими, без Carbon::setTestNow.
Множина. «5 вакансій», «2 вакансії», «1 вакансія». Перша версія хелпера жила в app/Support і використовувала Str. Домен теж хотів формувати такі рядки для SEO-пояснень. Результат: Shared\Text\Plural на чистому PHP, який використовують і домен, і Blade.
Переписування вакансій моделлю. Use case RewriteFlow в ContentOps\Application спершу викликав Process із Symfony напряму. Це не Laravel, тест не падав, але це інфраструктура в application-шарі. Ми винесли виклик claude -p за порт JobRewriter, і в тестах use case працює з FakeJobRewriter за мілісекунди.
Filament як свідомий виняток. Адмінка працює з Eloquent-моделями напряму, і це порушує правило «веб-шар не торкається інфраструктури». Замість того, щоб загортати кожен ресурс у репозиторії, ми записали виняток у ARCHITECTURE.md і не включили App\Filament у тест. Архітектурний тест має описувати реальні правила, а не ідеальні.
Чого тести не ловлять
Варто чесно сказати про межі.
- Семантику залежностей. Тест бачить
use, але не бачить, що use case зробив три запити до бази через порт, який мав би бути одним. Це ловлять лише unit-тести з фейками. - Анемічну модель. Домен може бути вільним від Laravel і водночас бути мішком геттерів. Тест на це не скаржиться.
- Циклічні залежності між контекстами.
Hiringможе використатиContent, аContent—Hiring, і жоден із тестів вище цього не помітить. Для цього потрібне окреме правило на кожну пару контекстів, і його варто додати, щойно зʼявиться перша спокуса.
Як почати у своєму проєкті
- Встановіть Pest 2 або новіший; архітектурні тести вбудовані, плагінів не треба.
- Напишіть одне правило:
expect('App\Domain')->not->toUse('Illuminate'). Воно впаде. Список файлів, які його порушують, і є вашим планом рефакторингу. - Не ставте правило, якого не готові дотримуватись. Краще три чесних тести й один записаний виняток, ніж десять правил, які всі обходять через
@skip. - Додавайте правило щоразу, коли на review вдруге виникає одна й та сама суперечка. Тест закінчує суперечку назавжди.
Усі шість тестів наведені вище повністю, їх можна скопіювати у свій проєкт як є. Про те, коли шари взагалі виправдані, є окрема картка: Коли DDD виправданий, а коли шкодить.