Skip to content

Is a Clean Architecture framework a contradiction?

"Clean Architecture framework" reads like an oxymoron. Clean Architecture is about independence from frameworks: the framework is a detail you keep at arm's length so your business code never depends on it. A framework, in the usual sense, is the opposite. It imposes its conventions, it lives in vendor/, and you depend on it by version. Someone raised this objection to me, and it is fair. So which one is Phexium?

Two definitions that collide

Robert C. Martin frames the framework as a detail. Your domain and your use cases sit at the center, and the framework stays on the outside, behind a boundary, replaceable. The whole point is that you do not marry it.

A framework, in the ordinary sense, asks for the opposite commitment. It calls your code rather than the other way around, which is inversion of control, the "Hollywood principle": don't call us, we'll call you. And you consume it as an external package you do not own. Put the two words side by side and they fight.

What "framework" actually means here

Two properties usually define a framework, and they are not the same thing.

The first is inversion of control: the framework drives, and your code fills in the blanks. The second is dependency ownership: the code lives in vendor/, behind a version constraint, as someone else's implementation you treat as a black box.

The second property is the one that produces lock-in. Phexium keeps the first, because structure is useful, and drops the second entirely.

The frame is code you own

Phexium does invert control. When you dispatch a command, a bus resolves the right handler and calls it. Your handler never calls the bus back. That is a real frame:

namespace Phexium\Plugin\CommandBus\Adapter;

final readonly class SyncCommandBus implements CommandBusInterface
{
    public function __construct(
        private ContainerInterface $container,
        private LoggerInterface $logger
    ) {}

    #[Override]
    public function dispatch(CommandInterface $command): void
    {
        $handler = $this->resolveHandler($command::class);
        $handler($command);
    }

    private function resolveHandler(string $commandClass): CommandHandlerInterface
    {
        $handlerClass = CommandHandlerNamingConvention::resolveHandlerClass($commandClass);
        // class_exists + container lookup, then a type check
        return $this->container->get($handlerClass);
    }
}

The "strict convention" this bus enforces, mapping CreateBookCommand to CreateBookHandler, is not buried in a vendor package. It is five lines you can read:

namespace Phexium\Plugin\CommandBus\Internal;

final class CommandHandlerNamingConvention
{
    public static function resolveHandlerClass(string $commandClass): string
    {
        return preg_replace('/Command$/', 'Handler', $commandClass);
    }
}

Both files live in your project's src/, under the Phexium namespace, next to your application code. There is no vendor/ boundary, no version constraint, and no hidden magic. If the Command to Handler convention does not fit, you change that preg_replace. If the synchronous dispatch does not fit, you edit dispatch() or write your own adapter behind CommandBusInterface. The frame is real, and it is yours.

Trade-offs

Ownership shifts responsibility onto you. Before you change the bus, you have to understand it. When upstream Phexium publishes a fix you want, you pull it with git fetch and a cherry-pick, and you handle any conflict with your local edits. The convention-based resolution also means a handler must follow the naming rule, or you accept the cost of changing the convention for the whole project.

For a large, general-purpose framework with hundreds of contributors, the vendor/ model is the right call: you want their fixes automatically and you do not want to read the implementation. Phexium targets a different goal, so it makes the opposite trade.

What this enables

The contradiction dissolves once the two definitions are separated. Phexium gives you the frame, the layers, the ports and adapters, the buses, without the lock-in, because the frame is code you own rather than a dependency you rent.

Clean Architecture promises that the framework is a detail you can replace. With Phexium that promise is literal. When a choice stops fitting, you rewrite it in your own repository, with no fork and no waiting for a release. If Phexium development stops tomorrow, your project keeps running unchanged. That is the whole argument against framework lock-in, and it is the reason the two words can sit together after all.