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,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,
],
]);
}
}