Skip to content

Instantly share code, notes, and snippets.

@olleharstedt
Created July 30, 2025 08:28
Show Gist options
  • Save olleharstedt/2f75ecdcd9c6b00f06bfc567fcbcc19b to your computer and use it in GitHub Desktop.
Save olleharstedt/2f75ecdcd9c6b00f06bfc567fcbcc19b to your computer and use it in GitHub Desktop.
Kill the mocking DSL
<?php
/**
* DI with high granularity, every side-effect is its own class.
* Each side-effect class would be its own mock/stub or anonymous class in the unit-test code.
*/
class PushOrderToRemoteAPICommand
{
public function __construct(
FileLogEffect $fileLog,
FetchDbLogByIdEffect $fetchDbLog,
UpdateDbLogEffect $updateDbLog,
FetchApiConfigEffect $fetchApiConf,
FetchCustomerAddressEffect $fetchCustAdr,
FetchClerkByIdEffect $fetchStaff,
FetchCreditCardDataEffect $fetchCredCard,
FetchWarehouseIdEffect $fetchWareId,
FetchTranslationsEffect $fetchTrans,
PostCurlEffect $postCurl,
DeleteOnHoldReceiptEffect $delHold,
SetNewOrderIdEffect $setOrd,
Registry $registry,
) {
}
public function __invoke()
{
$dbLog = $this->fetchDbLog($id);
$dbLog->status = 'in_progress';
$this->updateDbLog($dbLog);
// ...
}
}
/**
* Low granularity.
* Leads to complex mocking where you have to setup precise method calls
* to the db connections.
*/
class PushOrderToRemoteAPICommand
{
public function __construct(
Connection $db1,
Connection $db2,
Logger $logger,
Curl $curl,
Registry $registry,
) {
}
public function __invoke()
{
$dbLog = DbLog::fetchById($this->db1, $id);
$dbLog->status = 'in_progress';
$dbLog->update($this->db1);
// ...
}
}
/**
* "Normal" granularity? Inject repository objects.
* Lots of mocks.
*/
class PushOrderToRemoteAPICommand
{
public function __construct(
DbLogRepository $dblogrep,
CustomerRepository $custrep,
ClerkRepository $clerkrep,
ApiSettingsRepository $apirep,
TranslationRepository $transrep,
OrderRepository $orderrep,
Logger $logger,
Curl $curl,
Registry $registry,
) {
}
public function __invoke()
{
$dbLog = $this->dblogrep->fetchById($id);
$dbLog->status = 'in_progress';
$this->dblogrep->update($dbLog);
// ...
}
}
/**
* DI where all side-effects are gathered in a separate environmental facade. See below.
* Environment facade would be an anonymous class in the test code.
*/
class PushOrderToRemoteAPICommand
{
public function __construct(
PushOrderToRemoteAPIEnv $env,
Registry $registry,
) {
}
public function __invoke()
{
$dbLog = $env->fetchDbLogEntry($id);
$dbLog->status = 'in_progress';
$env->updateDbLogEntry($dbLog);
// ...
}
}
/**
* Environment facade that wraps all side-effects.
*/
class PushOrderToRemoteAPIEnv
{
public function __construct(
Connection $db1,
Connection $db2,
Curl $curl,
Registry $registry,
Logger $logger,
) {
}
public function traceLog() {}
public function criticalLog() {}
public function fetchDbLogEntry() {}
public function updateDbLogEntry() {}
public function fetchApiKey() {}
public function fetchCreditCardData() {}
public function fetchWarehouseId() {}
public function fetchTranslations() {}
public function fetchCustomerById() {}
public function fetchClerkId() {}
public function postCurl() {}
public function setOrderId() {}
public function deleteOnHold() {}
}
// Small wrapper around Fiber::suspend
function perform(Effect $e): mixed
{
return Fiber::suspend($e);
}
/**
* No injection, but use a separate command handler which runs the command as a fiber.
* Each effect is its own class, but used as algebraic effect instead.
* One can also use generators instead of fibers, but then you'd have to propagate the effect manually through the call stack.
*/
class PushOrderToRemoteAPICommand
{
public function __construct(
Registry $registry,
) {
}
public function __invoke()
{
$dbLog = perform(new FetchDbLogByIdEffect($id));
$dbLog->status = 'in_progress';
perform(new UpdateDbLogEffect($dbLog));
// ...
}
}
class AlgebraicEffectCommandHandler
{
public function __construct(Registry $registry)
{
}
public function setEffectHandler(EffectHandler $h) {}
public function run(): mixed
{
$fiber = new Fiber(new PushOrderToRemoteAPICommand($this->registry));
$data = [
'foo' => 'bar'
];
$effect = $fiber->start($data);
$db1 = OpenDatabase1();
$db2 = OpenDatabase2();
while (!$fiber->isTerminated()) {
$data = null;
if ($effect instanceof Effect) {
// NB: This should be factored out to separate effect handlers, which then are injected.
if ($effect instanceof FetchCustomerAddressEffect) {
$data = $db->select($effect->sql);
} else {
throw new RuntimeException('Unsupported effect class');
}
} else {
// Other Fiber usage?
}
if ($data) {
$effect = $fiber->resume($data);
}
}
return $fiber->getReturn();
}
}
@olleharstedt
Copy link
Author

class PushOrderToRemoteAPICommand
{
    public function __invoke(Env $env)
    {
        $dbLog = $env->fetchDbLogEntry($id);
        $dbLog->status = 'in_progress';
        $env->updateDbLogEntry($dbLog);
        // ...
    }

    // Env as anon class fetched from same class.
    public function getEnv($db, $registry, $curl, $logger)
    {
        return new class () {
            public function postCurl() { ... }
            // etc
        };
    }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment