Skip to main content

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:

  1. Required parameters
  2. Optional parameters with no default value (nullable)
  3. 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));
}