Bus System Best Practices
This guide outlines the best practices for working with the Bus System in the Planet project. Following these practices ensures that your code remains maintainable, testable, and aligned with the project's architectural principles.
DTO Design
Use Readonly Properties
Always use the readonly modifier for DTO properties to ensure immutability:
final readonly class GetUserByIdQuery implements QueryInterface
{
public function __construct(
public int $userId
) {
// Validation here
}
}
Validate in Constructor
Always validate input in the constructor to fail fast:
public function __construct(
public string $email,
public string $password
) {
if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException('Invalid email address');
}
if (strlen($password) < 8) {
throw new InvalidArgumentException('Password must be at least 8 characters');
}
}
Parameter Ordering
Follow this order for constructor parameters:
- Required parameters
- Optional parameters with no default value (nullable)
- Optional parameters with default values
public function __construct(
public string $name, // Required
public ?string $description, // Optional, nullable
public int $limit = 10 // Optional with default
) {
// Validation
}
Use Named Arguments
When creating DTOs, use named arguments for clarity:
$command = new CreateUserCommand(
name: $request->input('name'),
email: $request->input('email'),
password: $request->input('password')
);
Handler Design
Single Responsibility
Each handler should handle exactly one message type:
// Good
final readonly class CreateUserHandler
{
public function handle(CreateUserCommand $command): void
{
// Implementation
}
}
// Bad - Handling multiple command types
final readonly class UserHandler
{
public function handleCreate(CreateUserCommand $command): void { /* ... */ }
public function handleUpdate(UpdateUserCommand $command): void { /* ... */ }
}
Dependency Injection
Use constructor injection for dependencies:
final readonly class GetUserByIdHandler
{
public function __construct(
private UserRepositoryInterface $repository,
private LoggerInterface $logger
) {}
public function handle(GetUserByIdQuery $query): array
{
// Implementation using $this->repository and $this->logger
}
}
Proper Logging
Include context information in logs:
public function handle(CreateUserCommand $command): void
{
$this->logger->info('Creating user', [
'email' => $command->email,
// Don't log sensitive data like passwords
]);
try {
// Implementation
$this->logger->info('User created successfully', [
'userId' => $userId
]);
} catch (Exception $e) {
$this->logger->error('User creation failed', [
'exception' => $e->getMessage(),
'email' => $command->email
]);
throw $e;
}
}
Transaction Management
Use transactions for operations that modify multiple records:
public function handle(CreateOrderCommand $command): void
{
DB::transaction(function () use ($command) {
// Create order
// Update inventory
// Create payment record
});
}
Exception Handling
Domain-Specific Exceptions
Create domain-specific exceptions for better error handling:
// Instead of generic exceptions
throw new Exception('User not found');
// Use domain-specific exceptions
throw new UserNotFoundException("User with ID {$userId} not found");
Exception Hierarchy
Create a hierarchy of exceptions for your domain:
// Base exception for the module
abstract class UserModuleException extends Exception {}
// Specific exceptions
class UserNotFoundException extends UserModuleException {}
class UserAlreadyExistsException extends UserModuleException {}
class InvalidUserDataException extends UserModuleException {}
Comprehensive Error Messages
Include relevant information in error messages:
// Too generic
throw new UserNotFoundException('User not found');
// Better
throw new UserNotFoundException("User with ID {$userId} not found");
// Even better for debugging
throw new UserNotFoundException("User with ID {$userId} not found in database {$this->connection->getName()}");
Testing
Unit Testing Handlers
Test handlers in isolation:
public function test_get_user_by_id_handler_returns_user_data(): void
{
// Arrange
$userId = 1;
$expectedUser = ['id' => 1, 'name' => 'John'];
$repository = $this->createMock(UserRepositoryInterface::class);
$repository->expects($this->once())
->method('findById')
->with($userId)
->willReturn($expectedUser);
$logger = $this->createMock(LoggerInterface::class);
$handler = new GetUserByIdHandler($repository, $logger);
// Act
$result = $handler->handle(new GetUserByIdQuery($userId));
// Assert
$this->assertEquals($expectedUser, $result);
}
Integration Testing
Test the entire flow from controller to handler:
public function test_can_get_user_by_id_through_bus(): void
{
// Arrange
$user = User::factory()->create();
// Act
$response = $this->getJson("/api/users/{$user->id}");
// Assert
$response->assertStatus(200)
->assertJson([
'data' => [
'id' => $user->id,
'name' => $user->name
]
]);
}
Testing Error Cases
Always test error cases:
public function test_get_user_by_id_handler_throws_exception_when_user_not_found(): void
{
// Arrange
$userId = 999;
$repository = $this->createMock(UserRepositoryInterface::class);
$repository->expects($this->once())
->method('findById')
->with($userId)
->willReturn(null);
$logger = $this->createMock(LoggerInterface::class);
$handler = new GetUserByIdHandler($repository, $logger);
// Assert
$this->expectException(UserNotFoundException::class);
// Act
$handler->handle(new GetUserByIdQuery($userId));
}
Module Integration
Service Provider Registration
Register all handlers in the module's service provider:
public function boot(
QueryBusInterface $queryBus,
CommandBusInterface $commandBus
): void {
// Register Query handlers
$queryBus->register(GetUserByIdQuery::class, GetUserByIdHandler::class);
$queryBus->register(GetUsersByRoleQuery::class, GetUsersByRoleHandler::class);
// Register Command handlers
$commandBus->register(CreateUserCommand::class, CreateUserHandler::class);
$commandBus->register(UpdateUserCommand::class, UpdateUserHandler::class);
}
Controller Design
Keep controllers thin and focused on HTTP concerns:
public function show(int $id): JsonResponse
{
try {
$query = new GetUserByIdQuery($id);
$response = $this->queryBus->dispatch($query);
if ($response->isSuccess()) {
return response()->json([
'data' => $response->getData()
]);
}
return response()->json([
'error' => $response->getErrorMessage()
], 404);
} catch (Exception $e) {
return response()->json([
'error' => 'Internal server error'
], 500);
}
}
Cross-Module Communication
Never Create Direct Dependencies
Never create direct dependencies between modules:
// Bad - Direct dependency on another module
use App\Modules\Products\Models\Product;
class OrderHandler
{
public function handle(CreateOrderCommand $command): void
{
$product = Product::find($command->productId);
// ...
}
}
// Good - Use the Bus System for cross-module communication
class OrderHandler
{
public function __construct(
private QueryBusInterface $queryBus
) {}
public function handle(CreateOrderCommand $command): void
{
$response = $this->queryBus->dispatch(
new GetProductByIdQuery($command->productId)
);
if (!$response->isSuccess()) {
throw new ProductNotFoundException($response->getErrorMessage());
}
$product = $response->getData();
// ...
}
}
Use Domain Events for Asynchronous Communication
For asynchronous communication between modules, use domain events:
// In the User module
public function handle(CreateUserCommand $command): void
{
// Create user
// Dispatch domain event
event(new UserCreatedEvent($userId, $command->email));
}
// In another module
class UserCreatedListener
{
public function __construct(
private CommandBusInterface $commandBus
) {}
public function handle(UserCreatedEvent $event): void
{
$this->commandBus->dispatch(
new SendWelcomeEmailCommand($event->userId, $event->email)
);
}
}
Performance Considerations
Lazy Loading
Use lazy loading for dependencies that are not always needed:
public function handle(GetUserByIdQuery $query): array
{
// $this->expensiveService is only created if needed
if ($query->includeDetails) {
$details = $this->container->make(ExpensiveService::class)->getDetails($query->userId);
return ['user' => $user, 'details' => $details];
}
return ['user' => $user];
}
Caching
Use caching for frequently accessed data:
public function handle(GetUserByIdQuery $query): array
{
$cacheKey = "user:{$query->userId}";
if ($this->cache->has($cacheKey)) {
return $this->cache->get($cacheKey);
}
$user = $this->repository->findById($query->userId);
$this->cache->put($cacheKey, $user, 3600); // Cache for 1 hour
return $user;
}
Batch Processing
For operations on multiple items, use batch processing:
public function handle(DeleteUsersCommand $command): void
{
DB::transaction(function () use ($command) {
// Instead of deleting one by one
$this->repository->deleteMany($command->userIds);
});
}
Documentation
Code Comments
Add meaningful comments to complex logic:
public function handle(CalculatePricingCommand $command): void
{
// Apply base price
$price = $command->basePrice;
// Apply quantity discount
if ($command->quantity > 10) {
// For quantities over 10, apply 5% discount
$price *= 0.95;
}
// Apply seasonal discount if applicable
if ($this->isSeasonalDiscountPeriod()) {
// Additional 10% off during seasonal promotions
$price *= 0.9;
}
// Save final price
$this->repository->updatePrice($command->productId, $price);
}
PHPDoc Blocks
Add PHPDoc blocks to methods:
/**
* Handle the get user by ID query.
*
* @param GetUserByIdQuery $query The query containing the user ID
* @return array The user data
* @throws UserNotFoundException If the user is not found
*/
public function handle(GetUserByIdQuery $query): array
{
// Implementation
}
README Updates
Keep the module's README up to date with:
- Purpose of the module
- Available commands and queries
- Integration examples
- Common issues and solutions
Security Considerations
Input Validation
Always validate input in DTOs:
public function __construct(
public string $email,
public string $password
) {
if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException('Invalid email address');
}
if (strlen($password) < 8) {
throw new InvalidArgumentException('Password must be at least 8 characters');
}
}
Authorization Checks
Include authorization checks in handlers:
public function handle(UpdateUserCommand $command): void
{
// Check if the current user can update the target user
if (!$this->authorizationService->canUpdate(Auth::id(), $command->userId)) {
throw new UnauthorizedException('You are not authorized to update this user');
}
// Proceed with update
}
Sensitive Data Handling
Be careful with sensitive data:
// Don't log sensitive data
$this->logger->info('User created', [
'userId' => $user->id,
'email' => $user->email,
// Don't include password or other sensitive data
]);
// Don't return sensitive data
public function handle(GetUserByIdQuery $query): array
{
$user = $this->repository->findById($query->userId);
// Remove sensitive fields before returning
unset($user['password']);
unset($user['securityQuestionAnswer']);
return $user;
}
Common Anti-patterns to Avoid
Business Logic in Controllers
Never put business logic in controllers:
// Bad
public function store(Request $request): JsonResponse
{
$user = new User();
$user->name = $request->input('name');
$user->email = $request->input('email');
$user->password = Hash::make($request->input('password'));
$user->save();
return response()->json(['message' => 'User created'], 201);
}
// Good
public function store(Request $request): JsonResponse
{
$command = new CreateUserCommand(
$request->input('name'),
$request->input('email'),
$request->input('password')
);
$this->commandBus->dispatch($command);
return response()->json(['message' => 'User created'], 201);
}
Direct Database Access in Handlers
Use repositories instead of direct database access:
// Bad
public function handle(GetUserByIdQuery $query): array
{
return DB::table('users')->where('id', $query->userId)->first();
}
// Good
public function handle(GetUserByIdQuery $query): array
{
return $this->userRepository->findById($query->userId);
}
Returning Void from Queries
Queries should always return data:
// Bad
public function handle(GetUserByIdQuery $query): void
{
$user = $this->repository->findById($query->userId);
event(new UserViewedEvent($user));
}
// Good
public function handle(GetUserByIdQuery $query): array
{
$user = $this->repository->findById($query->userId);
event(new UserViewedEvent($user));
return $user;
}
Modifying State in Queries
Queries should never modify state:
// Bad
public function handle(GetUserByIdQuery $query): array
{
$user = $this->repository->findById($query->userId);
$user->last_viewed_at = now();
$user->save();
return $user->toArray();
}
// Good
public function handle(GetUserByIdQuery $query): array
{
$user = $this->repository->findById($query->userId);
// If you need to track views, use a command
$this->commandBus->dispatch(new TrackUserViewCommand($query->userId));
return $user->toArray();
}
Returning Data from Commands
Commands should never return data:
// Bad
public function handle(CreateUserCommand $command): int
{
$user = new User();
// Set properties
$user->save();
return $user->id;
}
// Good
public function handle(CreateUserCommand $command): void
{
$user = new User();
// Set properties
$user->save();
// If you need to communicate the ID, use an event
event(new UserCreatedEvent($user->id));
}