Dependency Injection for Mere Humans

php[tek] 2023

Ian Littman / @ian@phpc.social / @iansltx 

follow along at https://ian.im/ditek23

code samples at https://ian.im/dicode

Alternate title:

opinions and implementations on
Decoupled software design

Shout out to @jcarouth and his presentation back in 2015

What We'll Cover

  • Types of dependencies
  • What dependency injection is and why it's a good thing
  • Types of dependency injection, pros + cons
  • Refactoring to a dependency-injected architecture
  • Dependency Injection Containers
    • What they are
    • How to build one
    • How to configure/use one
  • ...and a few antipatterns along the way

Highly coupled code is bad

highly coupled code is bad

but some coupling is unavoidable

Decoupled Code is good

  • Easier to test
  • Easier to debug
  • Easier to reason about

What's a dependency, anyway?

Dependencies are the external objects, resources, or functions an object uses to accomplish its responsibility.

  • Per-class level <- we'll be here
  • Library level (handled by Composer)
  • OS level (handled by e.g. apt, apk, yum)

Some common Dependencies

  • Databases/Entity Managers (e.g. PDO, Doctrine)
  • Caching (e.g. Redis/Memcached adapters)
  • File storage (e.g. local FS, S3, Flysystem)
  • Notifications (email, SMS, push)
  • Over-the-network APIs (or SDKs that wrap them)
  • Logging

Some common Dependencies

  • The current web request
  • Environment variables
  • The current user
  • Session/API scope information
  • The current time
  • Static method calls
  • Sources of randomness

Spot the dependencies

class OrderProcessor {
    function __construct() {
        $this->orderRepository = new MysqlOrderRepository();
    }

    function completeOrder($order) {
        global $logger;
        $order->completedAt = new \DateTimeImmutable;
        $logger->log("Order {$order->id} is complete");
        $this->orderRepository->save($order);
        Mailer::sendOrderCompleteEmail($order);
    }
}

Spot the dependencies

class OrderProcessor {
    function __construct() {
        $this->orderRepository = new MysqlOrderRepository();
    }

    function completeOrder($order) {
        global $logger;
        $order->completedAt = new \DateTimeImmutable;
        $logger->log("Order {$order->id} is complete");
        $this->orderRepository->save($order);
        Mailer::sendOrderCompleteEmail($order);
    }
}

Spot the dependencies

class OrderProcessor {
    function __construct() {
        $this->orderRepository = new MysqlOrderRepository();
    }

    function completeOrder($order) {
        $logger = \App::make('logger');
        $order->completedAt = new \DateTimeImmutable;
        $logger->log("Order {$order->id} is complete");
        $this->orderRepository->save($order);
        Mailer::sendOrderCompleteEmail($order);
    }
}

Spot the dependencies

class OrderProcessor {
    function __construct() {
        $this->orderRepository = new MysqlOrderRepository();
    }

    function completeOrder($order) {
        $logger = Logger::getInstance();
        $order->completedAt = new \DateTimeImmutable;
        $logger->log("Order {$order->id} is complete");
        $this->orderRepository->save($order);
        Mailer::sendOrderCompleteEmail($order);
    }
}

Spot the dependencies

class OrderProcessor {
    function __construct() {
        $this->orderRepository = new MysqlOrderRepository();
    }

    function completeOrder($order) {
        $order->completedAt = new \DateTimeImmutable;
        \Logger::info("Order {$order->id} is complete");
        $this->orderRepository->save($order);
        Mailer::sendOrderCompleteEmail($order);
    }
}

Spot the dependencies

class OrderProcessor {
    function __construct() {
        $this->orderRepository = new MysqlOrderRepository();
    }

    function completeOrder($order) {
        $order->completedAt = new \DateTimeImmutable;
        log_info("Order {$order->id} is complete");
        $this->orderRepository->save($order);
        Mailer::sendOrderCompleteEmail($order);
    }
}

Hard questions

Q: What does OrderProcessor depend on?

A: Read the entire class to find out!
 

Q: Where is Mailer used in my application?
A: Grep everything in your project for "Mailer::".

 

Q: What'll it take to swap the mailer from SES to Mailgun?

A: ...have fun with that.

 

Q: How can I test completeOrder() without sending emails?

A: ¯\_(ツ)_/¯ *

 

* Yes, there are workarounds, but this isn't exactly a testing/mocking talk.

WHat's dependency injection, anyway?

Dependency injection is the practice of pushing (injecting) dependencies into an object, rather than having objects find/call their dependencies on their own.

 

This isn't the same as the Dependency Inversion Principle in SOLID. We'll get to that later.

Types of Dependency Injection

  • Constructor
  • Setter + Property
  • Parameter

Constructor injection

class ClassThatNeedsLogging {
    private Logger $logger;

    public function __construct(Logger $logger) {
        $this->logger = $logger;
    }
}

Constructor injection

class ClassThatNeedsLogging {
    public function __construct(
        private Logger $logger
    ) {
        // constructor property promotion
        // available since PHP 8.0
    }
}

Pros

  • Makes dependencies explicit
  • Can't externally modify dependencies after instantiation
  • Discourages violation of Single Responsibility Principle
  • Can set classes as readonly for even more predictability

Cons

  • May have a lot of constructor parameters
  • May never use most dependencies
  • Could be the wrong point in the object life cycle
public function __construct(
    private Twig $view,
    private AuthorizationServer $auth,
    private LogRepository $errorRepo,
    private AuthRequestRepository $authRequestRepo,
    private ClientRepository $clientRepo,
    private UserRepository $userRepo,
    private ResourceServer $resAuth,
    private AccessTokenRepository $accessTokenRepo,
    private UserValidatorInterface $validator
) {}

Antipattern #1: __construct($options)

"I know, I'll just throw everything in an $options array!"

 

Instead, if your options are optional:

  1. Make them optional (nullable, default null) parameters
  2. Use named parameters (PHP 8.0+) to set them

Setter injection

class ClassThatUsesLoggingButDoesNotRequireIt {
    private ?LoggerInterface $logger = null;

    public function setLogger(Logger $logger) {
        $this->logger = $logger;
    }
}

Yolo Property injection

class ClassThatMightWantAThingCalledALogger {
    public $logger = null;
}

 

class SaferClassThatWantsALogger {
    public ?Logger $logger = null;
} // as of 7.4

 

Can't use readonly here

Pros

  • Lazier loading
  • Works well with optional dependencies
  • Flexible across class lifecycle
    • Don't need every dependency for every method call
    • Can change dependencies without re-instantiating the class

Cons

  • Harder to quickly see which dependencies a class has
  • Existence of dependencies in fully instantiated class not guaranteed
    • Null checks inside the code
    • Conditional injection outside the code

Which would you rather have?

public function fileTPSReport(Report $rpt) {
    /* do some stuff */
    if ($this->logger instanceof Logger) {
        $this->logger->log('Did a thing');
    }
    /* do more stuff */
    if ($this->logger instanceof Logger) {
        $this->logger->log('Did things');
    }
}

Which would you rather have?

public function fileTPSReport(Report $rpt) {
    /* do some stuff */
    if ($this->logger) {
        $this->logger->log('Did a thing');
    }
    /* do more stuff */
    if ($this->logger) {
        $this->logger->log('Did things');
    }
}

Which would you rather have?

public function fileTPSReport(Report $rpt) {
    /* do some stuff */
    $this->logger->log('Did a thing');
    /* do more stuff */
    $this->logger->log('Did things');
}

 

class NullLogger implements Logger {
    public function log($message) {
        /** noop **/
    }
}

One weird constructor defaulting trick (PHP 8.1+)

public function __construct(
    private LoggerInterface $logger
        = new NullLogger()
) {}

Traits + Setter Injection

  1. Add setter to trait
  2. Mirror setter in an interface
  3. Import trait into classes
  4. Implement interface on classes
  5. Configure DI container to setter-inject based on interface

 

e.g. Psr\Log\{LoggerAwareInterface, LoggerAwareTrait}

 

Sorry, you can't have an interface implement a trait directly.

Parameter Injection

public function __invoke(
    ServerRequestInterface $request,
    ResponseInterface $response,
    array $args = []
): ResponseInterface {
    /** do something,
        return a ResponseInterface **/
}

Parameter Injection

public function getCollection(
    \Illuminate\Http\Request $request,
    \App\Services\OrderService $orderService
): AThing|ALaravel|Controller|CanReturn {
    /** do something, return a response **/
}

Parameter Injection

public function handle(
    \App\Services\OrderService $orderService
): void {
    /** do some things in a CLI context **/
}

Pros

  • What you need when you need it

Cons

  • Moves the problem of dependency management to the caller*

 

* Some frameworks take care of this for you for controllers/commands

Mix & Match as needed

Dependency Inversion Principle

High level modules should not depend on low level modules; both should depend on abstractions. Abstractions should not depend on details.  Details should depend upon abstractions.

Dependency Inversion Principle

High level modules should not depend on low level modules; both should depend on abstractions. Abstractions should not depend on details.  Details should depend upon abstractions.

 

tl;dr

  1. Expose interfaces
  2. Rely only on those interfaces

Interface Pro Tips

Should I use "interface" in my interface names?

class PhoneBurner implements SmsProvider {}
class InfobipSMS implements SmsProviderInterface {}

Should I use "interface" in my interface names?

class PhoneBurner implements SmsProvider {}
class InfobipSMS implements SmsProviderInterface {}

 

Doesn't matter either way. Pick a convention and stick with it.

Abstractions should not be leaky

class TESTMORE implements HasAcceleratorPedal {
    public function pressAccelerator(): void;
} // namespace Grumpy\Vehicles

class MuskMobile implements HasAcceleratorPedal {
    public function pressAccelerator(): void;
} // namespace Grumpy\Vehicles

Abstractions should not be leaky

class TESTMORE implements HasAcceleratorPedal {
    public function pressAccelerator(): Vroom;
} // namespace Grumpy\Vehicles

class MuskMobile implements HasAcceleratorPedal {
    public function pressAccelerator(): Zoom;
} // namespace Grumpy\Vehicles

 

If Vroom and Zoom implement Acceleration

we're good here

Abstractions should not be leaky

class MysqlUserRepo implements UserRepository {
    public function getById(int $id): ?User {}
}

class UsersInPostgres implements UserRepository {
    public function getById(int $id): ?User {}
}

class gRPCUserAPI implements UserRepository {
    public function getById(int $id): ?User {}
}

interface User { /** various signatures **/ }

Let's do some refactoring

Let's do some refactoring

class OrderProcessor {
    function __construct() {
        $this->orderRepository = new MysqlOrderRepository();
    }

    function completeOrder($order) {
        $logger = Logger:getInstance();
        $order->completedAt = new \DateTimeImmutable;
        $logger->log("Order {$order->id} is complete");
        $this->orderRepository->save($order);
        Mailer::sendOrderCompleteEmail($order);
    }
}

Let's do some refactoring

class OrderProcessor {
    function __construct(
        OrderRepository $orderRepo, Logger $logger
    ) {
        $this->orderRepository = $orderRepo;
        $this->logger = $logger;
    }

    function completeOrder($order) {
        $order->completedAt = new \DateTimeImmutable;
        $this->logger->log("Order {$order->id} is complete");
        $this->orderRepository->save($order);
        Mailer::sendOrderCompleteEmail($order);
    }
}
class OrderProcessor {
    function __construct(
        OrderRepository $orderRepo, Logger $logger,
        \DateTimeInterface $now, Mailer $mailer
    ) {
        $this->orderRepository = $orderRepo;
        $this->logger = $logger;
        $this->now = $now;
        $this->mailer = $mailer;
    }
    function completeOrder($order) {
        $order->completedAt = $this->now;
        $this->logger->log("Order {$order->id} is complete");
        $this->orderRepository->save($order);
        $this->mailer->sendOrderCompleteEmail($order);
    }
}
class OrderProcessor {
    function __construct(
  OrderRepository $orderRepo, Logger $logger,   Mailer $mailer
​​​​​​​ ) { $this->orderRepository = $orderRepo;   $this->logger = $logger;   $this->mailer = $mailer; } function completeOrder($order, \DateTimeInterface $now) { $order->completedAt = $now; $this->logger->log("Order {$order->id} is complete"); $this->orderRepository->save($order);   $this->mailer->sendOrderCompleteEmail($order); } }
class OrderProcessor {
    function __construct(
        private OrderRepository $orderRepo,
        private Logger $logger,
        private Mailer $mailer
    ) {
        // constructor property promotion, PHP 8.0+
    }

    function completeOrder($order, \DateTimeInterface $now) {
        $order->completedAt = $now;
        $this->logger->log("Order {$order->id} is complete");
        $this->orderRepository->save($order);
        $this->mailer->sendOrderCompleteEmail($order);
    }
}

What's a Dependency Injection Container?

A dependency injection container is an object used to manage the instantiation of other objects.

 

If you have one, it will be the place where the "new" keyword gets used more than anywhere else in your app, either via configuration code you write or under the hood.

What's a Dependency Injection Container NOT?

  • Not the only place you can (or should) use the "new" keyword in your application.
    • Factories
    • Value objects
  • Not required for dependency injection.
  • Not to be used as a Service Locator
class OrderProcessor {
    function __construct(ContainerInterface $c) {
        $this->orderRepository = $c->get('OrderRepository');
        $this->logger = $c->get('Logger');
        $this->mailer = $c->get('Mailer');
    }
    function completeOrder($order, \DateTimeInterface $now) {
        $order->completedAt = $now;
        $this->logger->log("Order {$order->id} is complete");
        $this->orderRepository->save($order);
        $this->mailer->sendOrderCompleteEmail($order);
    }
}

Antipattern #2: Service Location

class OrderProcessor {
    function __construct(protected ContainerInterface $c) {
        $this->orderRepository = $c->get('OrderRepository');
    }
    function completeOrder($order, \DateTimeInterface $now) {
        $order->completedAt = $now;
        $this->c->get('log')->log("Order {$order->id} is complete");
        $this->orderRepository->save($order);
        $this->c->get('mail')->sendOrderCompleteEmail($order);
    }
}

ANTIPATTERN #2: Service Location

What does a container implementation look like?

Twittee: A container in a 140 tweet

class Container {
 protected $s=array();
 function __set($k, $c) { $this->s[$k]=$c; }
 function __get($k) { return $this->s[$k]($this); }
}
    

Using Twittee

class NeedsALogger {
    public function __construct(
        private Logger $logger
    ) {}
}
class Logger {}

$c = new Container;
$c->logger = function() { return new Logger; };
$c->myService = function($c) {
    return new NeedsALogger($c->logger);
};

var_dump($c->myService); // includes the Logger

PSR-11 (fka Container-Interop)

namespace Psr\Container;

interface ContainerInterface {
    public function get($id);
    public function has($id);
}

PSR-11 (fka Container-Interop) 2.0

namespace Psr\Container;

interface ContainerInterface {
    public function get(string $id); // mixed
    public function has(string $id): bool;
}

Twittee++: a PSR-11 2.0 container in 280 characters

class Container implements Psr\Container\ContainerInterface{
 private $s=[];
 function __set($k,$c){$this->s[$k]=$c;}
 function __get($k){return $this->s($k)($this);}
 function get(string $k){return $this->s[$k]($this);}
 function has(string $k):bool{return isset($this->s[$k]);}
}

Twittee++ With a quick optimization from M1Ke

class Container implements Psr\Container\ContainerInterface{
 private $s=[];
 function __set($k,$c){$this->s[$k]=$c;}
 function __get($k){return $this->get($k);}
 function get(string $id){return $this->s[$id]($this);}
 function has(string $id):bool{return isset($this->s[$id]);}
}
class Container extends ArrayObject implements Psr\Container\ContainerInterface {
 function __set($k,$c){$this[$k]=$c;}
 function __get($k){return $this->get($k);}
 function get(string $id){return $this[$id]($this);}
 function has(string $id): bool {return isset($this[$id]);}
}

Dependency Injected Fizzbuzz

class Fizz { public function __toString() { return 'Fizz'; } }
class Buzz { public function __toString() { return 'Buzz'; } }
class FizzBuzz {
    public function __construct(
        private Fizz $fizz,
        private Buzz $buzz
    ) {}
    public function __invoke(int $i): string {
        if (!($i % 15)) return $this->fizz . $this->buzz;
        if (!($i % 3)) return (string) $this->fizz;
        if (!($i % 5)) return (string) $this->buzz;
        return (string) $i;
    }
}
$c = new Container;
$c->fizz = fn () => new Fizz;
$c->buzz = fn () => new Buzz;
$c->fizzBuzz = fn ($c)
    => new FizzBuzz($c->fizz, $c->buzz);


foreach (range(1, 15) as $i) {
    echo $c->get('fizzBuzz')($i) . "\n";
}

...but don't use Twittee++

  • Every major framework has one
  • Standalone ones for use elsewhere
    • Pimple
    • PHP-DI
    • Aura.Di

...but don't use Twittee++

  • Every major framework has one
  • Standalone ones for use elsewhere
    • Pimple
    • PHP-DI
    • Aura.Di
$c = new Pimple\Container;
$c[NeedsALogger::class] =
    fn ($c) => new NeedsALogger($c['logger']);
$c['logger'] = fn () => new Logger;

var_dump($c[NeedsALogger::class]); // NeedsALogger

Pimple - Now PSR-11 Compliant

Pimple

  • Use $c->factory(callable) if you don't want the same instance every time you ask for a dependency.
  • Use $c->protect(callable) if you want to
    set a closure to your container.
  • Use $c->raw(dep) if you want to
    get a closure you set without using protect()
  • Not much magic
  • Default container of Slim 3
  • Can include in Slim 4 as it conforms to PSR-11

Let's refactor a Slim 4 route

$app = Slim\Factory\AppFactory::create();

$app->get('/', function(Request $req, Response $res) {
    $userRepository = new UserRepository(new PDO(/* */));
    $users = $userRepository->listAll();
    return $res->withJson($users);
});

Let's refactor a Slim 4 route

$c = new \Pimple\Container();
$c['db'] = function($c) { return new PDO(/* */); };

Slim\Factory\AppFactory::setContainer($c);
$app = Slim\Factory\AppFactory::create();

$app->get('/', function(Request $req, Response $res) {
    $userRepository = new UserRepository($this->get('db'));
    $users = $userRepository->listAll();
    return $res->withJson($users);
});

Let's refactor a Slim 4 route

// snip
$c[UserRepository::class] = function($c) {
    return new UserRepository($c['db']);
};

// snip
$app->get('/', function(Request $req, Response $res) {
    $userRepository = $this->get(UserRepository::class);   
    $users = $userRepository->listAll();
    return $res->withJson($users);
});

Let's refactor a Slim 4 route

class GetUsersAction {
    public function __construct(
        private UserRepository $repo
    ) {}

    public function __invoke(Request $req, Response $res) {
            $users = $this->userRepository->listAll();
            return $res->withJson($users);
        }
    };
};

Let's refactor a Slim 4 route

$c['getUsers'] = function($c) {
    return new GetUsersAction($c[UserRepository::class]);
};

$app->get('/', 'getUsers');
use function DI\create;

$c = new \DI\Container();
$c->set('LoggerInterface', create('Logger'));

// returns a NeedsALogger
var_dump($c->get('NeedsALogger'));

...wait, what?

  • NeedsALogger has a param of type LoggerInterface in its constructor
  • By default, PHP-DI comes with "autowiring"
  • Autowiring uses PHP's built-in reflection to see parameter types
  • We bound LoggerInterface to our Logger concrete implementation when configuring the container
  • This gave PHP-DI enough info to build out the class

A Note on Autowiring

use function DI\autowire;

$builder = new \DI\ContainerBuilder();
$builder->enableCompilation('/tmp');
$builder->addDefinitions([
    'LoggerInterface' => autowire('Logger'),
    'NeedsALogger' => autowire()
]);

$c = $builder->build();

 

var_dump($c->get('NeedsALogger')); // cached!

PHP-DI + FizzBuzz

require 'FizzBuzz.php';

$c = new \DI\Container();

foreach (range(1, 15) as $i) {
    echo $c->get('fizzBuzz')($i) . "\n";
}

Further reading for PHP-DI

Thanks! Questions?

Dependency Injection for Mere Humans - php[tek] 2023

By Ian Littman

Dependency Injection for Mere Humans - php[tek] 2023

What’s the difference between service location and dependency injection? Why is this dependency injection thing such a big deal anyway, and how do you use that tool correctly? I’ll answer these questions and more, including real-world examples of refactoring an application toward the more explicit, testable, closer-to-SOLID applications.

  • 477