Step 00 — Squelette + intégration naïve

3 Bounded Contexts (Sales, Invoicing, LegacyFulfillment) avec :
- Domaines complets (agrégats, VOs, événements, invariants)
- Couche application (commands, queries, ports)
- Infrastructure in-memory (repos, gateway fake)
- Controllers HTTP Symfony
- Couplage naïf synchrone entre BC via NaiveSalesEventPublisher
- 20 tests unitaires et d'intégration passants
This commit is contained in:
2026-03-04 00:27:15 +01:00
commit a4a14e441b
86 changed files with 7059 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace MiniShop\Invoicing\Application\Command;
final readonly class IssueInvoiceForExternalOrder
{
/**
* @param list<array{description: string, quantity: int, unitPriceInCents: int, currency: string}> $lines
*/
public function __construct(
public string $externalOrderId,
public string $customerName,
public string $customerAddress,
public array $lines,
) {}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace MiniShop\Invoicing\Application\Command;
use MiniShop\Invoicing\Application\Port\InvoiceNumberGenerator;
use MiniShop\Invoicing\Application\Port\InvoiceRepository;
use MiniShop\Invoicing\Domain\Model\BillingParty;
use MiniShop\Invoicing\Domain\Model\Invoice;
use MiniShop\Invoicing\Domain\Model\InvoiceId;
use MiniShop\Invoicing\Domain\Model\InvoiceLine;
use MiniShop\Invoicing\Domain\Model\TaxRate;
use MiniShop\Shared\Technical\Clock;
use MiniShop\Shared\Technical\UuidGenerator;
final readonly class IssueInvoiceForExternalOrderHandler
{
public function __construct(
private InvoiceRepository $invoiceRepository,
private InvoiceNumberGenerator $invoiceNumberGenerator,
private Clock $clock,
) {}
public function __invoke(IssueInvoiceForExternalOrder $command): void
{
$lines = array_map(
static fn (array $line): InvoiceLine => new InvoiceLine(
$line['description'],
$line['quantity'],
$line['unitPriceInCents'],
$line['currency'],
TaxRate::standard(),
),
$command->lines,
);
$invoice = Invoice::issueForExternalOrder(
InvoiceId::fromString(UuidGenerator::generate()),
$this->invoiceNumberGenerator->next(),
$command->externalOrderId,
new BillingParty($command->customerName, $command->customerAddress),
$lines,
$this->clock->now(),
);
$this->invoiceRepository->save($invoice);
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace MiniShop\Invoicing\Application\Command;
final readonly class MarkInvoiceAsSent
{
public function __construct(
public string $invoiceId,
) {}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace MiniShop\Invoicing\Application\Command;
use MiniShop\Invoicing\Application\Port\InvoiceRepository;
use MiniShop\Invoicing\Domain\Model\InvoiceId;
final readonly class MarkInvoiceAsSentHandler
{
public function __construct(
private InvoiceRepository $invoiceRepository,
) {}
public function __invoke(MarkInvoiceAsSent $command): void
{
$invoice = $this->invoiceRepository->get(InvoiceId::fromString($command->invoiceId));
$invoice->markAsSent();
$this->invoiceRepository->save($invoice);
}
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace MiniShop\Invoicing\Application\Port;
interface InvoiceNumberGenerator
{
public function next(): string;
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace MiniShop\Invoicing\Application\Port;
use MiniShop\Invoicing\Domain\Model\Invoice;
use MiniShop\Invoicing\Domain\Model\InvoiceId;
interface InvoiceRepository
{
public function save(Invoice $invoice): void;
public function get(InvoiceId $id): Invoice;
public function findByExternalOrderId(string $externalOrderId): ?Invoice;
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace MiniShop\Invoicing\Application\Query;
final readonly class GetInvoiceByOrderRef
{
public function __construct(
public string $externalOrderId,
) {}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace MiniShop\Invoicing\Application\Query;
use MiniShop\Invoicing\Application\Port\InvoiceRepository;
use MiniShop\Invoicing\Domain\Model\Invoice;
final readonly class GetInvoiceByOrderRefHandler
{
public function __construct(
private InvoiceRepository $invoiceRepository,
) {}
public function __invoke(GetInvoiceByOrderRef $query): ?Invoice
{
return $this->invoiceRepository->findByExternalOrderId($query->externalOrderId);
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace MiniShop\Invoicing\Domain\Event;
use MiniShop\Invoicing\Domain\Model\InvoiceId;
final readonly class InvoiceIssued
{
public function __construct(
public InvoiceId $invoiceId,
public string $invoiceNumber,
public string $externalOrderId,
public int $totalInclTaxInCents,
public \DateTimeImmutable $issuedAt,
) {}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace MiniShop\Invoicing\Domain\Model;
final readonly class BillingParty
{
public function __construct(
public string $name,
public string $address,
) {}
}

View File

@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace MiniShop\Invoicing\Domain\Model;
use MiniShop\Invoicing\Domain\Event\InvoiceIssued;
final class Invoice
{
private InvoiceStatus $status;
/** @var list<object> */
private array $domainEvents = [];
/**
* @param list<InvoiceLine> $lines
*/
private function __construct(
public readonly InvoiceId $id,
public readonly string $invoiceNumber,
public readonly string $externalOrderId,
public readonly BillingParty $billingParty,
private readonly array $lines,
private readonly \DateTimeImmutable $issuedAt,
) {
$this->status = InvoiceStatus::Issued;
}
/**
* @param list<InvoiceLine> $lines
*/
public static function issueForExternalOrder(
InvoiceId $id,
string $invoiceNumber,
string $externalOrderId,
BillingParty $billingParty,
array $lines,
\DateTimeImmutable $issuedAt,
): self {
if ($lines === []) {
throw new \DomainException('An invoice must have at least one line.');
}
$invoice = new self($id, $invoiceNumber, $externalOrderId, $billingParty, $lines, $issuedAt);
$invoice->recordEvent(new InvoiceIssued(
$id,
$invoiceNumber,
$externalOrderId,
$invoice->totalInclTax(),
$issuedAt,
));
return $invoice;
}
public function markAsSent(): void
{
if ($this->status === InvoiceStatus::Sent) {
throw new \DomainException('Invoice is already sent.');
}
$this->status = InvoiceStatus::Sent;
}
public function totalExclTax(): int
{
return array_sum(array_map(
static fn (InvoiceLine $line): int => $line->lineTotalExclTax(),
$this->lines,
));
}
public function totalInclTax(): int
{
return array_sum(array_map(
static fn (InvoiceLine $line): int => $line->lineTotalInclTax(),
$this->lines,
));
}
public function status(): InvoiceStatus
{
return $this->status;
}
/** @return list<InvoiceLine> */
public function lines(): array
{
return $this->lines;
}
public function issuedAt(): \DateTimeImmutable
{
return $this->issuedAt;
}
/** @return list<object> */
public function releaseEvents(): array
{
$events = $this->domainEvents;
$this->domainEvents = [];
return $events;
}
private function recordEvent(object $event): void
{
$this->domainEvents[] = $event;
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace MiniShop\Invoicing\Domain\Model;
final readonly class InvoiceId
{
private function __construct(public string $value) {}
public static function fromString(string $value): self
{
return new self($value);
}
public function toString(): string
{
return $this->value;
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace MiniShop\Invoicing\Domain\Model;
final readonly class InvoiceLine
{
public function __construct(
public string $description,
public int $quantity,
public int $unitPriceInCents,
public string $currency,
public TaxRate $taxRate,
) {
if ($quantity <= 0) {
throw new \InvalidArgumentException('Quantity must be positive.');
}
}
public function lineTotalExclTax(): int
{
return $this->quantity * $this->unitPriceInCents;
}
public function lineTotalInclTax(): int
{
return (int) round($this->lineTotalExclTax() * (1 + $this->taxRate->rate));
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace MiniShop\Invoicing\Domain\Model;
enum InvoiceStatus: string
{
case Issued = 'issued';
case Sent = 'sent';
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace MiniShop\Invoicing\Domain\Model;
final readonly class TaxRate
{
public function __construct(
public float $rate,
public string $label,
) {
if ($rate < 0 || $rate > 1) {
throw new \InvalidArgumentException('Tax rate must be between 0 and 1.');
}
}
public static function standard(): self
{
return new self(0.20, 'TVA 20%');
}
public static function zero(): self
{
return new self(0.0, 'Exonere');
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace MiniShop\Invoicing\Infrastructure\Persistence;
use MiniShop\Invoicing\Application\Port\InvoiceRepository;
use MiniShop\Invoicing\Domain\Model\Invoice;
use MiniShop\Invoicing\Domain\Model\InvoiceId;
final class InMemoryInvoiceRepository implements InvoiceRepository
{
/** @var array<string, Invoice> */
private array $invoices = [];
public function save(Invoice $invoice): void
{
$this->invoices[$invoice->id->toString()] = $invoice;
}
public function get(InvoiceId $id): Invoice
{
return $this->invoices[$id->toString()]
?? throw new \RuntimeException(sprintf('Invoice "%s" not found.', $id->toString()));
}
public function findByExternalOrderId(string $externalOrderId): ?Invoice
{
foreach ($this->invoices as $invoice) {
if ($invoice->externalOrderId === $externalOrderId) {
return $invoice;
}
}
return null;
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace MiniShop\Invoicing\Infrastructure;
use MiniShop\Invoicing\Application\Port\InvoiceNumberGenerator;
final class SequentialInvoiceNumberGenerator implements InvoiceNumberGenerator
{
private int $counter = 0;
public function next(): string
{
return sprintf('INV-%06d', ++$this->counter);
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace MiniShop\Invoicing\Interfaces\Http;
use MiniShop\Invoicing\Application\Query\GetInvoiceByOrderRef;
use MiniShop\Invoicing\Application\Query\GetInvoiceByOrderRefHandler;
use MiniShop\Invoicing\Domain\Model\InvoiceLine;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/invoicing/orders/{externalOrderId}/invoice', name: 'invoicing_get_invoice', methods: ['GET'])]
final readonly class GetInvoiceController
{
public function __construct(
private GetInvoiceByOrderRefHandler $handler,
) {}
public function __invoke(string $externalOrderId): JsonResponse
{
$invoice = ($this->handler)(new GetInvoiceByOrderRef($externalOrderId));
if ($invoice === null) {
return new JsonResponse(['error' => 'Invoice not found.'], Response::HTTP_NOT_FOUND);
}
return new JsonResponse([
'invoiceId' => $invoice->id->toString(),
'invoiceNumber' => $invoice->invoiceNumber,
'externalOrderId' => $invoice->externalOrderId,
'billingParty' => [
'name' => $invoice->billingParty->name,
'address' => $invoice->billingParty->address,
],
'status' => $invoice->status()->value,
'totalExclTax' => $invoice->totalExclTax(),
'totalInclTax' => $invoice->totalInclTax(),
'lines' => array_map(
static fn (InvoiceLine $line): array => [
'description' => $line->description,
'quantity' => $line->quantity,
'unitPriceInCents' => $line->unitPriceInCents,
'lineTotalExclTax' => $line->lineTotalExclTax(),
'lineTotalInclTax' => $line->lineTotalInclTax(),
],
$invoice->lines(),
),
]);
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace MiniShop\LegacyFulfillment\Application\Command;
final readonly class MarkShipmentDispatched
{
public function __construct(
public string $externalOrderId,
) {}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace MiniShop\LegacyFulfillment\Application\Command;
use MiniShop\LegacyFulfillment\Application\Port\ShipmentRequestRepository;
use MiniShop\LegacyFulfillment\Domain\Model\LegacyOrderRef;
final readonly class MarkShipmentDispatchedHandler
{
public function __construct(
private ShipmentRequestRepository $shipmentRequestRepository,
) {}
public function __invoke(MarkShipmentDispatched $command): void
{
$ref = LegacyOrderRef::fromExternalId($command->externalOrderId);
$request = $this->shipmentRequestRepository->findByOrderRef($ref)
?? throw new \RuntimeException(sprintf('Shipment for order "%s" not found.', $command->externalOrderId));
$request->markDispatched();
$this->shipmentRequestRepository->save($request);
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace MiniShop\LegacyFulfillment\Application\Command;
final readonly class RequestShipmentFromSalesOrder
{
public function __construct(
public string $externalOrderId,
public string $recipientName,
public string $street,
public string $city,
public string $postalCode,
public string $country,
public int $totalWeightInGrams,
public string $description,
) {}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace MiniShop\LegacyFulfillment\Application\Command;
use MiniShop\LegacyFulfillment\Application\Port\LegacyFulfillmentGateway;
use MiniShop\LegacyFulfillment\Application\Port\ShipmentRequestRepository;
use MiniShop\LegacyFulfillment\Domain\Model\LegacyOrderRef;
use MiniShop\LegacyFulfillment\Domain\Model\ParcelSpec;
use MiniShop\LegacyFulfillment\Domain\Model\ShipmentRequest;
use MiniShop\LegacyFulfillment\Domain\Model\ShippingAddress;
use MiniShop\Shared\Technical\Clock;
final readonly class RequestShipmentFromSalesOrderHandler
{
public function __construct(
private ShipmentRequestRepository $shipmentRequestRepository,
private LegacyFulfillmentGateway $gateway,
private Clock $clock,
) {}
public function __invoke(RequestShipmentFromSalesOrder $command): void
{
$request = ShipmentRequest::fromSalesOrder(
LegacyOrderRef::fromExternalId($command->externalOrderId),
new ShippingAddress(
$command->recipientName,
$command->street,
$command->city,
$command->postalCode,
$command->country,
),
new ParcelSpec($command->totalWeightInGrams, $command->description),
$this->clock->now(),
);
$this->shipmentRequestRepository->save($request);
$this->gateway->sendShipmentRequest($request);
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace MiniShop\LegacyFulfillment\Application\Port;
use MiniShop\LegacyFulfillment\Domain\Model\ShipmentRequest;
interface LegacyFulfillmentGateway
{
public function sendShipmentRequest(ShipmentRequest $request): void;
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace MiniShop\LegacyFulfillment\Application\Port;
use MiniShop\LegacyFulfillment\Domain\Model\LegacyOrderRef;
use MiniShop\LegacyFulfillment\Domain\Model\ShipmentRequest;
interface ShipmentRequestRepository
{
public function save(ShipmentRequest $request): void;
public function findByOrderRef(LegacyOrderRef $orderRef): ?ShipmentRequest;
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace MiniShop\LegacyFulfillment\Application\Query;
final readonly class GetShipmentByExternalOrderRef
{
public function __construct(
public string $externalOrderId,
) {}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace MiniShop\LegacyFulfillment\Application\Query;
use MiniShop\LegacyFulfillment\Application\Port\ShipmentRequestRepository;
use MiniShop\LegacyFulfillment\Domain\Model\LegacyOrderRef;
use MiniShop\LegacyFulfillment\Domain\Model\ShipmentRequest;
final readonly class GetShipmentByExternalOrderRefHandler
{
public function __construct(
private ShipmentRequestRepository $shipmentRequestRepository,
) {}
public function __invoke(GetShipmentByExternalOrderRef $query): ?ShipmentRequest
{
return $this->shipmentRequestRepository->findByOrderRef(
LegacyOrderRef::fromExternalId($query->externalOrderId),
);
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace MiniShop\LegacyFulfillment\Domain\Event;
use MiniShop\LegacyFulfillment\Domain\Model\LegacyOrderRef;
final readonly class ShipmentRequested
{
public function __construct(
public LegacyOrderRef $orderRef,
public \DateTimeImmutable $requestedAt,
) {}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace MiniShop\LegacyFulfillment\Domain\Model;
final readonly class LegacyOrderRef
{
private function __construct(public string $value) {}
public static function fromExternalId(string $externalId): self
{
return new self($externalId);
}
public function toString(): string
{
return $this->value;
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace MiniShop\LegacyFulfillment\Domain\Model;
final readonly class ParcelSpec
{
public function __construct(
public int $weightInGrams,
public string $description,
) {
if ($weightInGrams <= 0) {
throw new \InvalidArgumentException('Weight must be positive.');
}
}
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace MiniShop\LegacyFulfillment\Domain\Model;
use MiniShop\LegacyFulfillment\Domain\Event\ShipmentRequested;
final class ShipmentRequest
{
private ShipmentStatus $status;
/** @var list<object> */
private array $domainEvents = [];
private function __construct(
public readonly LegacyOrderRef $orderRef,
public readonly ShippingAddress $address,
public readonly ParcelSpec $parcel,
private readonly \DateTimeImmutable $requestedAt,
) {
$this->status = ShipmentStatus::Requested;
}
public static function fromSalesOrder(
LegacyOrderRef $orderRef,
ShippingAddress $address,
ParcelSpec $parcel,
\DateTimeImmutable $requestedAt,
): self {
$request = new self($orderRef, $address, $parcel, $requestedAt);
$request->recordEvent(new ShipmentRequested($orderRef, $requestedAt));
return $request;
}
public function markDispatched(): void
{
if ($this->status === ShipmentStatus::Dispatched) {
throw new \DomainException('Shipment already dispatched.');
}
$this->status = ShipmentStatus::Dispatched;
}
public function status(): ShipmentStatus
{
return $this->status;
}
public function requestedAt(): \DateTimeImmutable
{
return $this->requestedAt;
}
/** @return list<object> */
public function releaseEvents(): array
{
$events = $this->domainEvents;
$this->domainEvents = [];
return $events;
}
private function recordEvent(object $event): void
{
$this->domainEvents[] = $event;
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace MiniShop\LegacyFulfillment\Domain\Model;
enum ShipmentStatus: string
{
case Requested = 'requested';
case Dispatched = 'dispatched';
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace MiniShop\LegacyFulfillment\Domain\Model;
final readonly class ShippingAddress
{
public function __construct(
public string $recipientName,
public string $street,
public string $city,
public string $postalCode,
public string $country,
) {}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace MiniShop\LegacyFulfillment\Infrastructure\Gateway;
use MiniShop\LegacyFulfillment\Application\Port\LegacyFulfillmentGateway;
use MiniShop\LegacyFulfillment\Domain\Model\ShipmentRequest;
final class FakeLegacyFulfillmentGateway implements LegacyFulfillmentGateway
{
/** @var list<ShipmentRequest> */
private array $sentRequests = [];
public function sendShipmentRequest(ShipmentRequest $request): void
{
$this->sentRequests[] = $request;
}
/** @return list<ShipmentRequest> */
public function sentRequests(): array
{
return $this->sentRequests;
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace MiniShop\LegacyFulfillment\Infrastructure\Persistence;
use MiniShop\LegacyFulfillment\Application\Port\ShipmentRequestRepository;
use MiniShop\LegacyFulfillment\Domain\Model\LegacyOrderRef;
use MiniShop\LegacyFulfillment\Domain\Model\ShipmentRequest;
final class InMemoryShipmentRequestRepository implements ShipmentRequestRepository
{
/** @var array<string, ShipmentRequest> */
private array $requests = [];
public function save(ShipmentRequest $request): void
{
$this->requests[$request->orderRef->toString()] = $request;
}
public function findByOrderRef(LegacyOrderRef $orderRef): ?ShipmentRequest
{
return $this->requests[$orderRef->toString()] ?? null;
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace MiniShop\LegacyFulfillment\Interfaces\Http;
use MiniShop\LegacyFulfillment\Application\Query\GetShipmentByExternalOrderRef;
use MiniShop\LegacyFulfillment\Application\Query\GetShipmentByExternalOrderRefHandler;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/fulfillment/orders/{externalOrderId}/shipment', name: 'fulfillment_get_shipment', methods: ['GET'])]
final readonly class GetShipmentController
{
public function __construct(
private GetShipmentByExternalOrderRefHandler $handler,
) {}
public function __invoke(string $externalOrderId): JsonResponse
{
$shipment = ($this->handler)(new GetShipmentByExternalOrderRef($externalOrderId));
if ($shipment === null) {
return new JsonResponse(['error' => 'Shipment not found.'], Response::HTTP_NOT_FOUND);
}
return new JsonResponse([
'orderRef' => $shipment->orderRef->toString(),
'status' => $shipment->status()->value,
'address' => [
'recipientName' => $shipment->address->recipientName,
'street' => $shipment->address->street,
'city' => $shipment->address->city,
'postalCode' => $shipment->address->postalCode,
'country' => $shipment->address->country,
],
'parcel' => [
'weightInGrams' => $shipment->parcel->weightInGrams,
'description' => $shipment->parcel->description,
],
]);
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Application\Command;
final readonly class CancelOrder
{
public function __construct(
public string $orderId,
) {}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Application\Command;
use MiniShop\Sales\Application\Port\OrderRepository;
use MiniShop\Sales\Application\Port\SalesEventPublisher;
use MiniShop\Sales\Domain\Event\OrderCancelled;
use MiniShop\Sales\Domain\Model\OrderId;
final readonly class CancelOrderHandler
{
public function __construct(
private OrderRepository $orderRepository,
private SalesEventPublisher $publisher,
) {}
public function __invoke(CancelOrder $command): void
{
$order = $this->orderRepository->get(OrderId::fromString($command->orderId));
$order->cancel();
$this->orderRepository->save($order);
foreach ($order->releaseEvents() as $event) {
if ($event instanceof OrderCancelled) {
$this->publisher->publishOrderCancelled($event);
}
}
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Application\Command;
final readonly class ConfirmOrder
{
public function __construct(
public string $orderId,
) {}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Application\Command;
use MiniShop\Sales\Application\Port\OrderRepository;
use MiniShop\Sales\Application\Port\SalesEventPublisher;
use MiniShop\Sales\Domain\Event\OrderConfirmed;
use MiniShop\Sales\Domain\Model\OrderId;
final readonly class ConfirmOrderHandler
{
public function __construct(
private OrderRepository $orderRepository,
private SalesEventPublisher $publisher,
) {}
public function __invoke(ConfirmOrder $command): void
{
$order = $this->orderRepository->get(OrderId::fromString($command->orderId));
$order->confirm();
$this->orderRepository->save($order);
foreach ($order->releaseEvents() as $event) {
if ($event instanceof OrderConfirmed) {
$this->publisher->publishOrderConfirmed($event);
}
}
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Application\Command;
final readonly class PlaceOrder
{
/**
* @param list<array{productName: string, quantity: int, unitPriceInCents: int, currency: string}> $lines
*/
public function __construct(
public string $orderId,
public string $customerId,
public array $lines,
) {}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Application\Command;
use MiniShop\Sales\Application\Port\OrderRepository;
use MiniShop\Sales\Application\Port\SalesEventPublisher;
use MiniShop\Sales\Domain\Event\OrderPlaced;
use MiniShop\Sales\Domain\Model\CustomerId;
use MiniShop\Sales\Domain\Model\Money;
use MiniShop\Sales\Domain\Model\Order;
use MiniShop\Sales\Domain\Model\OrderId;
use MiniShop\Sales\Domain\Model\OrderLine;
use MiniShop\Shared\Technical\Clock;
final readonly class PlaceOrderHandler
{
public function __construct(
private OrderRepository $orderRepository,
private SalesEventPublisher $publisher,
private Clock $clock,
) {}
public function __invoke(PlaceOrder $command): void
{
$lines = array_map(
static fn (array $line): OrderLine => new OrderLine(
$line['productName'],
$line['quantity'],
new Money($line['unitPriceInCents'], $line['currency']),
),
$command->lines,
);
$order = Order::place(
OrderId::fromString($command->orderId),
CustomerId::fromString($command->customerId),
$lines,
$this->clock->now(),
);
$this->orderRepository->save($order);
foreach ($order->releaseEvents() as $event) {
if ($event instanceof OrderPlaced) {
$this->publisher->publishOrderPlaced($event);
}
}
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Application\Port;
use MiniShop\Sales\Domain\Model\CustomerId;
use MiniShop\Sales\Domain\Model\Order;
use MiniShop\Sales\Domain\Model\OrderId;
interface OrderRepository
{
public function save(Order $order): void;
public function get(OrderId $id): Order;
/** @return list<Order> */
public function findByCustomer(CustomerId $customerId): array;
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Application\Port;
use MiniShop\Sales\Domain\Event\OrderCancelled;
use MiniShop\Sales\Domain\Event\OrderConfirmed;
use MiniShop\Sales\Domain\Event\OrderPlaced;
interface SalesEventPublisher
{
public function publishOrderPlaced(OrderPlaced $event): void;
public function publishOrderConfirmed(OrderConfirmed $event): void;
public function publishOrderCancelled(OrderCancelled $event): void;
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Application\Query;
final readonly class GetOrderById
{
public function __construct(
public string $orderId,
) {}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Application\Query;
use MiniShop\Sales\Application\Port\OrderRepository;
use MiniShop\Sales\Domain\Model\Order;
use MiniShop\Sales\Domain\Model\OrderId;
final readonly class GetOrderByIdHandler
{
public function __construct(
private OrderRepository $orderRepository,
) {}
public function __invoke(GetOrderById $query): Order
{
return $this->orderRepository->get(OrderId::fromString($query->orderId));
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Application\Query;
final readonly class ListOrdersByCustomer
{
public function __construct(
public string $customerId,
) {}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Application\Query;
use MiniShop\Sales\Application\Port\OrderRepository;
use MiniShop\Sales\Domain\Model\CustomerId;
use MiniShop\Sales\Domain\Model\Order;
final readonly class ListOrdersByCustomerHandler
{
public function __construct(
private OrderRepository $orderRepository,
) {}
/** @return list<Order> */
public function __invoke(ListOrdersByCustomer $query): array
{
return $this->orderRepository->findByCustomer(CustomerId::fromString($query->customerId));
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Domain\Event;
use MiniShop\Sales\Domain\Model\OrderId;
final readonly class OrderCancelled
{
public function __construct(
public OrderId $orderId,
) {}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Domain\Event;
use MiniShop\Sales\Domain\Model\CustomerId;
use MiniShop\Sales\Domain\Model\Money;
use MiniShop\Sales\Domain\Model\OrderId;
use MiniShop\Sales\Domain\Model\OrderLine;
final readonly class OrderConfirmed
{
/**
* @param list<OrderLine> $lines
*/
public function __construct(
public OrderId $orderId,
public CustomerId $customerId,
public Money $total,
public array $lines,
) {}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Domain\Event;
use MiniShop\Sales\Domain\Model\CustomerId;
use MiniShop\Sales\Domain\Model\Money;
use MiniShop\Sales\Domain\Model\OrderId;
final readonly class OrderPlaced
{
public function __construct(
public OrderId $orderId,
public CustomerId $customerId,
public Money $total,
public \DateTimeImmutable $placedAt,
) {}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Domain\Exception;
final class EmptyOrderException extends \DomainException
{
public static function noLines(): self
{
return new self('An order must have at least one line.');
}
public static function cannotConfirmEmpty(): self
{
return new self('Cannot confirm an order with no lines.');
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Domain\Exception;
use MiniShop\Sales\Domain\Model\OrderStatus;
final class InvalidOrderStateException extends \DomainException
{
public static function cannotConfirm(OrderStatus $current): self
{
return new self(sprintf('Cannot confirm order in "%s" state.', $current->value));
}
public static function cannotCancel(OrderStatus $current): self
{
return new self(sprintf('Cannot cancel order in "%s" state.', $current->value));
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Domain\Model;
final readonly class CustomerId
{
private function __construct(public string $value) {}
public static function fromString(string $value): self
{
return new self($value);
}
public function toString(): string
{
return $this->value;
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Domain\Model;
final readonly class Money
{
public function __construct(
public int $amount,
public string $currency,
) {}
public static function zero(string $currency): self
{
return new self(0, $currency);
}
public function add(self $other): self
{
if ($this->currency !== $other->currency) {
throw new \InvalidArgumentException(
sprintf('Cannot add %s to %s.', $other->currency, $this->currency),
);
}
return new self($this->amount + $other->amount, $this->currency);
}
public function isPositive(): bool
{
return $this->amount > 0;
}
public function equals(self $other): bool
{
return $this->amount === $other->amount && $this->currency === $other->currency;
}
}

View File

@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Domain\Model;
use MiniShop\Sales\Domain\Event\OrderCancelled;
use MiniShop\Sales\Domain\Event\OrderConfirmed;
use MiniShop\Sales\Domain\Event\OrderPlaced;
use MiniShop\Sales\Domain\Exception\EmptyOrderException;
use MiniShop\Sales\Domain\Exception\InvalidOrderStateException;
final class Order
{
private OrderStatus $status;
/** @var list<OrderLine> */
private array $lines;
/** @var list<object> */
private array $domainEvents = [];
private function __construct(
public readonly OrderId $id,
public readonly CustomerId $customerId,
private readonly \DateTimeImmutable $placedAt,
) {
$this->status = OrderStatus::Draft;
$this->lines = [];
}
/**
* @param list<OrderLine> $lines
*/
public static function place(
OrderId $id,
CustomerId $customerId,
array $lines,
\DateTimeImmutable $placedAt,
): self {
if ($lines === []) {
throw EmptyOrderException::noLines();
}
$order = new self($id, $customerId, $placedAt);
$order->lines = $lines;
$order->status = OrderStatus::Placed;
$order->recordEvent(new OrderPlaced($id, $customerId, $order->total(), $placedAt));
return $order;
}
public function confirm(): void
{
if ($this->status !== OrderStatus::Placed) {
throw InvalidOrderStateException::cannotConfirm($this->status);
}
$this->status = OrderStatus::Confirmed;
$this->recordEvent(new OrderConfirmed(
$this->id,
$this->customerId,
$this->total(),
$this->lines,
));
}
public function cancel(): void
{
if ($this->status !== OrderStatus::Placed) {
throw InvalidOrderStateException::cannotCancel($this->status);
}
$this->status = OrderStatus::Cancelled;
$this->recordEvent(new OrderCancelled($this->id));
}
public function total(): Money
{
return array_reduce(
$this->lines,
static fn (Money $carry, OrderLine $line): Money => $carry->add($line->lineTotal()),
Money::zero($this->lines[0]->unitPrice->currency),
);
}
public function status(): OrderStatus
{
return $this->status;
}
/** @return list<OrderLine> */
public function lines(): array
{
return $this->lines;
}
public function placedAt(): \DateTimeImmutable
{
return $this->placedAt;
}
/** @return list<object> */
public function releaseEvents(): array
{
$events = $this->domainEvents;
$this->domainEvents = [];
return $events;
}
private function recordEvent(object $event): void
{
$this->domainEvents[] = $event;
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Domain\Model;
final readonly class OrderId
{
private function __construct(public string $value) {}
public static function fromString(string $value): self
{
return new self($value);
}
public function toString(): string
{
return $this->value;
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Domain\Model;
final readonly class OrderLine
{
public function __construct(
public string $productName,
public int $quantity,
public Money $unitPrice,
) {
if ($quantity <= 0) {
throw new \InvalidArgumentException('Quantity must be positive.');
}
}
public function lineTotal(): Money
{
return new Money(
$this->unitPrice->amount * $this->quantity,
$this->unitPrice->currency,
);
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Domain\Model;
enum OrderStatus: string
{
case Draft = 'draft';
case Placed = 'placed';
case Confirmed = 'confirmed';
case Cancelled = 'cancelled';
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Infrastructure\Messaging;
use MiniShop\Invoicing\Application\Command\IssueInvoiceForExternalOrder;
use MiniShop\Invoicing\Application\Command\IssueInvoiceForExternalOrderHandler;
use MiniShop\LegacyFulfillment\Application\Command\RequestShipmentFromSalesOrder;
use MiniShop\LegacyFulfillment\Application\Command\RequestShipmentFromSalesOrderHandler;
use MiniShop\Sales\Application\Port\SalesEventPublisher;
use MiniShop\Sales\Domain\Event\OrderCancelled;
use MiniShop\Sales\Domain\Event\OrderConfirmed;
use MiniShop\Sales\Domain\Event\OrderPlaced;
use MiniShop\Sales\Domain\Model\OrderLine;
/**
* Couplage naif : Sales appelle directement les handlers des autres BC.
* Ce couplage sera supprime dans les episodes suivants (Published Language, Conformist, ACL).
*/
final readonly class NaiveSalesEventPublisher implements SalesEventPublisher
{
public function __construct(
private IssueInvoiceForExternalOrderHandler $issueInvoiceHandler,
private RequestShipmentFromSalesOrderHandler $requestShipmentHandler,
) {}
public function publishOrderPlaced(OrderPlaced $event): void
{
// Pas d'action downstream sur OrderPlaced dans le scenario naif.
}
public function publishOrderConfirmed(OrderConfirmed $event): void
{
// Appel direct vers Invoicing — couplage naif
($this->issueInvoiceHandler)(new IssueInvoiceForExternalOrder(
externalOrderId: $event->orderId->toString(),
customerName: 'Customer ' . $event->customerId->toString(),
customerAddress: 'N/A',
lines: array_map(
static fn (OrderLine $line): array => [
'description' => $line->productName,
'quantity' => $line->quantity,
'unitPriceInCents' => $line->unitPrice->amount,
'currency' => $line->unitPrice->currency,
],
$event->lines,
),
));
// Appel direct vers LegacyFulfillment — couplage naif
($this->requestShipmentHandler)(new RequestShipmentFromSalesOrder(
externalOrderId: $event->orderId->toString(),
recipientName: 'Customer ' . $event->customerId->toString(),
street: '1 Rue du Commerce',
city: 'Paris',
postalCode: '75001',
country: 'FR',
totalWeightInGrams: 1000,
description: sprintf('Order %s', $event->orderId->toString()),
));
}
public function publishOrderCancelled(OrderCancelled $event): void
{
// Pas d'action downstream sur OrderCancelled dans le scenario naif.
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Infrastructure\Persistence;
use MiniShop\Sales\Application\Port\OrderRepository;
use MiniShop\Sales\Domain\Model\CustomerId;
use MiniShop\Sales\Domain\Model\Order;
use MiniShop\Sales\Domain\Model\OrderId;
final class InMemoryOrderRepository implements OrderRepository
{
/** @var array<string, Order> */
private array $orders = [];
public function save(Order $order): void
{
$this->orders[$order->id->toString()] = $order;
}
public function get(OrderId $id): Order
{
return $this->orders[$id->toString()]
?? throw new \RuntimeException(sprintf('Order "%s" not found.', $id->toString()));
}
/** @return list<Order> */
public function findByCustomer(CustomerId $customerId): array
{
return array_values(
array_filter(
$this->orders,
static fn (Order $order): bool => $order->customerId->equals($customerId),
),
);
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Interfaces\Http;
use MiniShop\Sales\Application\Command\CancelOrder;
use MiniShop\Sales\Application\Command\CancelOrderHandler;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/sales/orders/{orderId}/cancel', name: 'sales_cancel_order', methods: ['POST'])]
final readonly class CancelOrderController
{
public function __construct(
private CancelOrderHandler $handler,
) {}
public function __invoke(string $orderId): JsonResponse
{
($this->handler)(new CancelOrder(orderId: $orderId));
return new JsonResponse(null, Response::HTTP_NO_CONTENT);
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Interfaces\Http;
use MiniShop\Sales\Application\Command\ConfirmOrder;
use MiniShop\Sales\Application\Command\ConfirmOrderHandler;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/sales/orders/{orderId}/confirm', name: 'sales_confirm_order', methods: ['POST'])]
final readonly class ConfirmOrderController
{
public function __construct(
private ConfirmOrderHandler $handler,
) {}
public function __invoke(string $orderId): JsonResponse
{
($this->handler)(new ConfirmOrder(orderId: $orderId));
return new JsonResponse(null, Response::HTTP_NO_CONTENT);
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Interfaces\Http;
use MiniShop\Sales\Application\Query\GetOrderById;
use MiniShop\Sales\Application\Query\GetOrderByIdHandler;
use MiniShop\Sales\Domain\Model\OrderLine;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/sales/orders/{orderId}', name: 'sales_get_order', methods: ['GET'])]
final readonly class GetOrderController
{
public function __construct(
private GetOrderByIdHandler $handler,
) {}
public function __invoke(string $orderId): JsonResponse
{
$order = ($this->handler)(new GetOrderById($orderId));
return new JsonResponse([
'orderId' => $order->id->toString(),
'customerId' => $order->customerId->toString(),
'status' => $order->status()->value,
'total' => [
'amount' => $order->total()->amount,
'currency' => $order->total()->currency,
],
'lines' => array_map(
static fn (OrderLine $line): array => [
'productName' => $line->productName,
'quantity' => $line->quantity,
'unitPrice' => $line->unitPrice->amount,
'currency' => $line->unitPrice->currency,
'lineTotal' => $line->lineTotal()->amount,
],
$order->lines(),
),
]);
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Interfaces\Http;
use MiniShop\Sales\Application\Query\ListOrdersByCustomer;
use MiniShop\Sales\Application\Query\ListOrdersByCustomerHandler;
use MiniShop\Sales\Domain\Model\Order;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/sales/customers/{customerId}/orders', name: 'sales_list_customer_orders', methods: ['GET'])]
final readonly class ListCustomerOrdersController
{
public function __construct(
private ListOrdersByCustomerHandler $handler,
) {}
public function __invoke(string $customerId): JsonResponse
{
$orders = ($this->handler)(new ListOrdersByCustomer($customerId));
return new JsonResponse(array_map(
static fn (Order $order): array => [
'orderId' => $order->id->toString(),
'status' => $order->status()->value,
'total' => [
'amount' => $order->total()->amount,
'currency' => $order->total()->currency,
],
],
$orders,
));
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace MiniShop\Sales\Interfaces\Http;
use MiniShop\Sales\Application\Command\PlaceOrder;
use MiniShop\Sales\Application\Command\PlaceOrderHandler;
use MiniShop\Shared\Technical\UuidGenerator;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/sales/orders', name: 'sales_place_order', methods: ['POST'])]
final readonly class PlaceOrderController
{
public function __construct(
private PlaceOrderHandler $handler,
) {}
public function __invoke(Request $request): JsonResponse
{
/** @var array{customerId: string, lines: list<array{productName: string, quantity: int, unitPriceInCents: int, currency: string}>} $data */
$data = json_decode($request->getContent(), true, flags: JSON_THROW_ON_ERROR);
$orderId = UuidGenerator::generate();
($this->handler)(new PlaceOrder(
orderId: $orderId,
customerId: $data['customerId'],
lines: $data['lines'],
));
return new JsonResponse(['orderId' => $orderId], Response::HTTP_CREATED);
}
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace MiniShop\Shared\Technical;
interface Clock
{
public function now(): \DateTimeImmutable;
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace MiniShop\Shared\Technical;
final class SystemClock implements Clock
{
public function now(): \DateTimeImmutable
{
return new \DateTimeImmutable();
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace MiniShop\Shared\Technical;
final class UuidGenerator
{
public static function generate(): string
{
$bytes = random_bytes(16);
$bytes[6] = chr(ord($bytes[6]) & 0x0f | 0x40);
$bytes[8] = chr(ord($bytes[8]) & 0x3f | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4));
}
}