4

I'm currently writing a small console application on the Symfony 2 framework. I'm attempting to insulate the application from the framework (mainly as an exercise after hearing some interesting talks on hexagonal architecture/ports and adaptors, clean code and decoupling applications from frameworks), so that it could potentially be run as a console application, a web application, or moved to another framework with little effort.

The issue I'm having is when one of my interfaces is implemented using the adaptor pattern and it depends on another interface that is also implemented using the adaptor pattern. It's difficult to describe and is probably best described with a code example. Here I've prefixed my class/interface names with "My", just to make it clear which code is my own (and I can edit) and which belongs to the Symfony framework.

// My code.

interface MyOutputInterface
{
    public function writeln($message);
}

class MySymfonyOutputAdaptor implements MyOutputInterface
{
    private $output;

    public function __construct(\Symfony\Component\Console\Output\ConsoleOutput $output)
    {
        $this->output = $output;
    }

    public function writeln($message)
    {
        $this->output->writeln($message)
    }
}

interface MyDialogInterface
{
    public function askConfirmation(MyOutputInterface $output, $message);
}

class MySymfonyDialogAdaptor implements MyDialogInterface
{
    private $dialog;

    public function __construct(\Symfony\Component\Console\Helper\DialogHelper $dialog)
    {
        $this->dialog = $dialog;
    }

    public function askConfirmation(MyOutputInterface $output, $message)
    {
        $this->dialog->askConfirmation($output, $message); // Fails: Expects $output to be instance of \Symfony\Component\Console\Output\OutputInterface
    }
}

// Symfony code.

namespace Symfony\Component\Console\Helper;

class DialogHelper
{
    public function askConfirmation(\Symfony\Component\Console\Output\OutputInterface $output, $question, $default = true)
    {
        // ...
    }
}

One extra thing to note is that \Symfony\Component\Console\Output\ConsoleOutput implements \Symfony\Component\Console\Output\OutputInterface .

To conform to MyDialogInterface, the MySymfonyDialogAdaptor::askConfirmation method must take an instance of MyOutputInterface as an argument. However, the call to Symfony's DialogHelper::askConfirmation method expects an instance of \Symfony\Component\Console\Output\OutputInterface, meaning the code won't run.

I can see a couple of ways around this, neither of which are particularly satisfactory:

  1. Have MySymfonyOutputAdaptor implement both MyOutputInterface and Symfony\Component\Console\Output\OutputInterface. This isn't ideal, as I'd need to specify all of the methods in that interface, when my application only really cares about the writeln method.

  2. Have MySymfonyDialogAdaptor assume that the object passed to it is an instance of MySymfonyOutputAdaptor: If it's not, then throw an exception. Then add a method to the MySymfonyOutputAdaptor class to obtain the underlying \Symfony\Component\Console\Output\ConsoleOutput object, which can be passed to Symfony's DialogHelper::askConfirmation method directly (as it implements Symfony's OutputInterface). This would look something like the following:

    class MySymfonyOutputAdaptor implements MyOutputInterface
    {
        private $output;
    
        public function __construct(\Symfony\Component\Console\Output\ConsoleOutput $output)
        {
            $this->output = $output;
        }
    
        public function writeln($message)
        {
            $this->output->writeln($message)
        }
    
        public function getSymfonyConsoleOutput()
        {
            return $this->output;
        }
    }
    
    class MySymfonyDialogAdaptor implements MyDialogInterface
    {
        private $dialog;
    
        public function __construct(\Symfony\Component\Console\Helper\DialogHelper $dialog)
        {
            $this->dialog = $dialog;
        }
    
        public function askConfirmation(MyOutputInterface $output, $message)
        {
            if (!$output instanceof MySymfonyOutputAdaptor) {
                throw new InvalidArgumentException();
            }
    
            $symfonyConsoleOutput = $output->getSymfonyConsoleOutput();
    
            $this->dialog->askConfirmation($symfonyConsoleOutput, $message);
        }
    }
    

    This feels wrong: If MySymfonyDialogAdaptor::askConfirmation has a requirement that its first argument is an instance of MySymfonyOutputAdaptor, it should specify it as its typehint, but that would mean it no longer implements MyDialogInterface. Also, accessing the underlying ConsoleOutput object outside of its own adaptor doesn't seem ideal, as it should really be wrapped by the adaptor.

Can anyone suggest a way around this? I feel like I'm missing something: Perhaps I'm putting adaptors in the wrong places and rather than multiple adaptors, I just need one adaptor wrapping the whole output/dialog system? Or maybe there's another inheritance layer I need to include in order to implement both interfaces?

Any advice appreciated.

EDIT: This issue is very similar to the one described in the following pull-request: https://github.com/SimpleBus/CommandBus/pull/2

ChrisC
  • 2,461
  • 1
  • 16
  • 25
  • You could try having your implementions that implement your own interface, also extend the symfony components that use them. That way you get your implemenation along with Symfony's, without any additional work. – Oddman Nov 09 '14 at 20:41

1 Answers1

2

After much discussion with colleagues (thanks Ian and Owen), plus some help from Matthias via https://github.com/SimpleBus/CommandBus/pull/2 , we've come up with the following solution:

<?php

// My code.

interface MyOutputInterface
{
    public function writeln($message);
}

class SymfonyOutputToMyOutputAdaptor implements MyOutputInterface
{
    private $output;

    public function __construct(\Symfony\Component\Console\Output\OutputInterface $output)
    {
        $this->output = $output;
    }

    public function writeln($message)
    {
        $this->output->writeln($message)
    }
}

class MyOutputToSymfonyOutputAdapter implements Symfony\Component\Console\Output\OutputInterface
{
    private $myOutput;

    public function __construct(MyOutputInterface $myOutput)
    {
        $this->myOutput = $myOutput;
    }

    public function writeln($message)
    {
        $this->myOutput->writeln($message);
    }

    // Implement all methods defined in Symfony's OutputInterface.
}

interface MyDialogInterface
{
    public function askConfirmation(MyOutputInterface $output, $message);
}

class MySymfonyDialogAdaptor implements MyDialogInterface
{
    private $dialog;

    public function __construct(\Symfony\Component\Console\Helper\DialogHelper $dialog)
    {
        $this->dialog = $dialog;
    }

    public function askConfirmation(MyOutputInterface $output, $message)
    {
        $symfonyOutput = new MyOutputToSymfonyOutputAdapter($output);

        $this->dialog->askConfirmation($symfonyOutput, $message);
    }
}

// Symfony code.

namespace Symfony\Component\Console\Helper;

class DialogHelper
{
    public function askConfirmation(\Symfony\Component\Console\Output\OutputInterface $output, $question, $default = true)
    {
        // ...
    }
}

I think the concept I was missing was that adaptors are essentially one-directional (e.g. from my code to Symfony's, or vice versa) and that I needed another separate adaptor to convert from MyOutputInterface back to Symfony's OutputInterface class.

This isn't completely ideal, as I still have to implement all of Symfony's methods in this new adaptor (MyOutputToSymfonyOutputAdapter), but this architecture does feel quite well-structured, as it is clear that each adaptor converts in one direction: I've renamed the adaptors accordingly to make this more clear.

Another alternative would be to fully implement only the methods that I wanted to support (just writeln in this example) and define the other methods to throw an exception to indicate they are unsupported by the adaptor if they are called.

Many thanks for the help all.

ChrisC
  • 2,461
  • 1
  • 16
  • 25