Hexagonal Architecture Ports Adapters
Hexagonal Architecture (Ports and Adapters)
Hexagonal Architecture isoliert die Business-Logik von externen Systemen. Lernen Sie das Ports and Adapters Pattern für flexible und testbare Software.
Das Konzept
┌─────────────────────────────────────────────────────────────┐ │ HEXAGONAL ARCHITECTURE │ ├─────────────────────────────────────────────────────────────┤ │ │ │ Auch bekannt als: Ports and Adapters, Onion Architecture │ │ │ │ ┌───────────┐ │ │ REST│ Adapter │CLI │ │ └─────┬─────┘ │ │ │ │ │ ┌─────▼─────┐ │ │ │ PORT │ (Input) │ │ │(Interface)│ │ │ └─────┬─────┘ │ │ │ │ │ ┌────────────────────┼────────────────────┐ │ │ │ │ │ │ │ │ ┌───────▼───────┐ │ │ │ │ │ │ │ │ │ │ │ DOMAIN │ │ │ │ │ │ (Business │ │ │ │ │ │ Logic) │ │ │ │ │ │ │ │ │ │ │ └───────┬───────┘ │ │ │ │ │ │ │ │ └────────────────────┼────────────────────┘ │ │ │ │ │ ┌─────▼─────┐ │ │ │ PORT │ (Output) │ │ │(Interface)│ │ │ └─────┬─────┘ │ │ │ │ │ ┌─────▼─────┐ │ │ MySQL│ Adapter │Redis │ │ └───────────┘ │ │ │ └─────────────────────────────────────────────────────────────┘
Komponenten
| Komponente | Beschreibung | Beispiele |
|---|---|---|
| Domain | Reine Geschäftslogik, keine Abhängigkeiten | Entities, Value Objects, Services |
| Ports | Interfaces zur Außenwelt | UserRepository, PaymentGateway |
| Adapters | Implementierungen der Ports | MySQLUserRepository, StripeGateway |
Projektstruktur
src/
├── Domain/ # Kern - keine Abhängigkeiten
│ ├── Model/
│ │ ├── User.php
│ │ ├── Order.php
│ │ └── ValueObject/
│ │ ├── Email.php
│ │ └── Money.php
│ ├── Service/
│ │ ├── OrderService.php
│ │ └── PricingService.php
│ ├── Repository/ # Port Interfaces
│ │ ├── UserRepositoryInterface.php
│ │ └── OrderRepositoryInterface.php
│ └── Event/
│ └── OrderPlaced.php
│
├── Application/ # Use Cases / Application Services
│ ├── UseCase/
│ │ ├── PlaceOrder/
│ │ │ ├── PlaceOrderCommand.php
│ │ │ └── PlaceOrderHandler.php
│ │ └── GetUser/
│ │ ├── GetUserQuery.php
│ │ └── GetUserHandler.php
│ └── Port/ # Secondary Ports
│ ├── PaymentGatewayInterface.php
│ └── NotificationServiceInterface.php
│
├── Infrastructure/ # Adapter Implementations
│ ├── Persistence/
│ │ ├── Doctrine/
│ │ │ └── DoctrineUserRepository.php
│ │ └── InMemory/
│ │ └── InMemoryUserRepository.php
│ ├── Payment/
│ │ ├── StripePaymentGateway.php
│ │ └── PayPalPaymentGateway.php
│ └── Notification/
│ └── EmailNotificationService.php
│
└── Adapter/ # Primary Adapters (Input)
├── Http/
│ ├── Controller/
│ │ └── OrderController.php
│ └── Request/
│ └── PlaceOrderRequest.php
├── Cli/
│ └── ProcessOrdersCommand.php
└── GraphQL/
└── OrderResolver.php
Domain Layer
// Domain/Model/Order.php
// Reine Business-Logik, keine Framework-Abhängigkeiten
namespace Domain\Model;
class Order
{
private OrderId $id;
private UserId $userId;
private array $items = [];
private OrderStatus $status;
private Money $total;
public function __construct(OrderId $id, UserId $userId)
{
$this->id = $id;
$this->userId = $userId;
$this->status = OrderStatus::PENDING;
$this->total = Money::zero('EUR');
}
public function addItem(Product $product, int $quantity): void
{
if ($quantity <= 0) {
throw new InvalidQuantityException('Quantity must be positive');
}
if (!$product->isAvailable()) {
throw new ProductNotAvailableException($product->id());
}
$this->items[] = new OrderItem($product, $quantity);
$this->recalculateTotal();
}
public function confirm(): void
{
if ($this->status !== OrderStatus::PENDING) {
throw new InvalidOrderStateException('Can only confirm pending orders');
}
if (empty($this->items)) {
throw new EmptyOrderException('Cannot confirm empty order');
}
$this->status = OrderStatus::CONFIRMED;
}
private function recalculateTotal(): void
{
$this->total = Money::zero('EUR');
foreach ($this->items as $item) {
$this->total = $this->total->add($item->lineTotal());
}
}
}
// Domain/Repository/OrderRepositoryInterface.php
// Port Interface - definiert WAS, nicht WIE
namespace Domain\Repository;
interface OrderRepositoryInterface
{
public function save(Order $order): void;
public function findById(OrderId $id): ?Order;
public function findByUser(UserId $userId): array;
public function nextId(): OrderId;
}
Application Layer (Use Cases)
// Application/UseCase/PlaceOrder/PlaceOrderCommand.php
namespace Application\UseCase\PlaceOrder;
class PlaceOrderCommand
{
public function __construct(
public readonly string $userId,
public readonly array $items, // [{productId, quantity}]
) {}
}
// Application/UseCase/PlaceOrder/PlaceOrderHandler.php
namespace Application\UseCase\PlaceOrder;
use Domain\Repository\OrderRepositoryInterface;
use Domain\Repository\ProductRepositoryInterface;
use Application\Port\PaymentGatewayInterface;
class PlaceOrderHandler
{
public function __construct(
private OrderRepositoryInterface $orderRepository,
private ProductRepositoryInterface $productRepository,
private PaymentGatewayInterface $paymentGateway,
) {}
public function handle(PlaceOrderCommand $command): OrderId
{
// 1. Order erstellen (Domain)
$order = new Order(
$this->orderRepository->nextId(),
new UserId($command->userId)
);
// 2. Items hinzufügen
foreach ($command->items as $item) {
$product = $this->productRepository->findById(
new ProductId($item['productId'])
);
if (!$product) {
throw new ProductNotFoundException($item['productId']);
}
$order->addItem($product, $item['quantity']);
}
// 3. Order bestätigen (Domain-Regel)
$order->confirm();
// 4. Zahlung verarbeiten (via Port)
$this->paymentGateway->charge(
$order->userId(),
$order->total()
);
// 5. Persistieren (via Port)
$this->orderRepository->save($order);
return $order->id();
}
}
Infrastructure Layer (Adapters)
// Infrastructure/Persistence/Doctrine/DoctrineOrderRepository.php
namespace Infrastructure\Persistence\Doctrine;
use Domain\Repository\OrderRepositoryInterface;
use Domain\Model\Order;
use Doctrine\ORM\EntityManagerInterface;
class DoctrineOrderRepository implements OrderRepositoryInterface
{
public function __construct(
private EntityManagerInterface $em
) {}
public function save(Order $order): void
{
$this->em->persist($order);
$this->em->flush();
}
public function findById(OrderId $id): ?Order
{
return $this->em->find(Order::class, $id->value());
}
public function findByUser(UserId $userId): array
{
return $this->em->createQueryBuilder()
->select('o')
->from(Order::class, 'o')
->where('o.userId = :userId')
->setParameter('userId', $userId->value())
->getQuery()
->getResult();
}
public function nextId(): OrderId
{
return new OrderId(Uuid::uuid4()->toString());
}
}
// Infrastructure/Persistence/InMemory/InMemoryOrderRepository.php
// Für Tests
namespace Infrastructure\Persistence\InMemory;
use Domain\Repository\OrderRepositoryInterface;
use Domain\Model\Order;
class InMemoryOrderRepository implements OrderRepositoryInterface
{
private array $orders = [];
private int $nextId = 1;
public function save(Order $order): void
{
$this->orders[$order->id()->value()] = $order;
}
public function findById(OrderId $id): ?Order
{
return $this->orders[$id->value()] ?? null;
}
public function findByUser(UserId $userId): array
{
return array_filter(
$this->orders,
fn(Order $o) => $o->userId()->equals($userId)
);
}
public function nextId(): OrderId
{
return new OrderId((string) $this->nextId++);
}
}
Adapter Layer (HTTP)
// Adapter/Http/Controller/OrderController.php
namespace Adapter\Http\Controller;
use Application\UseCase\PlaceOrder\PlaceOrderCommand;
use Application\UseCase\PlaceOrder\PlaceOrderHandler;
class OrderController
{
public function __construct(
private PlaceOrderHandler $placeOrderHandler
) {}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'user_id' => 'required|string',
'items' => 'required|array',
'items.*.product_id' => 'required|string',
'items.*.quantity' => 'required|integer|min:1',
]);
$command = new PlaceOrderCommand(
userId: $validated['user_id'],
items: $validated['items']
);
try {
$orderId = $this->placeOrderHandler->handle($command);
return response()->json([
'order_id' => $orderId->value()
], 201);
} catch (ProductNotFoundException $e) {
return response()->json([
'error' => "Product not found: {$e->productId()}"
], 404);
} catch (DomainException $e) {
return response()->json([
'error' => $e->getMessage()
], 400);
}
}
}
Dependency Injection
// config/services.php oder DI Container
// Domain Repositories → Infrastructure Implementation
$container->bind(
OrderRepositoryInterface::class,
DoctrineOrderRepository::class
);
$container->bind(
ProductRepositoryInterface::class,
DoctrineProductRepository::class
);
// Application Ports → Infrastructure Adapters
$container->bind(
PaymentGatewayInterface::class,
StripePaymentGateway::class
);
// Für Tests: In-Memory Implementierungen
// $container->bind(OrderRepositoryInterface::class, InMemoryOrderRepository::class);
Vorteile
1. TESTBARKEIT ┌────────────────────────────────────────────────────────┐ │ Domain isoliert testen ohne DB, HTTP, etc. │ │ Use Cases mit In-Memory Repositories testen │ │ Keine Mocks für externe Services nötig │ └────────────────────────────────────────────────────────┘ 2. AUSTAUSCHBARKEIT ┌────────────────────────────────────────────────────────┐ │ MySQL → PostgreSQL: Nur Adapter tauschen │ │ Stripe → PayPal: Neuer Adapter, gleicher Port │ │ REST → GraphQL: Neuer Adapter, gleiche Use Cases │ └────────────────────────────────────────────────────────┘ 3. UNABHÄNGIGKEIT ┌────────────────────────────────────────────────────────┐ │ Domain kennt keine Frameworks │ │ Business-Logik bleibt bei Framework-Wechsel │ │ Technologie-Entscheidungen verschiebbar │ └────────────────────────────────────────────────────────┘ 4. FOKUS ┌────────────────────────────────────────────────────────┐ │ Domain fokussiert auf Business │ │ Infrastructure fokussiert auf Technik │ │ Klare Trennung der Verantwortungen │ └────────────────────────────────────────────────────────┘
💡 Best Practices:
1. Domain darf KEINE externen Dependencies haben
2. Ports sind Interfaces im Domain/Application Layer
3. Adapters implementieren Ports in Infrastructure
4. Dependency Inversion: High-Level → Interface ← Low-Level
5. Für kleine Projekte oft overkill - pragmatisch bleiben
2. Ports sind Interfaces im Domain/Application Layer
3. Adapters implementieren Ports in Infrastructure
4. Dependency Inversion: High-Level → Interface ← Low-Level
5. Für kleine Projekte oft overkill - pragmatisch bleiben