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(); } }