📜 ⬆️ ⬇️

Warehouse management system using CQRS and Event Sourcing. Service layer



This article will look at the Service Layer in Magento 2 and the services (API interfaces) for managing entities, which were described in a previous article devoted to the design and allocation of domain entities for the warehouse management system (Inventory).

Service layer


Since the warehouse management system we write on the Magento 2 platform, respectively, the services we enter will be described taking into account the features of this platform.
In Magento 2, to implement the principle of weak connectivity at the module level (within the Bounded Context), a Service Layer was introduced, which defines the set of available operations for each module in terms of interaction between customers and other modules of the system.

Service Layer (or Service Contracts) in Magento is a set of PHP interfaces that are defined for a module and are located in the Api folder of this module. Service contracts consist of Data Interfaces - DTO interfaces representing data from domain domain entities; and Service Interfaces - interfaces that provide access to business logic that can be called by the client (controller, web service REST / SOAP, PHP code of other modules).
')
Since it is assumed that all external clients of the module will work with it under the contracts described by the Service Layer, the Service Layer can actually be represented as Facade , which conceals the implementation details and complexity of the business logic behind it.
For clients, dependencies on well-defined APIs make it easier to upgrade to the next versions of the system, since the modules obey semantic versioning (Semantic Versioning).

For better modularity and decoupling, service contracts from implementation sometimes service contracts are separated into a separate module. For example, in the case of Inventory, we have two modules: one declares a set of InventoryAPI service interfaces, and the second provides an implementation for these interfaces - Inventory . Thus, a third-party developer who wants to replace the base implementation is no longer tied to this implementation in code. All it needs is interfaces, since other modules in the system depend on interfaces.

Repository Interfaces - Repository


Repositories are interfaces that provide a set of CRUD operations for entities.
A typical repository interface consists of a set of the following methods:
public function save(\Magento\Module\Api\Data\DataInterface $entityData); public function get($entityId); public function delete(\Magento\Module\Api\Data\DataInterface $entityData); public function deleteById($entityId); public function getList(SearchCriteriaInterface $searchCriteria); 

The set of methods may be narrower (if the domain entity is not characterized by certain operations. For example, deletion), but not broader, since it is not recommended to add methods with semantics different from the predefined set. Such methods are recommended to be placed in separate services.

Repositories can be perceived as Facades, which combine sets of methods for managing entities.

In the context of the Inventory module, repositories for the entities Source (the entity responsible for representing any physical warehouse where the goods may be located) and SourceItem (entity-bundle, represents the quantity of a specific SKU on a specific physical storage) appear.

 /** * This is Facade for basic operations with Source * There is no delete method, as Source can't be deleted from the system because we want to keep Order information for all orders placed. Sources can be disabled instead. * * Used fully qualified namespaces in annotations for proper work of WebApi request parser * * @api */ interface SourceRepositoryInterface { /** * Save Source data * * @param \Magento\InventoryApi\Api\Data\SourceInterface $source * @return int * @throws \Magento\Framework\Exception\CouldNotSaveException */ public function save(SourceInterface $source); /** * Get Source data by given sourceId. If you want to create plugin on get method, also you need to create separate * plugin on getList method, because entity loading way is different for these methods * * @param int $sourceId * @return \Magento\InventoryApi\Api\Data\SourceInterface * @throws \Magento\Framework\Exception\NoSuchEntityException */ public function get($sourceId); /** * Load Source data collection by given search criteria * * @param \Magento\Framework\Api\SearchCriteriaInterface $searchCriteria * @return \Magento\InventoryApi\Api\Data\SourceSearchResultsInterface */ public function getList(SearchCriteriaInterface $searchCriteria = null); } 

In the case of the SourceRepository, we do not have a delete method, because such a business operation on Source entities does not exist, since it is necessary to always save all the information related to placed orders (including where the goods were delivered from). Accordingly, it is necessary to prevent the possible loss of such data in the future (removing the Source from which delivery was performed). Instead, the operation is used - mark Source as inactive (disabled).

 /** * This is Facade for basic operations with SourceItem * * The method save is absent, due to different semantic (save multiple) * @see SourceItemSaveInterface * * There is no get method because SourceItem identifies by compound identifier (sku and source_id), * thus, it's needed to use getList() method * * Used fully qualified namespaces in annotations for proper work of WebApi request parser * * @api */ interface SourceItemRepositoryInterface { /** * Load Source Item data collection by given search criteria * * We need to have this method for direct work with Source Items, as Source Item contains * additional data like qty, status (can be searchable by additional field) * * @param \Magento\Framework\Api\SearchCriteriaInterface $searchCriteria * @return \Magento\InventoryApi\Api\Data\SourceItemSearchResultsInterface */ public function getList(SearchCriteriaInterface $searchCriteria); /** * Delete Source Item data * * @param SourceItemInterface $sourceItem * @return void * @throws \Magento\Framework\Exception\NoSuchEntityException * @throws \Magento\Framework\Exception\CouldNotDeleteException */ public function delete(SourceItemInterface $sourceItem); } 

Since the main usage scenarios for the save operation occur with the SourceItems collection, and not with one entity, as the standard save contract in the repository assumes.
For multiple storage , which can occur during import or synchronization of stock flows with external ERP or PIM systems, a separate SourceItemSaveInterface contract is introduced, which allows atomic saving of multiple SourceItem s in a single service call. Such a contract allows you to process an insert operation using a single query into the database, which will significantly speed up processing. The basic save operation that accepts a single entity is not added to the repository contract in order not to add several points for expansion, since in fact in this case the third-party developer will have to pliginate both save operations (single and multiple) . Therefore, customization of one expansion point always looks preferable.

The multiple save command contract looks like Magento \ InventoryApi \ Api \ SourceItemSaveInterface

 /** * Service method for source items save multiple * Performance efficient API, used for stock synchronization * * Used fully qualified namespaces in annotations for proper work of WebApi request parser * * @api */ interface SourceItemSaveInterface { /** * Save Multiple Source item data * * @param \Magento\InventoryApi\Api\Data\SourceItemInterface[] $sourceItems * @return void * @throws \Magento\Framework\Exception\InputException * @throws \Magento\Framework\Exception\CouldNotSaveException */ public function execute(array $sourceItems); } 

And its implementation, SourceItemSave, delegates the saving of the SaveMultiple model resource .

Also, there is no get () method in the SourceItemRepository, since SourceItem is an entity-bundle and is defined by a composite identifier (SKU and SourceId).

The repository for Stock (Virtual Aggregations of Entities) looks standard:

 interface StockRepositoryInterface { /** * Save Stock data * * @param \Magento\InventoryApi\Api\Data\StockInterface $stock * @return int * @throws \Magento\Framework\Exception\CouldNotSaveException */ public function save(StockInterface $stock); /** * Get Stock data by given stockId. If you want to create plugin on get method, also you need to create separate * plugin on getList method, because entity loading way is different for these methods * * @param int $stockId * @return \Magento\InventoryApi\Api\Data\StockInterface * @throws \Magento\Framework\Exception\NoSuchEntityException */ public function get($stockId); /** * Find Stocks by given SearchCriteria * * @param \Magento\Framework\Api\SearchCriteriaInterface|null $searchCriteria * @return \Magento\InventoryApi\Api\Data\StockSearchResultsInterface */ public function getList(SearchCriteriaInterface $searchCriteria = null); /** * Delete the Stock data by stockId. If stock is not found do nothing * * @param int $stockId * @return void * @throws \Magento\Framework\Exception\CouldNotDeleteException */ public function deleteById($stockId); } 

Services for source and stock mapping


Guided by the rule "to reduce the amount of boilerplate code in the API client (in business logic code), we do not enter the Data interface SourceStockLinkInterface. Instead, we introduce a set of domain service commands to assign the Source to the Stock.

As a result, we get three commands:

 interface AssignSourcesToStockInterface { /** * Assign list of source ids to stock * * @param int $stockId * @param int[] $sourceIds * @return void * @throws \Magento\Framework\Exception\InputException * @throws \Magento\Framework\Exception\CouldNotSaveException */ public function execute(array $sourceIds, $stockId); } interface GetAssignedSourcesForStockInterface { /** * Get Sources assigned to Stock * * @param int $stockId * @return \Magento\InventoryApi\Api\Data\SourceInterface[] * @throws \Magento\Framework\Exception\InputException * @throws \Magento\Framework\Exception\LocalizedException */ public function execute($stockId); } interface UnassignSourceFromStockInterface { /** * Unassign source from stock * * @param int $sourceId * @param int $stockId * @return void * @throws \Magento\Framework\Exception\InputException * @throws \Magento\Framework\Exception\CouldNotDeleteException */ public function execute($sourceId, $stockId); } 

API vs SPI


Within this project, it was decided to explicitly separate the API (Application Programming Interface) from the SPI ( Service Provider Interfaces ) in order to improve the extensibility and reduce the connectivity of the components.


Thus, for example, the implementation of the Magento \ Inventory \ Model \ StockRepository repository looks like this:

 /** * @inheritdoc */ class StockRepository implements StockRepositoryInterface { /** * @var SaveInterface */ private $commandSave; /** * @var GetInterface */ private $commandGet; /** * @var DeleteByIdInterface */ private $commandDeleteById; /** * @var GetListInterface */ private $commandGetList; /** * @param SaveInterface $commandSave * @param GetInterface $commandGet * @param DeleteByIdInterface $commandDeleteById * @param GetListInterface $commandGetList */ public function __construct( SaveInterface $commandSave, GetInterface $commandGet, DeleteByIdInterface $commandDeleteById, GetListInterface $commandGetList ) { $this->commandSave = $commandSave; $this->commandGet = $commandGet; $this->commandDeleteById = $commandDeleteById; $this->commandGetList = $commandGetList; } /** * @inheritdoc */ public function save(StockInterface $stock) { $this->commandSave->execute($stock); } /** * @inheritdoc */ public function get($stockId) { return $this->commandGet->execute($stockId); } /** * @inheritdoc */ public function deleteById($stockId) { $this->commandDeleteById->execute($stockId); } /** * @inheritdoc */ public function getList(SearchCriteriaInterface $searchCriteria = null) { return $this->commandGetList->execute($searchCriteria); } } 

The constructor accepts a set of command interfaces for each of the operations provided. And during a call to a public method from the repository, the call is proxied to the appropriate command.

SPI command interfaces are as follows:

 /** * Save Stock data command (Service Provider Interface - SPI) * * Separate command interface to which Repository proxies initial Save call, could be considered as SPI - Interfaces * so that you should extend and implement to customize current behaviour, but NOT expected to be used (called) in the code * of business logic directly * * @see \Magento\InventoryApi\Api\StockRepositoryInterface * @api */ interface SaveInterface { /** * Save Stock data * * @param StockInterface $stock * @return int * @throws CouldNotSaveException */ public function execute(StockInterface $stock); } /** * Get Stock by stockId command (Service Provider Interface - SPI) * * Separate command interface to which Repository proxies initial Get call, could be considered as SPI - Interfaces * that you should extend and implement to customize current behavior, but NOT expected to be used (called) in the code * of business logic directly * * @see \Magento\InventoryApi\Api\StockRepositoryInterface * @api */ interface GetInterface { /** * Get Stock data by given stockId * * @param int $stockId * @return StockInterface * @throws NoSuchEntityException */ public function execute($stockId); } /** * Delete Stock by stockId command (Service Provider Interface - SPI) * * Separate command interface to which Repository proxies initial Delete call, could be considered as SPI - Interfaces * that you should extend and implement to customize current behaviour, but NOT expected to be used (called) in the code * of business logic directly * * @see \Magento\InventoryApi\Api\StockRepositoryInterface * @api */ interface DeleteByIdInterface { /** * Delete the Stock data by stockId. If stock is not found do nothing * * @param int $stockId * @return void * @throws CouldNotDeleteException */ public function execute($stockId); } /** * Find Stocks by SearchCriteria command (Service Provider Interface - SPI) * * Separate command interface to which Repository proxies initial GetList call, could be considered as SPI - Interfaces * that you should extend and implement to customize current behaviour, but NOT expected to be used (called) in the code * of business logic directly * * @see \Magento\InventoryApi\Api\StockRepositoryInterface * @api */ interface GetListInterface { /** * Find Stocks by given SearchCriteria * * @param SearchCriteriaInterface|null $searchCriteria * @return StockSearchResultsInterface */ public function execute(SearchCriteriaInterface $searchCriteria = null); } 

These commands represent the SPI module interfaces and are under the namespace.

 Magento\Inventory\Model\Stock\Command\* 

Command implementations are as follows ( Magento \ Inventory \ Model \ Stock \ Command \ * ). For example, the Stock save command:

 /** * @inheritdoc */ class Save implements SaveInterface { /** * @var StockResourceModel */ private $stockResource; /** * @var LoggerInterface */ private $logger; /** * @param StockResourceModel $stockResource * @param LoggerInterface $logger */ public function __construct( StockResourceModel $stockResource, LoggerInterface $logger ) { $this->stockResource = $stockResource; $this->logger = $logger; } /** * @inheritdoc */ public function execute(StockInterface $stock) { try { $this->stockResource->save($stock); return $stock->getStockId(); } catch (\Exception $e) { $this->logger->error($e->getMessage()); throw new CouldNotSaveException(__('Could not save Stock'), $e); } } } 

Product Backup Mechanism


The reservation object is created in order to have the current level of goods that we have for sale between the events of order creation and the decrease in the number of goods in specific physical warehouses.
Implementing a business scenario of placing an order, described in detail in the previous part .

We enter the Data Interface for backup

 /** * The entity responsible for reservations, created to keep inventory amount (product quantity) up-to-date. * It is created to have a state between order creation and inventory deduction (deduction of specific SourceItems) * * @api */ interface ReservationInterface extends ExtensibleDataInterface { /** * Constants for keys of data array. Identical to the name of the getter in snake case */ const RESERVATION_ID = 'reservation_id'; const STOCK_ID = 'stock_id'; const SKU = 'sku'; const QUANTITY = 'quantity'; const STATUS = 'status'; /**#@+ * Reservation possible statuses. */ const STATUS_OPEN = 1; const STATUS_CLOSED = 2; /**#@-*/ /** * Get Reservation id * * @return int|null */ public function getReservationId(); /** * Get stock id * * @return int */ public function getStockId(); /** * Get Product SKU * * @return string */ public function getSku(); /** * Get Product Qty * * @return float */ public function getQuantity(); /** * Get Reservation Status * * @return int */ public function getStatus(); } 

Since we perceive the reservation as an Append-Only immutable entity , we do not need modifiers (setter methods) in the ReservationInterface. Accordingly, we need ReservationBuilderInterface in order to create reservation objects.

 $reservationBuilder->setStockId(1); $reservationBuilder->setSku('sku'); $reservationBuilder->setQty(10); $newReservation = $reservationBuilder->build(); //now we could save Reservation entity $reservationAppend->execute([$newReservation]); 

Object Reservation Services


The service of adding reservations (bookings) is used during order placement, order processing or order cancellation. As well as creating and processing the return of goods. At this time, a stack of reservations is created, one reservation per SKU, and added using this service for processing.

 /** * Command which appends reservations when order placed or canceled * * @api */ interface ReservationAppend { /** * Append reservations when Order Placed (or Cancelled) * * @param Reservation[] $reservations * @return void * @throws \Magento\Framework\Exception\InputException * @throws \Magento\Framework\Exception\CouldNotSaveException */ public function execute(array $reservations); } 

The following service is used to calculate the exact quantity (Quantity) of goods available for sale, as Quantity StockItem-a is updated with a latency caused by nature of Event Sourcing, because during placing an order, the system works with StockItem entity (virtual aggregation) and does not know from which physical warehouses (Source) will be written off. Thus, between the operation of placing an order and processing, it may take some time.

 /** * Command which returns Reservation Quantity by Product SKU and Stock * * @api */ interface GetReservationQuantityForProduct { /** * Get Reservation Quantity for given SKU in a given Stock * * @param string $sku * @param int $stockId * @return float */ public function execute($sku, $stockId); } 

Each reservation can be open or closed .
Since the reservation is an immutable object that cannot be changed. Instead of changing the state of the reservation, we simply create a second reserve, which “dampens” the cancellation of the first one.
For example,
placing an order for 30 units of goods we create reservations:
ReservationID - 1, StockId - 1, SKU - SKU-1, Qty - ( -30 ), Status - OPEN
Having processed this order - we create another reservation.
ReservationID - 2, StockId - 1, SKU - SKU-1, Qty - ( +30 ), Status - CLOSED

In total, these two reserves (-30) + (+30) = 0 will not affect the Quantity, which is stored in StockItem.
Here it is important to note two things: we do not introduce a connection (binding) between the reservation object and the Order (Order), since the reserve can be attached to other business operations. And from the point of view of the warehouse (Inventory), the order number is not important to us, within which we need to ship the goods and reduce the stock.
Using negative and positive values ​​for reservations will help us to simplify the calculation of the total number we need to subtract from the Quantity stored in StockItem.

For example, using this query:

 select SUM(r.qty) as total_reservation_qty from Reservations as r where stockId = {%id%} and sku = {%sku%} 

Magento MSI (Multi Source Inventory)


This article is the third article in the “Warehouse Management System Using CQRS and Event Sourcing” cycle within which the collection of requirements, design and development of a warehouse management system will be considered using Magento 2 as an example.

An open project, where development is underway, and where engineers from the community are involved, as well as where you can get acquainted with the current state of the project and documentation, is available here

More detailed documentation on

Source: https://habr.com/ru/post/335636/


All Articles