As a programmer, I have encountered numerous projects over the years, which I inherited, managed, upgraded, developed, and handed over. Many of them involved spaghetti code or, as it is also called, a "big ball of mud." This issue often affects projects built on some framework, where the code is organized similarly to examples in the framework's documentation.
Unfortunately, the documentation of MVC frameworks often fails to warn that code examples are primarily meant to illustrate functionality and are not suitable for real-world applications. As a result, real projects often integrate all layers into controller or presenter methods (in the case of MVP), which process requests (typically HTTP requests). If the framework includes a Component Object Model, such as Nette, components are often part of the controller or presenter, further complicating the situation.
Problems with Non-Optimal Code Structure
The code of such projects quickly grows in length and complexity. In a single script, database operations, data manipulation, component initialization, template settings, and business logic are mixed. While authors occasionally extract parts of the functionality into standalone services (usually singletons), this rarely helps much. Such a project becomes difficult to read and hard to maintain.
From my experience, standardized design patterns are rarely used, especially in smaller projects (5–50 thousand lines of code), such as simple CRUD applications for small businesses looking to simplify administration. Yet, these projects could greatly benefit from patterns like CQRS (Command Query Responsibility Segregation) and DDD (Domain-Driven Design).
- HTTP request -> Controller/Presenter
- Input validation -> Convert to request
- Request -> Command
- Command -> Render/Response
I will demonstrate how this approach looks using the Nette stack, Contributte (with Symfony Event Dispatcher integration), and Nextras ORM.
// Command definition
final class ItemSaveCommand implements Command {
private ItemSaveRequest $request;
public function __construct(private Orm $orm) {
//
}
/** @param ItemSaveRequest $request */
public function setRequest(Request $request): void {
$this->request = $request;
}
public function execute(): void {
$request = $this->request;
if ($request->id) {
$entity = $this->orm->items->getById($request->id);
} else {
$entity = new Item();
$entity->uuid = $request->uuid;
}
$entity->data = $request->data;
$this->orm->persist($entity);
}
}
// Command Factory
interface ItemSaveCommandFactory extends FactoryService {
public function create(): ItemSaveCommand;
}
// Request
#[RequestCommandFactory(ItemSaveCommandFactory::class)]
final class ItemSaveRequest implements Request {
public int|null $id = null;
public string $uuid;
public string $data;
}
/**
* Command execution service
* Supports transactions and request logging
*/
final class CommandExecutionService implements Service {
private \DateTimeImmutable $requestedAt;
public function __construct(
private Orm $orm,
private Container $container,
private Connection $connection,
) {
$this->requestedAt = new \DateTimeImmutable();
}
/** @throws \Throwable */
public function execute(AbstractCommandRequest $request, bool $logRequest = true, bool $transaction = true): mixed {
$factoryClass = RequestHelper::getCommandFactory($request);
$factory = $this->container->getByType($factoryClass);
$cmd = $factory->create();
$clonedRequest = clone $request;
$cmd->setRequest($request);
try {
$cmd->execute();
if (!$transaction) {
$this->orm->flush();
}
if (!$logRequest) {
return;
}
$logEntity = RequestHelper::createRequestLog(
$clonedRequest,
$this->requestedAt,
RequestLogConstants::StateSuccess
);
if ($transaction) {
$this->orm->persistAndFlush($logEntity);
} else {
$this->orm->persist($logEntity);
}
return;
} catch (\Throwable $e) {
if ($transaction) {
$this->connection->rollbackTransaction();
}
if (!$logRequest) {
throw $e;
}
$logEntity = RequestHelper::createRequestLog(
$clonedRequest,
$this->requestedAt,
RequestLogConstants::StateFailed
);
if ($transaction) {
$this->orm->persistAndFlush($logEntity);
} else {
$this->orm->persist($logEntity);
}
throw $e;
}
}
}
// Listener for executing commands via Event Dispatcher
final class RequestExecutionListener implements EventSubscriberInterface {
public function __construct(
private CommandExecutionService $commandExecutionService
) {
//
}
public static function getSubscribedEvents(): array {
return [
RequestExecuteEvent::class => 'onRequest'
];
}
/** @param ExecuteRequestEvent<mixed> $ev */
public function onRequest(ExecuteRequestEvent $ev): void {
$this->commandExecutionService->execute($ev->request, $ev->logRequest, $ev->transaction);
}
}
// Event definition for command execution
final class ExecuteRequestEvent extends Event {
public function __construct(
public Request $request,
public bool $logRequest = true,
public bool $transaction = true,
) {
// Constructor
}
}
// Event Dispatcher Facade
final class EventDispatcherFacade {
public static EventDispatcherInterface $dispatcher;
public static function set(EventDispatcherInterface $dispatcher): void {
self::$dispatcher = $dispatcher;
}
}
// Helper function for simple event dispatching
function dispatch(Event $event): object {
return EventDispatcherFacade::$dispatcher->dispatch($event);
}
// Usage in Presenter (e.g., in response to a component event)
final class ItemPresenter extends Presenter {
public function createComponentItem(): Component {
$component = new Component();
$component->onSave[] = function (ItemSaveRequest $request) {
dispatch(new ExecuteRequestEvent($request));
};
return $component;
}
}
This solution has several advantages. Database-related logic is separated from MVC/P, contributing to better readability and easier maintenance. The request object, which acts as a data carrier, is ideal for logging into the database, such as an event log. This ensures that all user inputs modifying data are saved along with their chronological order. In case of an error, these logs can be reviewed and replayed if necessary.
The drawbacks of this approach include the fact that the command should not return any data. Therefore, if I need to work further with the newly created data (e.g., pass it to a template), I must retrieve it using its UUID, which is why both the request and the entity contain it. Another disadvantage is that any changes to the database schema require updating all requests to match the new schema, which can be time-consuming.
Top comments (0)