Было бы круто отображать бизнес-процессы в архитектуре и реагировать на это без существенного увеличения сложности.
Набор подходов для организации кода в системах со сложной предметной областью
Сущности и действия реального мира, которые нужно автоматизировать
Работа с доменом начинается с анализа предметной области и составления требований
Создаём счёт, добавляем в него товары и выставляем его клиенту.
Набор терминов, применяемых в модели домена для объяснения связей и действий
Создаём счёт, добавляем в него товары и выставляем его клиенту.
class BillController { public function actionCreate($client_id, array $item_ids) { $client = Client::find()->byId($client_id)->one(); if (!$client) { throw new NotFoundException('No client'); } $bill = new Bill(); $bill->client = $client; $bill->status = 'new'; $items = Item::find()->byId($item_ids)->all(); if (!$items) { throw new NotFoundException('No items'); } $bill->items = $items; foreach ($bill->items as $item) { $bill->sum += $item->price; } $bill->save(); $this->notifyBillCreated($client->email, $bill); $this->log->info('Created bill ' . $bill->id); return $this->render('bill', ['bill' => $bill]); } }
Само собой, товары в счёт можно добавлять и удалять, если счёт еще не выставлен
class BillController { public function actionAddItem($bill_id, $item_id = null) { $bill = Bill::find()->byId($bill_id)->one(); if (!$bill) { throw new NotFoundException('No bill'); } if ($bill->status !== 'new') { throw new HttpException(403); } $item = Item::find()->byId($item_id)->one(); if (!$item) { throw new NotFoundException('No item'); } $bill->items[] = $item; $bill->calculateSum(); $this->log->info('Updated bill ' . $bill->id); $bill->save(); return $this->render('bill', ['bill' => $bill]); } }
class Client { private $id; private $name; private $address; private $phone; public function __construct($id, $name, $address, $phone) { if (!preg_match('/\+\d+/', $phone)) { throw new ValidationException($phone); } $this->phone = $phone; // ... name, address } }
new Client( UUID::create(), 'Фамилия Имя Отчество', 'ул. Кодеров, д. 0xFF', '+380441234567' );
class Address { private $country; private $city; private $zip; private $lines; public function __construct($country, $city, $zip, $lines) { $this->country = $this->validateCountry($country); $this->city = $this->validateCity($city); $this->zip = $this->validatePhone($zip); $this->lines = $this->validateLines($lines); } private function validateZip($zip) { if (!preg_match('/^\d+$/', $zip) { throw new ZipValidationException($zip); } return $zip; } // ... }
new Client( UUID::create(), new Name('Фамилия', 'Имя', 'Отчество'), new Address('Украина', 'Киев', '01001', ['ул. Кодеров, д. 0xFF']), new Phone('380', '44', '1234567') );
Каждого товара может быть больше чем 1 единица в счёте!
class Item { private $id; private $name; private $description; private $price; public function __construct($id, $name, Money $price) { $this->id = $id; $this->name = $name; $this->price = $price; } public function getId() {}; public function getDescription() {}; public function setDescription($description) {}; public function getPrice() {}; public function setPrice(Money $price) {}; public function getName() {}; public function setName() {}; }
$item = new Item(UUID::create(), 'Silver bullet', Money::USD(1000)); $item->setDescription('This bullet is a killing feature!');
class Position { protected $item; protected $quantity; public function __construct(Item $item, $quantity = 1) { $this->item = $item; $this->quantity = $quantity; } public function getItem() {} public function getQuantity() {} public function setQuantity($quantity) {} public function getPrice() {} public function getAmount() {} }
class Bill { protected $id; protected $client; protected $positions; protected $status; public function __construct($id, Client $client) { $this->id = $id; $this->client = $client; } public function getId() {} public function getClient() {} public function getPositions() {} public function setPositions() {} public function getStatus() {} public function setStatus($status) {} }
$item = new Item(UUID::create(), 'Silver bullet', Money::USD(1000)); $quantity = 2; $bill = new Bill(UUID::create(), $client); $bill->setStatus('new'); $bill->setPositions([new Position($item, $quantity)]);
// Заменим позицию в счёте $anotherPosition = new Position( new Item(UUID::create(), 'Rage cup', Money::USD(500)) ); $bill->setPositions([$anotherPosition]);
$bill->setPositions([$anotherPosition]); $bill->setStatus('processing'); // VS $bill->positions = [$anotherPosition]; $bill->status = 'processing';
abstract class Status { protected $name; public function getName() { return $this->name; } protected $next = []; public function canBeChangedTo(self $status) { return in_array(get_class($status), $this->next, true); } public function allowsModification() { return true; } }
class NewStatus extends Status { protected $name = 'new'; protected $next = [ProcessingStatus::class, RejectedStatus::class]; }
class Bill { public function changeStatus(Status $status) { if (!$this->status->canBeChangedTo($status)) { throw new WrongStatusChangeDirection(); } $this->status = $status; } public function addPosition(Position $position) { if (!$this->status->allowsModification()) { throw new ModificationProhibitedException(); } $this->positions->push($position); } public function removePosition(Position $position) { // ... } }
abstract class Status { // ... public function ensureCanBeChangedTo(self $status) { if (!$this->canBeChangedTo($status)) { throw new WrongStatusChangeDirectionException(); } } public function ensureAllowsModification() { if (!$this->allowsModification()) { throw new ModificationProhibitedException(); } } }
class Bill { public function changeStatus(Status $status) { $this->status->ensureCanBeChangedTo($status); $this->status = $status; } public function addPosition(Position $position) { $this->status->ensureAllowsModification(); $this->positions->push($position); } public function removePosition(Position $position) { $this->status->ensureAllowsModification(); // ... } }
$bill = new Bill(UUID::create(), $client); $bill->addPosition(new Position($item, $quantity)); $bill->addPosition($anotherPosition); $bill->changeStatus(new ProcessingStatus()); // save...
interface BillRepositoryInterface { /** @throws BillNotFoundException when no bill exists */ public function findById($id); public function findByStatus(Status $status); public function insert(Bill $bill); public function update(Bill $bill); }
class SqlBillRepository implements BillRepositoryInterface { public function __construct(Connection $db) { $this->db = $db; } public function create(Bill $bill) { $this->db->transactionBegin(); $this->db->insert('bill', [ 'id' => $bill->getId(), 'client_id' => $bill->getClient()->getId(), 'status' => $bill->getStatus() ]); foreach ($bill->getPositions() as $position) { $this->db->insert('bill_position', [ 'item_id' => $position->getItem()->getId(), 'quantity' => $position->getQuantity(), ]); } $this->db->transactionCommit(); } }
class BillController { public function actionCreate($client_id) { $client = $this->clientRepository->findById($client_id); $bill = new Bill(UUID::create(), $client); $bill->changeStatus(new NewStatus()); $this->billRepository->create($bill); $this->sendEmail(...); return $bill->getId(); } }
class BillServiceInterface { public function create($clientId); public function addPosition($id, $itemId, $quantity); public function reject($id); public function process($id); // ... }
class BillService { public function __construct( ClientRepositoryInterface $clientRepository, BillRepositoryInterface $billRepository ) { $this->clientRepository = $clientRepository; $this->billRepository = $billRepository; } public function create($clientId) { $client = $this->clientRepository->findById($clientId); $bill = new Bill(UUID::create(), $client); $bill->changeStatus(new NewStatus()); $this->billRepository->create($bill); return $bill->getId(); } }
class BillController { public function actionCreate($client_id) { $billId = $this->billService->create($client_id); return $billId; } }
Что если метод сервиса получается сложным?
class BillService { public function addPosition($billId, $itemId, $quantity) { $bill = $this->billRepository->findById($billId); $item = $this->itemRepository->findById($itemId); $bill->addPosition($item, $quantity); foreach ($bill->getPositions() as $position) { // Merge positions of the same item } $this->billRepository->update($bill); } }
class BillOptimizerService { public function mergePositions($bill) { $this->findSimilarPositions($bill); } private function findSimilarPositions($bill) { // ... } }
Что если метод сервиса получается сложным?
class BillService { public function addPosition($billId, $itemId, $quantity) { $bill = $this->billRepository->findById($billId); $item = $this->itemRepository->findById($itemId); $bill->addPosition($item, $quantity); $this->billOptimizerService->mergePositions($bill); $this->billRepository->update($bill); } }
class BillPositionsMerger { public function __construct(Bill $bill) { $this->bill = $bill; } public function run() { while ($pairs = $this->findSimilarPositions()) { foreach ($pairs as $pair) { $this->mergePositions($pair); } } } private function findSimilarPositions() { ... } private function mergePositions($pair) { ... } }
Что если метод сервиса получается сложным?
class BillService { public function addPosition($billId, $itemId, $quantity) { $bill = $this->billRepository->findById($billId); $item = $this->itemRepository->findById($itemId); $bill->addPosition($item, $quantity); (new BillPositionMerger($bill))->run(); $this->billRepository->update($bill); } }
class BillService { public function reject($billId) { if (($bill = $this->billRepository->findById($id)) === null) { return null; } $bill->changeStatus(new RejectedStatus()); if ($this->billRepository->update($bill) !== false) { // аккуратно, там ^^ в случае ошибки возвращается false! return null; } return true; } }
class BillService { function reject() { $bill = $this->billRepository->findById($id); $bill->changeStatus(new ProcessingStatus()); $this->billRepository->update($bill); return true; } }
class Bill { public function changeStatus(Status $status) { $this->status->ensureCanBeChangedTo($status); $this->status = $status; $this->registerEvent( new BillStatusWasChangedEvent($bill, $status) ); } public function registerEvent($event) { // TODO: можно вынести в trait $this->events[] = $event; } public function releaseEvents() { $events = $this->events; $this->events = []; return $events; } }
class BillService { public function reject($id) { $bill = $this->billRepository->findById($id); $bill->changeStatus(new ProcessingStatus()); $this->billRepository->update($bill); $this->dispatcher->dispatchEvents($bill->releaseEvents()); } }
class ClientService { public function changeAddress($id, $country, $city, $zip, $lines) { $client = $this->clientRepository->findById($id); $address = new Address($country, $city, $zip, $lines); $client->changeAddress($address); $this->clientRepository->update($client); } }
class AddressDto { public $country; public $city; public $zip; public $lines = []; public function load($data) { $this->country = $data['country']; // ... } public static function fromRequest($request) { return (new self())->load($request->post()); } }
class ClientController { public function actionChangeAddress($client_id) { $dto = AddressDto::fromRequest($this->request); $this->clientRepository->changeAddress($client_id, $dto); return $client_id; } }
class ClientService { public function changeAddress($id, AddressDto $dto) { $client = $this->clientRepository->findById($id); $address = new Address( $dto->country, $dto->city, $dto->zip, $dto->lines ); $client->changeAddress($address); $this->clientRepository->update($client); } }