Architecture
For the GraphQL modules we choose a hexagonal architecture, also known as ports and adapters. In case you see some way to improve this implementation, feel free to ping us or make a pull request any time.
If you are interested in understanding the overall picture, Zvonimir Spajic created a blog post about demystifing hexagonal architecture.
The file system hierarchy in the src
directory looks similar to this
├── ..
├── Shared
│ └── Infrastructure
│ └── Repository.php
└── Category
├── Controller
│ └── Category.php
├── DataType
│ ├── CategoryFilterList.php
│ └── Category.php
├── Exception
│ └── CategoryNotFound.php
└── Service
├── Category.php
└── RelationService.php
The folders in the src
directory are the contexts in which we are working. All models currently have a Shared context and then a context for every entity we need to expose via the GraphQL API.
Controller
The sole purpose of the controller is to be a slim layer which translates from the incoming HTTP request to our business logic. As we are using GraphQLite and graphql-php our controllers don’t need any kind of validation, as all this is done in those libraries. This on the other hand is relying on the controller methods to use the correct type hints.
So most controllers look like this example
<?php
declare(strict_types=1);
namespace Full\Qualified\Namespace\Context\Controller;
use Full\Qualified\Namespace\Context\DataType\Category as CategoryDataType;
use Full\Qualified\Namespace\Context\Service\Category as CategoryService;
use TheCodingMachine\GraphQLite\Annotations\Query;
final class Category
{
/** @var CategoryService */
private $categoryService;
public function __construct(
CategoryService $categoryService
) {
$this->categoryService = $categoryService;
}
/**
* @Query()
*/
public function category(string $id): CategoryDataType
{
return $this->categoryService->category($id);
}
}
Business
The data types in DataType directory and services in Service directory belong to the business layer. This is where the business rules are implemented, business rules as for example in “is the currently authenticated user allowed to see this entity”.
Data types
Classes in DataType are a facade to the OXID eShop models in our case and wrap them, also they define the fields of our data types in the GraphQL API. They are allowed to call methods on OXID eShop models directly, although not part of the infrastructure layer. We are aware, that this violates the hexagonal approach, but yet struggle to come up with a good way to deal with this.
A sample data type might look like this
<?php
declare(strict_types=1);
namespace Full\Qualified\Namespace\Context\DataType;
use DateTimeImmutable;
use DateTimeInterface;
use Exception;
use OxidEsales\Eshop\Application\Model\Category as CategoryModel;
use OxidEsales\GraphQL\Storefront\Shared\DataType\DataType;
use TheCodingMachine\GraphQLite\Annotations\Field;
use TheCodingMachine\GraphQLite\Annotations\Type;
use TheCodingMachine\GraphQLite\Types\ID;
/**
* @Type()
*/
final class Category implements DataType
{
/** @var CategoryModel */
private $category;
public function __construct(CategoryModel $category)
{
$this->category = $category;
}
public function getEshopModel(): CategoryModel
{
return $this->category;
}
/**
* @Field()
*/
public function getId(): ID
{
return new ID(
$this->category->getId()
);
}
/**
* @Field()
*/
public function isActive(?DateTimeInterface $now = null): bool
{
$active = (bool) $this->category->getFieldData('oxactive');
if ($active) {
return true;
}
$from = new DateTimeImmutable(
(string) $this->category->getFieldData('oxactivefrom')
);
$to = new DateTimeImmutable(
(string) $this->category->getFieldData('oxactiveto')
);
$now = $now ?? new DateTimeImmutable('now');
if ($from <= $now && $to >= $now) {
return true;
}
return false;
}
}
Services
Services are to be called from the controllers to retrieve the entities in question. It might be tempting, but they are not allowed to talk to the persistence (e.g. Database) directly. A service can use the infrastructure layer to fetch an entity and decide whether it can be returned to the caller or not based on rules to be implemented in the service itself.
A sample service might look like this
<?php
declare(strict_types=1);
namespace Full\Qualified\Namespace\Context\DataType;
use OxidEsales\GraphQL\Base\Exception\InvalidLogin;
use OxidEsales\GraphQL\Base\Exception\NotFound;
use OxidEsales\GraphQL\Base\Service\Authorization;
use Full\Qualified\Namespace\Context\DataType\Category as CategoryDataType;
use Full\Qualified\Namespace\Context\Exception\CategoryNotFound;
use OxidEsales\GraphQL\Storefront\Shared\Infrastructure\Repository;
final class Category
{
/** @var Repository */
private $repository;
/** @var Authorization */
private $authorizationService;
public function __construct(
Repository $repository,
Authorization $authorizationService
) {
$this->repository = $repository;
$this->authorizationService = $authorizationService;
}
/**
* @throws CategoryNotFound
*/
public function category(string $id): CategoryDataType
{
try {
/** @var CategoryDataType $category */
$category = $this->repository->getById($id, CategoryDataType::class);
} catch (NotFound $e) {
throw CategoryNotFound::byId($id);
}
if ($category->isActive()) {
return $category;
}
if (!$this->authorizationService->isAllowed('VIEW_INACTIVE_CATEGORY')) {
throw new InvalidLogin('Unauthorized');
}
return $category;
}
}
Additionally to this, we put the relation services for the data types into the Service
directory. Those are using the @ExtendType
annotation an need to exist in the DI container under their fully qualified class name. Those relation services do not call OXID models directly, but only through the infrastructure layer.
Infrastructure
The purpose of the infrastructure layer is to fetch entities from the persistence, in our case mostly the database itself. For most of our cases we can just use the repository from the graphql-storefront module.
You are also free to create your own repository or infrastructure service to communicate with the OXID eShop.
Reasons
for Hexagonal approach
Whenever something in the OXID eShop changes we only need to adapt it in the infrastructure layer (and in this case the data types). But there is no need to search through a lot of files, we know pretty fast where things are going wrong and need to be changed.
for wrapping OXID eShop models
We wanted to have the GraphQL API behave consistent to the (well known) existing system. The OXID eShop models are the central point which standard system and the GraphQL modules can share.
One of the main advantages to use OXID eShop models through the oxNew()
call is that the GraphQL API then also reflects the changes your modules introduce. This is also the reason, why we use the specific getters and only fall back to using getFieldData()
if no specific getter is available.
for making classes final
You might have noticed, that all classes in the GraphQL modules are final
. Therefore they can not be extended through OXID’s module chain. It is technically not possible to extend data types by extending the class in the usuall PHP way anyway and we favour composition over inheritance.
The final
class declaration makes this concept explicit!