Bài 15 — Service Container và Dependency Injection: tách nguồn thời gian khỏi quy tắc task quá hạn. Tiếp nối bài 14, chúng ta bắt đầu phần tổ chức ứng dụng. Mục tiêu không phải tạo interface cho mọi class, mà là thay một dependency có ý nghĩa trong kiểm thử.
1. Vì sao đồng hồ là dependency?
Quy tắc TaskFlow: task chưa done, có due_at và due_at nhỏ hơn thời điểm hiện tại thì quá hạn. Đúng bằng thời điểm hạn chưa được tính quá hạn trong contract này. Nếu service gọi thời gian thật trực tiếp, test biên dễ phụ thuộc thời điểm chạy. Ta truyền một Clock vào service để test lựa chọn thời gian rõ ràng.
Dependency Injection nghĩa là đối tượng nhận thứ nó cần từ bên ngoài, thay vì tự tạo mọi dependency. Service Container giúp ghép các đối tượng đó. Bạn vẫn có thể new TaskDeadline($clock) bằng PHP thường; DI không chỉ tồn tại khi dùng framework.
2. Tạo contract, implementation và service
Tạo ba file sau. Giữ cast immutable_datetime của Task từ bài 9; cấu hình timezone ứng dụng trong ví dụ là UTC.
app/Contracts/Clock.php
<?php
namespace App\Contracts;
use Carbon\CarbonImmutable;
interface Clock
{
public function now(): CarbonImmutable;
}app/Support/SystemClock.php
<?php
namespace App\Support;
use App\Contracts\Clock;
use Carbon\CarbonImmutable;
class SystemClock implements Clock
{
public function now(): CarbonImmutable
{
return CarbonImmutable::now('UTC');
}
}app/Services/TaskDeadline.php
<?php
namespace App\Services;
use App\Contracts\Clock;
use App\Models\Task;
class TaskDeadline
{
public function __construct(private readonly Clock $clock) {}
public function isOverdue(Task $task): bool
{
return $task->status !== 'done'
&& $task->due_at !== null
&& $task->due_at->lessThan($this->clock->now());
}
}Clock chỉ hứa trả một CarbonImmutable. SystemClock đọc thời gian UTC mỗi lần now() được gọi, không chụp thời gian một lần trong constructor. TaskDeadline chỉ áp dụng quy tắc, không quan tâm đồng hồ hệ thống hay đồng hồ cố định trong test. Service không ghi database, không gửi notification và không kiểm tra quyền người dùng.
3. Đăng ký interface binding
Trong app/Providers/AppServiceProvider.php, thêm hai import và thay nội dung register() như sau; giữ boot() hiện có:
use App\Contracts\Clock;
use App\Support\SystemClock;
public function register(): void
{
$this->app->bind(Clock::class, SystemClock::class);
}
Provider này đã nằm trong bootstrap/providers.php của skeleton. Container có thể dựng class concrete TaskDeadline, nhưng interface Clock cần biết implementation nào được chọn. Bỏ binding sẽ gây lỗi resolve; không chữa bằng cách gọi new SystemClock sâu trong service vì như vậy bỏ mất điểm thay thế đang thiết kế.
4. Resolve ở ranh giới, inject trong nghiệp vụ
$service = app(\App\Services\TaskDeadline::class);
$overdue = $service->isOverdue($task);
Lời gọi app() ở đây minh họa container và được dùng trong test. Khi nối controller, có thể type-hint TaskDeadline trong constructor hoặc method được framework gọi. Đừng rải app(Clock::class) trong mọi method nghiệp vụ: dependency sẽ bị ẩn, khó đọc và khó thay thế.
Không cần bind thủ công mọi concrete class nếu container đã tự resolve được constructor. Ngược lại, primitive như một string API URL không thể được đoán đúng chỉ từ kiểu string; cần factory binding hoặc cấu hình rõ ràng. Không hardcode secret trong provider.
5. Bind, singleton và scoped
bind phù hợp với ví dụ nhỏ này. singleton giữ lại một instance trong container; scoped gắn vòng đời instance với lifecycle do framework quản lý, hữu ích khi worker xử lý nhiều request/job. Đừng đưa user hiện tại hay Request vào một singleton tồn tại lâu vì có thể giữ dữ liệu của lượt xử lý trước.
SystemClock hiện stateless nhưng bài không cần tối ưu số instance. Chọn lifetime theo trạng thái và vòng đời dependency, không theo suy nghĩ “singleton chắc nhanh hơn”. Đối chiếu Service Container khi ứng dụng dùng Octane hoặc queue worker.
6. Thay đồng hồ trước khi resolve service trong test
tests/Feature/TaskDeadlineTest.php
<?php
namespace Tests\Feature;
use App\Contracts\Clock;
use App\Models\Task;
use App\Services\TaskDeadline;
use App\Support\SystemClock;
use Carbon\CarbonImmutable;
use Tests\TestCase;
class TaskDeadlineTest extends TestCase
{
public function test_default_interface_binding_resolves(): void
{
$this->assertInstanceOf(SystemClock::class, app(Clock::class));
$this->assertInstanceOf(TaskDeadline::class, app(TaskDeadline::class));
}
public function test_injected_clock_makes_deadline_boundaries_deterministic(): void
{
$this->app->instance(Clock::class, new class implements Clock {
public function now(): CarbonImmutable
{
return CarbonImmutable::parse('2026-10-01 12:00:00', 'UTC');
}
});
$service = app(TaskDeadline::class);
$this->assertTrue($service->isOverdue(new Task(['status' => 'todo', 'due_at' => '2026-10-01 11:59:59'])));
$this->assertFalse($service->isOverdue(new Task(['status' => 'todo', 'due_at' => '2026-10-01 12:00:00'])));
$this->assertFalse($service->isOverdue(new Task(['status' => 'done', 'due_at' => '2026-10-01 11:00:00'])));
$this->assertFalse($service->isOverdue(new Task(['status' => 'todo', 'due_at' => null])));
}
}php artisan test --filter=TaskDeadlineTest
php artisan test
instance() đăng ký một đối tượng Clock cố định rồi mới resolve TaskDeadline. Nếu service đã được tạo trước khi thay clock, đối tượng cũ vẫn giữ dependency cũ. Đây là lý do thứ tự setup test quan trọng. Test dùng model chưa lưu nên không cần database cho riêng các trường hợp này.
Toàn suite pass 37 test, 126 assertions. Test kiểm tra binding mặc định và bốn tình huống: quá hạn, bằng hạn, đã done và chưa có hạn. Không dùng sleep để “chờ test đúng thời điểm”; đồng hồ cố định làm kỳ vọng tái lập được.
7. Bài tập và giới hạn
Thêm test hạn trong tương lai và thời điểm cùng instant nhưng offset khác. Thử tạo service bằng new với clock giả để thấy nghiệp vụ không phụ thuộc container. Sau đó tạm bỏ binding, quan sát lỗi resolve rồi khôi phục. Không commit trạng thái cố ý hỏng.
Clock abstraction không tự xử lý timezone người dùng, lịch làm việc, ngày nghỉ hay quyền xem task. Không biến ví dụ này thành thư viện abstraction lớn trước khi có yêu cầu. Điều hướng: Bài 14 · Lộ trình. Bài tiếp theo tổ chức nghiệp vụ với Service, Action và DTO.




Chưa có bình luận. Hãy là người đầu tiên chia sẻ ý kiến.