Primary vs Secondary Adapters: The Two Sides of the Hexagon
Table of Contents
- The Hexagon Has Two Sides
- Primary Adapters (Driving/Left Side)
- Secondary Adapters (Driven/Right Side)
- Key Differences
- Dependency Direction
- Real-World Examples
- Testing Strategy
- Common Patterns
The Hexagon Has Two Sides
%%{init: {'theme':'base', 'themeVariables': { 'fontSize':'14px'}}}%%
graph LR
subgraph Primary["π΅ PRIMARY ADAPTERS (Driving/Left)"]
REST["REST API<br/>Controller"]
CLI["CLI<br/>Command"]
GraphQL["GraphQL<br/>Resolver"]
UI["Web UI<br/>Form"]
end
subgraph Core["π DOMAIN (Hexagon Core)"]
App["Application<br/>Handlers"]
Domain["Domain<br/>Entities"]
Ports["Ports<br/>Interfaces"]
end
subgraph Secondary["π’ SECONDARY ADAPTERS (Driven/Right)"]
DB["Database<br/>Doctrine"]
Email["Email<br/>SMTP"]
Queue["Message Queue<br/>RabbitMQ"]
Storage["File Storage<br/>S3"]
end
REST -->|"drives"| App
CLI -->|"drives"| App
GraphQL -->|"drives"| App
UI -->|"drives"| App
App -->|"uses (via Port)"| Ports
Ports -.->|"implemented by"| DB
Ports -.->|"implemented by"| Email
Ports -.->|"implemented by"| Queue
Ports -.->|"implemented by"| Storage
style Primary fill:#BBDEFB,stroke:#1976D2,stroke-width:3px
style Core fill:#C8E6C9,stroke:#388E3C,stroke-width:4px
style Secondary fill:#FFF9C4,stroke:#F57F17,stroke-width:3px
Primary Adapters (Driving/Left Side)
What Are Primary Adapters?
Primary adapters DRIVE the application. They initiate interactions with your business logic.
βI want to do somethingβ β Primary Adapter β Application
Characteristics
- Initiate requests to the application
- Depend ON the domain (know about handlers, commands)
- Convert external input to application commands
- Located on the LEFT side of the hexagon (driving side)
- Examples: Controllers, CLI commands, GraphQL resolvers, event listeners
Responsibility: Input Transformation
Primary adapters transform external format β application format.
HTTP JSON β Request DTO β Command β Handler
CLI arguments β Command β Handler
GraphQL query β Command β Handler
Example 1: REST API Controller (Primary Adapter)
namespace App\User\Infrastructure\Controller;
use App\User\Application\Command\RegisterUserCommand;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Messenger\MessageBusInterface;
#[Route('/api/users', methods: ['POST'])]
final readonly class RegisterUserController
{
public function __construct(
private MessageBusInterface $messageBus,
private SerializerInterface $serializer,
) {}
public function __invoke(Request $request): JsonResponse
{
// 1. Deserialize HTTP JSON β DTO
$dto = $this->serializer->deserialize(
$request->getContent(),
RegisterUserRequest::class,
'json'
);
// 2. Transform DTO β Command (Application)
$command = new RegisterUserCommand(
email: $dto->email,
password: $dto->password
);
// 3. DRIVE the application by dispatching command
$this->messageBus->dispatch($command);
// 4. Transform result β HTTP JSON
return new JsonResponse(['status' => 'created'], 201);
}
}
Key Points:
- Initiates the interaction (user makes HTTP request)
- Depends on Application layer (imports
RegisterUserCommand) - Knows about HTTP, JSON, Symfony Request/Response
- Drives the application by calling
messageBus->dispatch()
Example 2: CLI Command (Primary Adapter)
namespace App\User\Infrastructure\Console;
use App\User\Application\Command\ImportUsersCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Messenger\MessageBusInterface;
final class ImportUsersCliCommand extends Command
{
protected static $defaultName = 'app:import-users';
public function __construct(
private MessageBusInterface $messageBus,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addArgument('file', InputArgument::REQUIRED, 'CSV file path');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$filePath = $input->getArgument('file');
// Parse CSV file
$users = $this->parseCSV($filePath);
// For each user, DRIVE the application
foreach ($users as $userData) {
$command = new ImportUsersCommand(
email: $userData['email'],
name: $userData['name']
);
$this->messageBus->dispatch($command);
}
$output->writeln('Users imported successfully');
return Command::SUCCESS;
}
}
Key Points:
- Initiates via CLI execution
- Depends on Application (imports command)
- Knows about Symfony Console, file I/O
- Drives application by dispatching commands
Example 3: GraphQL Resolver (Primary Adapter)
namespace App\Product\Infrastructure\GraphQL;
use App\Product\Application\Query\FindProductQuery;
use Overblog\GraphQLBundle\Annotation as GQL;
#[GQL\Type]
final readonly class ProductResolver
{
public function __construct(
private MessageBusInterface $queryBus,
) {}
#[GQL\Query(name: "product")]
public function getProduct(string $id): ProductGraphQLDTO
{
// DRIVE the application
$query = new FindProductQuery($id);
$product = $this->queryBus->dispatch($query);
// Transform domain β GraphQL DTO
return new ProductGraphQLDTO(
id: $product->getId(),
name: $product->getName(),
price: $product->getPrice()->toCents()
);
}
}
Secondary Adapters (Driven/Right Side)
What Are Secondary Adapters?
Secondary adapters are DRIVEN by the application. The application tells them what to do.
Application β βI need to save thisβ β Secondary Adapter β Database
Characteristics
- Respond to requests from the application
- Implement domain ports (interfaces defined in domain)
- Convert domain β external system format
- Located on the RIGHT side of the hexagon (driven side)
- Examples: Repositories, email senders, message publishers, file storage
Responsibility: Output Transformation
Secondary adapters transform domain format β external format.
Entity β Repository β Doctrine Mapping β SQL β Database
Email Object β EmailSender β SMTP Protocol β Email Server
Event β EventPublisher β JSON β Message Queue
Example 1: Doctrine Repository (Secondary Adapter)
namespace App\User\Infrastructure\Persistence;
use App\User\Domain\Model\User;
use App\User\Domain\Port\UserRepositoryInterface; // Port (interface)
use App\User\Domain\ValueObject\UserId;
use Doctrine\ORM\EntityManagerInterface;
final class DoctrineUserRepository implements UserRepositoryInterface
{
public function __construct(
private EntityManagerInterface $entityManager
) {}
// DRIVEN by the application
public function save(User $user): void
{
// Transform: Domain Entity β Doctrine Persistence β SQL
$this->entityManager->persist($user);
$this->entityManager->flush();
}
public function findById(UserId $id): ?User
{
// Transform: Database Row β Doctrine Hydration β Domain Entity
return $this->entityManager->find(User::class, $id->toString());
}
public function existsByEmail(string $email): bool
{
// Transform: Domain Query β SQL Query β Boolean Result
$qb = $this->entityManager->createQueryBuilder();
$qb->select('COUNT(u.id)')
->from(User::class, 'u')
->where('u.email = :email')
->setParameter('email', $email);
return (int) $qb->getQuery()->getSingleScalarResult() > 0;
}
}
Key Points:
- Implements port interface defined in Domain
- Driven by application (handler calls methods)
- Knows about Doctrine, SQL, database
- Does NOT initiate interactionsβwaits to be called
Example 2: Email Sender (Secondary Adapter)
namespace App\Notification\Infrastructure\Email;
use App\Notification\Domain\Port\EmailSenderInterface; // Port
use App\Notification\Domain\ValueObject\Email;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email as SymfonyEmail;
final readonly class SymfonyEmailSender implements EmailSenderInterface
{
public function __construct(
private MailerInterface $mailer,
) {}
// DRIVEN by the application
public function send(Email $email): void
{
// Transform: Domain Email β Symfony Email β SMTP Protocol
$message = (new SymfonyEmail())
->from($email->from->value)
->to($email->to->value)
->subject($email->subject)
->html($email->body);
$this->mailer->send($message);
}
}
Key Points:
- Implements
EmailSenderInterface(domain port) - Driven by handler calling
send() - Knows about Symfony Mailer, SMTP
- Transforms domain object β infrastructure format
Example 3: Message Queue Publisher (Secondary Adapter)
namespace App\Order\Infrastructure\Messaging;
use App\Order\Domain\Event\OrderPlacedEvent;
use App\Order\Domain\Port\EventPublisherInterface; // Port
use Symfony\Component\Messenger\MessageBusInterface;
final readonly class SymfonyEventPublisher implements EventPublisherInterface
{
public function __construct(
private MessageBusInterface $eventBus,
) {}
// DRIVEN by the application
public function publish(OrderPlacedEvent $event): void
{
// Transform: Domain Event β Message Bus β RabbitMQ
$this->eventBus->dispatch($event);
}
}
Key Differences
| Aspect | Primary Adapter | Secondary Adapter |
|---|---|---|
| Direction | Drives application | Driven by application |
| Initiates? | Yes (user action, cron, event) | No (called by application) |
| Depends On | Application layer | Domain ports (interfaces) |
| Implements | Nothing (calls handlers) | Port interfaces |
| Location | Left side (driving) | Right side (driven) |
| Examples | Controller, CLI, GraphQL | Repository, Email, Queue |
| Input/Output | Receives external input | Produces external output |
| Knows About | Commands, Queries, Handlers | Ports, Entities |
Dependency Direction
Primary Adapter Dependencies
Primary Adapter (Infrastructure)
β depends on
Application Layer (Commands, Handlers)
β depends on
Domain Layer (Entities, Ports)
Example:
// Controller (Primary Adapter) depends on Application
use App\User\Application\Command\RegisterUserCommand; // β
OK
class RegisterUserController
{
public function __invoke(Request $request): Response
{
$command = new RegisterUserCommand(...);
$this->messageBus->dispatch($command);
}
}
Secondary Adapter Dependencies
Domain Layer (Ports = Interfaces)
β implemented by
Secondary Adapter (Infrastructure)
Example:
// Repository (Secondary Adapter) implements Domain Port
namespace App\User\Infrastructure\Persistence;
use App\User\Domain\Port\UserRepositoryInterface; // Domain interface
class DoctrineUserRepository implements UserRepositoryInterface
{
// Implementation details...
}
Key Point: Domain defines interface, Infrastructure implements it (Dependency Inversion Principle).
Real-World Examples
Example: E-Commerce Order System
// PRIMARY ADAPTERS (Driving)
// 1. REST API Controller
#[Route('/api/orders', methods: ['POST'])]
class CreateOrderController // Drives application
{
public function __invoke(Request $request): JsonResponse
{
$command = new CreateOrderCommand(...);
$this->messageBus->dispatch($command); // Drives
return new JsonResponse(['status' => 'created'], 201);
}
}
// 2. CLI Command
class ProcessPendingOrdersCommand extends Command
{
protected function execute(...): int
{
$orders = $this->orders->findPending();
foreach ($orders as $order) {
$command = new ProcessOrderCommand($order->getId());
$this->messageBus->dispatch($command); // Drives
}
return Command::SUCCESS;
}
}
// 3. Event Listener (triggered by external event)
class PaymentReceivedListener // Drives application when payment event arrives
{
public function __invoke(PaymentReceivedEvent $event): void
{
$command = new ConfirmOrderCommand($event->orderId);
$this->messageBus->dispatch($command); // Drives
}
}
// SECONDARY ADAPTERS (Driven)
// 1. Order Repository (Database)
class DoctrineOrderRepository implements OrderRepositoryInterface
{
// Driven by handlers calling save(), findById(), etc.
public function save(Order $order): void { /* ... */ }
}
// 2. Payment Processor (External API)
class StripePaymentProcessor implements PaymentProcessorInterface
{
// Driven by handlers calling charge(), refund(), etc.
public function charge(PaymentRequest $request): PaymentResult { /* ... */ }
}
// 3. Email Sender (Email Service)
class SendGridEmailSender implements EmailSenderInterface
{
// Driven by handlers calling send()
public function send(Email $email): void { /* ... */ }
}
// 4. Inventory Service (External Microservice)
class HttpInventoryService implements InventoryServiceInterface
{
// Driven by handlers calling reserveStock()
public function reserveStock(ProductId $id, int $quantity): void { /* ... */ }
}
Testing Strategy
Testing Primary Adapters
Focus: Test input transformation and command/query dispatch.
class RegisterUserControllerTest extends WebTestCase
{
public function test_registers_user_via_api(): void
{
$client = static::createClient();
// Test HTTP β Command transformation
$client->request('POST', '/api/users', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode([
'email' => 'user@example.com',
'password' => 'secret123',
]));
$this->assertResponseStatusCodeSame(201);
// Verify command was dispatched correctly
// (use test message bus or check side effects)
}
}
Testing Secondary Adapters
Focus: Test port implementation correctness.
class DoctrineUserRepositoryTest extends KernelTestCase
{
public function test_saves_and_retrieves_user(): void
{
$repository = $this->getContainer()->get(UserRepositoryInterface::class);
$user = new User(
UserId::generate(),
new Email('test@example.com'),
HashedPassword::fromPlaintext('password')
);
// Test port implementation
$repository->save($user);
$retrieved = $repository->findById($user->getId());
$this->assertEquals($user->getId(), $retrieved->getId());
$this->assertEquals($user->getEmail(), $retrieved->getEmail());
}
}
Testing Without Adapters (Pure Domain)
Use in-memory fakes for secondary adapters:
class RegisterUserHandlerTest extends TestCase
{
public function test_registers_user(): void
{
// Fake secondary adapter (in-memory)
$users = new InMemoryUserRepository();
$handler = new RegisterUserHandler($users);
$command = new RegisterUserCommand('user@example.com', 'password123');
$handler($command);
// Verify via in-memory repository
$this->assertTrue($users->existsByEmail('user@example.com'));
}
}
Common Patterns
Pattern 1: Multiple Primary Adapters, Same Use Case
REST API Controller βββββ
CLI Command βββββΌββββ CreateOrderCommand β Handler
GraphQL Resolver βββββ
All three primary adapters drive the same application logic.
Pattern 2: One Secondary Adapter, Multiple Implementations
Handler uses UserRepositoryInterface (Port)
β
βββββββββββββββββΌββββββββββββββββ
β β β
InMemory Doctrine DynamoDB
(Test) (Production) (AWS)
Multiple secondary adapters implement the same port for different contexts.
Pattern 3: Adapter Composition
// Secondary adapter wrapping another adapter
final readonly class CachedUserRepository implements UserRepositoryInterface
{
public function __construct(
private UserRepositoryInterface $decorated, // Another secondary adapter
private CacheInterface $cache,
) {}
public function findById(UserId $id): ?User
{
return $this->cache->get(
"user:{$id}",
fn() => $this->decorated->findById($id) // Delegate to real adapter
);
}
}
Decorator pattern: one secondary adapter wraps another.
Visual Summary
%%{init: {'theme':'base', 'themeVariables': { 'fontSize':'13px'}}}%%
graph TB
subgraph Primary["PRIMARY ADAPTERS (Initiate)"]
REST[REST Controller]
CLI[CLI Command]
GQL[GraphQL]
end
subgraph App["APPLICATION"]
Handler[Handler]
end
subgraph Domain["DOMAIN"]
Port[Port Interface]
end
subgraph Secondary["SECONDARY ADAPTERS (Respond)"]
Repo[Repository]
Email[Email Sender]
end
REST -->|"calls"| Handler
CLI -->|"calls"| Handler
GQL -->|"calls"| Handler
Handler -->|"uses"| Port
Repo -.->|"implements"| Port
Email -.->|"implements"| Port
style Primary fill:#BBDEFB,stroke:#1976D2,stroke-width:2px
style App fill:#C8E6C9,stroke:#388E3C,stroke-width:2px
style Domain fill:#FFF9C4,stroke:#F57F17,stroke-width:2px
style Secondary fill:#FFCCBC,stroke:#E64A19,stroke-width:2px
Key Takeaways
- Primary = Driving (Controllers, CLI) β Initiate interactions
- Secondary = Driven (Repositories, Email) β Respond to application
- Primary depends ON application (knows about commands/handlers)
- Secondary implements domain ports (interfaces defined by domain)
- Dependency Inversion: Domain defines interfaces, infrastructure implements
- Testing: Primary = functional tests, Secondary = integration tests, Domain = unit tests