<? phpukraine PHP-ПЛАТФОРМА
⌕Пошук по платформі
ПАТЕРН ЗАСТОСУНКУ

Сервісний шарService Layer

Кожен сценарій застосунку живе в окремому сервісі між контролером і доменом. Контролер перекладає HTTP на виклик сервісу, а сервіс по черзі перевіряє, зберігає й повідомляє.

Laravel Symfony складність 1 з 3 6 хв читання
ГЛІФ
01 · ПРОБЛЕМА

Відгук на вакансію живе в контролері

Кандидат відгукується на вакансію з сайту, а тепер ще й з Telegram-бота. Перевірки, збереження й лист роботодавцю написані в контролері, тож для бота їх скопіювали. За місяць у боті вже можна відгукнутися на закриту вакансію двічі.

app/Http/Controllers/ApplicationController.php
1final class ApplicationController
2{
3 public function store(Request $request, Job $job): RedirectResponse
4 {
5 $request->validate(['cv' => ['required', 'file', 'mimes:pdf']]);
6
7 if ($job->status !== 'published') {
1Правила сценарію записані як перевірки HTTP-відповіді. У боті ту саму перевірку довелось писати заново.
8 return back()->withErrors('Вакансію закрито');
9 }
10
11 if ($job->applications()->where('user_id', $request->user()->id)->exists()) {
2Перевірку на повторний відгук у боті забули, і роботодавці отримують по два листи.
12 return back()->withErrors('Ви вже відгукнулись');
13 }
14
15 $application = $job->applications()->create([
16 'user_id' => $request->user()->id,
17 'cv_path' => $request->file('cv')->store('cvs'),
18 ]);
19
20 Mail::to($job->company->email)->send(new NewApplicationMail($application));
3Щоб перевірити сценарій, тест мусить надіслати HTTP-запит з файлом, хоча правила від HTTP не залежать.
21
22 return back()->with('status', 'Відгук надіслано');
23 }
24}
02 · ІДЕЯ РІШЕННЯ

Один сценарій, один сервіс

ДІАГРАМА · УСЯ СХЕМА
Кроки: 4
ControllerTelegramBotсервіс застосункуApplyToJob__invoke(jobId, candidateId, cv)доменJobaccept(Candidate)«interface»Applicationssave()Mailer
ControllerTelegramBotсервіс застосункуApplyToJob__invoke(jobId, candidateId, cv)доменJobaccept(Candidate)«interface»Applicationssave()Mailer
Уся схема. Натисніть → або виберіть крок нижче, щоб пройти по ній частинами.
Абстракція Ключовий учасник Клієнт реалізує · - - ▷
  • Controller використовує ApplyToJob.
  • TelegramBot використовує ApplyToJob.
  • ApplyToJob використовує Job.
  • ApplyToJob використовує Applications.
  • ApplyToJob використовує Mailer.
03 · КОД

Запустіть і подивіться

PHP 8.5
1final class ApplicationController
2{
3 public function store(Request $request, Job $job): RedirectResponse
4 {
5 $request->validate(['cv' => ['required', 'file', 'mimes:pdf']]);
6
7 if ($job->status !== 'published') {
8 return back()->withErrors('Вакансію закрито');
9 }
10
11 if ($job->applications()->where('user_id', $request->user()->id)->exists()) {
12 return back()->withErrors('Ви вже відгукнулись');
13 }
14
15 $application = $job->applications()->create([
16 'user_id' => $request->user()->id,
17 'cv_path' => $request->file('cv')->store('cvs'),
18 ]);
19
20 Mail::to($job->company->email)->send(new NewApplicationMail($application));
21
22 return back()->with('status', 'Відгук надіслано');
23 }
24}
1<?php
2
3final class Job
4{
5 /** @var list<int> */
6 private array $candidates = [];
7
8 public function __construct(public readonly int $id, public readonly string $title, private bool $open = true) {}
9
101 public function accept(int $candidateId): void
11 {
12 if (! $this->open) {
13 throw new DomainException('вакансію закрито');
14 }
15 if (in_array($candidateId, $this->candidates, true)) {
16 throw new DomainException('ви вже відгукнулись');
17 }
18 $this->candidates[] = $candidateId;
19 }
20}
21
22final class InMemoryJobs
23{
24 /** @param array<int, Job> $jobs */
25 public function __construct(private array $jobs) {}
26
27 public function get(int $id): Job { return $this->jobs[$id]; }
28 public function save(Job $job): void { $this->jobs[$job->id] = $job; }
29}
30
312final class ApplyToJob
32{
33 public function __construct(private InMemoryJobs $jobs, private Closure $notify) {}
34
35 public function __invoke(int $jobId, int $candidateId, string $cv): void
36 {
37 $job = $this->jobs->get($jobId);
38 $job->accept($candidateId);
39 $this->jobs->save($job);
40 ($this->notify)("новий відгук на «{$job->title}», резюме {$cv}");
41 }
42}
43
443$apply = new ApplyToJob(
45 new InMemoryJobs([7 => new Job(7, 'Middle Laravel Developer'), 8 => new Job(8, 'PHP Team Lead', open: false)]),
46 fn (string $text) => print " лист роботодавцю: {$text}\n",
47);
48
49$requests = [
50 ['сайт', 7, 101, 'olena.pdf'],
51 ['Telegram-бот', 7, 101, 'olena.pdf'],
52 ['Telegram-бот', 8, 102, 'taras.pdf'],
53];
54
554foreach ($requests as [$channel, $jobId, $candidateId, $cv]) {
56 try {
57 $apply($jobId, $candidateId, $cv);
58 echo "{$channel}: відгук надіслано\n";
59 } catch (DomainException $e) {
60 echo "{$channel}: відмова, {$e->getMessage()}\n";
61 }
62}
Перший запуск завантажить рушій PHP 8.5, приблизно 3,8 МБ Відкрити в пісочниці ↗
ВИВІД
Завантажуємо рушій PHP 8.5
Лише перший раз. Далі код запускається миттєво, рушій лишається в кеші браузера.
$ php main.php ▍
  1. 1 рядки 10–19

    Правила живуть у домені: Job сам вирішує, чи можна на нього відгукнутися. Сервіс їх не дублює.

  2. 2 рядки 31–42

    Сервіс робить кроки сценарію по черзі: взяти вакансію, перевірити, зберегти, повідомити. Про HTTP і Telegram він не знає.

  3. 3 рядки 44–47

    Залежності приходять ззовні. У Laravel тут буде репозиторій на Eloquent і Mail, у тестах фейки.

  4. 4 рядки 55–62

    Сайт і бот викликають той самий сервіс, тому повторний відгук відхиляється в обох однаково.

  5. Наведіть на пояснення, щоб підсвітити рядки.Торкніться пояснення, щоб підсвітити рядки.
04 · У ВАШОМУ ФРЕЙМВОРКУ

Ви вже цим користуєтесь

LARAVEL

Класи Actions

Окремого класу для сервісного шару Laravel не дає. Прийнято класи в app/Actions з одним публічним методом, як CreateNewUser у Fortify. Контролер отримує дію через впровадження в метод і лише перекладає HTTP.

final class ApplicationController
{
    public function store(ApplyRequest $request, Job $job, ApplyToJob $apply): RedirectResponse
    {
        $apply($job->id, $request->user()->id, $request->file('cv')->store('cvs'));
 
        return back()->with('status', 'Відгук надіслано');
    }
}

Ви вже користуєтесь цим, коли викликаєте ту саму дію з контролера, Artisan-команди чи job, а правила лишаються в одному класі.

SYMFONY

Сервіси застосунку

У Symfony кожен клас у src/ уже сервіс з автовайрингом. Сервіс застосунку приходить аргументом дії контролера, а дані запиту можна одразу перетворити на DTO з валідацією через #[MapRequestPayload].

#[Route('/jobs/{id}/apply', methods: ['POST'])]
public function apply(int $id, #[MapRequestPayload] ApplyInput $input, ApplyToJob $apply): JsonResponse
{
    $apply($id, $input->candidateId, $input->cv);
 
    return $this->json(['status' => 'ok'], 201);
}

Ви вже користуєтесь цим, коли контролер, консольна команда й обробник Messenger отримують той самий ApplyToJob через автовайринг.

✓ ЯК ЦЕ ТЕСТУВАТИ

Сервіс тестуйте без HTTP і без бази: з in-memory репозиторієм і фейком пошти. Так кожне правило сценарію перевіряється за мілісекунди, а для контролера лишається один тест на маршрут.

it('rejects a second application from the same candidate', function () {
    $apply = new ApplyToJob(new InMemoryJobs([7 => new Job(7, 'PHP Developer')]), fn () => null);
 
    $apply(7, candidateId: 101, cv: 'cv.pdf');
 
    expect(fn () => $apply(7, candidateId: 101, cv: 'cv.pdf'))
        ->toThrow(DomainException::class, 'вже відгукнулись');
});
05 · КОЛИ НЕ ТРЕБА

CRUD без сценарію

Якщо дія це один create() без правил, як у довіднику міст в адмінці, сервіс лише переказує виклик моделі. Сервісний шар окупається, коли в сценарії є перевірки, кілька кроків або кілька точок входу.

// сервіс, який нічого не додає
public function create(array $data): City
{
    return City::create($data);
}
06 · СУСІДИ

Схожі на вигляд, різні за змістом

07 · ПЕРЕВІР СЕБЕ

Питання зі співбесід

Усі питання з архітектури →

Переклад HTTP: прочитати й провалідувати запит, викликати сервіс, перетворити результат чи виняток на відповідь. Правил сценарію в контролері більше немає.

Сервіс координує кроки: дістає обʼєкти, викликає їхні методи, зберігає, повідомляє. Правила, як «на закриту вакансію не відгукуються», живуть у домені. Якщо всі правила переїхали в сервіси, домен стає набором полів без поведінки.

Транзакцію відкриває сервіс, бо саме він знає межі сценарію. Лист чи подію надсилають після коміту, інакше роботодавець отримає лист про відгук, який відкотився. У Laravel для цього є DB::afterCommit() і черги з afterCommit.