Complete Request-Response Flow: End-to-End Journey

Table of Contents

  1. Overview
  2. The Complete Flow Diagram
  3. Step-by-Step Breakdown
  4. Data Transformations
  5. Real Example: Register User
  6. Error Flow
  7. Performance Considerations

Overview

Understanding how a request flows through all layers of hexagonal architecture is crucial. This guide shows the complete journey from HTTP request to database and back, with data transformations at each boundary.

The Journey in One Sentence

HTTP JSON โ†’ Controller โ†’ Command DTO โ†’ Handler โ†’ Domain Entity โ†’ Repository Port โ†’ Doctrine Adapter โ†’ Database โ†’ Entity โ†’ Query Result โ†’ Response DTO โ†’ Controller โ†’ HTTP JSON


The Complete Flow Diagram

%%{init: {'theme':'base', 'themeVariables': { 'fontSize':'13px'}}}%%
sequenceDiagram
    autonumber

    participant Client as ๐ŸŒ Client<br/>(Browser/API)
    participant Router as ๐Ÿšฆ Symfony Router
    participant Ctrl as ๐ŸŽฎ Controller<br/>(Infrastructure)
    participant Valid as โœ… Validator<br/>(Symfony)
    participant Bus as ๐ŸšŒ Message Bus<br/>(Symfony)
    participant Handler as โš™๏ธ Handler<br/>(Application)
    participant Factory as ๐Ÿญ Factory<br/>(Domain)
    participant Entity as ๐Ÿ’Ž Entity<br/>(Domain)
    participant Port as ๐Ÿ”Œ Port<br/>(Domain Interface)
    participant Adapter as ๐Ÿ”ง Adapter<br/>(Infrastructure)
    participant DB as ๐Ÿ—„๏ธ Database<br/>(PostgreSQL)

    rect rgb(255, 240, 240)
        Note over Client,Router: INCOMING REQUEST
        Client->>Router: POST /api/users<br/>{"email": "user@example.com", "password": "secret123"}
        Router->>Ctrl: Route to RegisterUserController
    end

    rect rgb(240, 248, 255)
        Note over Ctrl,Valid: INFRASTRUCTURE LAYER: Input Validation
        Ctrl->>Ctrl: Deserialize JSON to RegisterUserRequest DTO
        Ctrl->>Valid: Validate DTO constraints
        Valid-->>Ctrl: Validation OK
        Ctrl->>Bus: Create RegisterUserCommand<br/>dispatch(command)
    end

    rect rgb(240, 255, 240)
        Note over Bus,Handler: APPLICATION LAYER: Orchestration
        Bus->>Handler: __invoke(RegisterUserCommand)
        Handler->>Port: $this->users->existsByEmail()
        Port->>Adapter: existsByEmail()
        Adapter->>DB: SELECT COUNT(*) FROM users WHERE email = ?
        DB-->>Adapter: 0
        Adapter-->>Port: false
        Port-->>Handler: false (email available)
    end

    rect rgb(255, 255, 240)
        Note over Handler,Entity: DOMAIN LAYER: Business Logic
        Handler->>Factory: UserFactory::create(email, password)
        Factory->>Entity: new Email(value)
        Entity->>Entity: validate email format
        Entity-->>Factory: Email created
        Factory->>Entity: HashedPassword::fromPlaintext()
        Entity->>Entity: hash password + validate length
        Entity-->>Factory: HashedPassword created
        Factory->>Entity: new User(id, email, password)
        Entity->>Entity: apply business rules
        Entity-->>Factory: User entity
        Factory-->>Handler: User entity
    end

    rect rgb(240, 248, 255)
        Note over Handler,DB: INFRASTRUCTURE LAYER: Persistence
        Handler->>Port: $this->users->save($user)
        Port->>Adapter: save($user)
        Adapter->>DB: INSERT INTO users (...) VALUES (...)
        DB-->>Adapter: OK
        Adapter-->>Port: void
        Port-->>Handler: void
    end

    rect rgb(255, 240, 240)
        Note over Handler,Client: RESPONSE PATH
        Handler-->>Bus: void (success)
        Bus-->>Ctrl: void
        Ctrl->>Ctrl: Create UserResponse DTO<br/>from User entity
        Ctrl-->>Router: Response(201, UserResponse)
        Router-->>Client: 201 Created<br/>{"id": "123", "email": "user@example.com"}
    end

Step-by-Step Breakdown

Phase 1: Request Entry (Infrastructure)

Step 1-2: Routing

Input:  POST /api/users HTTP/1.1
        Content-Type: application/json
        {"email": "user@example.com", "password": "secret123"}

Action: Symfony Router matches route โ†’ RegisterUserController

Step 3: Deserialization

// Controller receives raw request
public function __invoke(Request $request): JsonResponse
{
    // Deserialize JSON to DTO
    $dto = $this->serializer->deserialize(
        $request->getContent(),
        RegisterUserRequest::class,
        'json'
    );

    // $dto is now: RegisterUserRequest {
    //     email: "user@example.com",
    //     password: "secret123"
    // }
}

Data Transformation:

Raw JSON String โ†’ RegisterUserRequest DTO (Infrastructure)

Step 4-5: Validation

// Validate using Symfony constraints
$errors = $this->validator->validate($dto);

if (count($errors) > 0) {
    throw new ValidationException($errors);
}

// DTO class with constraints:
class RegisterUserRequest
{
    #[Assert\NotBlank]
    #[Assert\Email]
    public string $email;

    #[Assert\NotBlank]
    #[Assert\Length(min: 8)]
    public string $password;
}

Step 6: Create Command & Dispatch

// Transform DTO โ†’ Command (Application DTO)
$command = new RegisterUserCommand(
    email: $dto->email,
    password: $dto->password
);

// Dispatch to message bus
$this->messageBus->dispatch($command);

Data Transformation:

RegisterUserRequest DTO โ†’ RegisterUserCommand DTO (Application)

Phase 2: Application Layer Orchestration

Step 7: Handler Invocation

// Symfony automatically invokes handler
#[AsMessageHandler]
final readonly class RegisterUserHandler
{
    public function __invoke(RegisterUserCommand $command): void
    {
        // Handler starts orchestration
    }
}

Step 8-12: Check Email Uniqueness

// Handler calls port
if ($this->users->existsByEmail($command->email)) {
    throw new EmailAlreadyExistsException($command->email);
}

// Port interface (Domain)
interface UserRepositoryInterface
{
    public function existsByEmail(string $email): bool;
}

// Adapter implementation (Infrastructure)
final class DoctrineUserRepository implements UserRepositoryInterface
{
    public function existsByEmail(string $email): bool
    {
        $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;
    }
}

// Database query executed:
// SELECT COUNT(id) FROM users WHERE email = 'user@example.com'

Data Flow:

Command (email string)
  โ†’ Port method call
    โ†’ Adapter (Doctrine QueryBuilder)
      โ†’ SQL Query
        โ†’ Database
          โ†’ Result (0)
            โ†’ Adapter (false)
              โ†’ Port (false)
                โ†’ Handler (proceeds)

Phase 3: Domain Layer Business Logic

Step 13: Factory Invocation

// Handler delegates creation to factory
$user = UserFactory::create($command->email, $command->password);

Step 14-17: Email Value Object Creation

// Factory creates Email value object
$email = new Email($command->email);

// Email constructor validates
final readonly class Email
{
    public function __construct(public string $value)
    {
        // Business rule: must be valid email
        if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
            throw new InvalidEmailException($value);
        }

        // Business rule: corporate domain only (example)
        if (!str_ends_with($value, '@company.com')) {
            throw new InvalidEmailDomainException($value);
        }
    }
}

Data Transformation:

Primitive string โ†’ Email Value Object (Domain)

Step 18-20: Password Hashing

// Factory creates hashed password
$hashedPassword = HashedPassword::fromPlaintext($command->password);

// Value object handles hashing
final readonly class HashedPassword
{
    private function __construct(public string $hash) {}

    public static function fromPlaintext(string $plaintext): self
    {
        // Business rule: minimum length
        if (strlen($plaintext) < 8) {
            throw new PasswordTooShortException();
        }

        // Hash the password
        $hash = password_hash($plaintext, PASSWORD_ARGON2ID);

        return new self($hash);
    }
}

Data Transformation:

Plaintext string โ†’ HashedPassword Value Object (Domain)

Step 21-23: Entity Creation

// Factory creates entity with all value objects
public static function create(string $email, string $password): User
{
    return new User(
        id: UserId::generate(),
        email: new Email($email),
        password: HashedPassword::fromPlaintext($password),
        isActive: false,
        createdAt: new \DateTimeImmutable()
    );
}

// Entity constructor applies business rules
public function __construct(
    private UserId $id,
    private Email $email,
    private HashedPassword $password,
    private bool $isActive,
    private \DateTimeImmutable $createdAt,
) {
    // Business invariant: new users are inactive
    if ($this->isActive) {
        throw new NewUserCannotBeActiveException();
    }
}

Data Transformation:

Primitives (string, string)
  โ†’ Value Objects (Email, HashedPassword)
    โ†’ Entity (User) [Domain]

Phase 4: Persistence (Infrastructure)

Step 24-28: Save to Database

// Handler saves entity through port
$this->users->save($user);

// Port interface (Domain)
interface UserRepositoryInterface
{
    public function save(User $user): void;
}

// Adapter implementation (Infrastructure)
final class DoctrineUserRepository implements UserRepositoryInterface
{
    public function save(User $user): void
    {
        $this->entityManager->persist($user);
        $this->entityManager->flush();
    }
}

// Doctrine generates SQL:
// INSERT INTO users (id, email, password, is_active, created_at)
// VALUES ('550e8400-...', 'user@example.com', '$argon2id$...', false, '2024-01-15 10:30:00')

Data Transformation:

User Entity (Domain)
  โ†’ Doctrine Metadata Mapping
    โ†’ SQL INSERT Statement
      โ†’ Database Row

Phase 5: Response Path

Step 29-30: Handler Completion

// Handler completes (returns void)
public function __invoke(RegisterUserCommand $command): void
{
    // ... all steps completed

    // No return value (command pattern)
}

Step 31: Response DTO Creation

// Controller receives void, creates response
public function __invoke(Request $request): JsonResponse
{
    $command = new RegisterUserCommand(/*...*/);

    $this->messageBus->dispatch($command);

    // Fetch created user to return
    $user = $this->users->findByEmail($command->email);

    // Transform Entity โ†’ Response DTO
    $response = new UserResponse(
        id: $user->getId()->toString(),
        email: $user->getEmail()->value,
        isActive: $user->isActive(),
        createdAt: $user->getCreatedAt()->format('c')
    );

    return new JsonResponse($response, Response::HTTP_CREATED);
}

Data Transformation:

User Entity (Domain) โ†’ UserResponse DTO (Infrastructure) โ†’ JSON

Step 32-33: JSON Response

Output: HTTP/1.1 201 Created
        Content-Type: application/json

        {
            "id": "550e8400-e29b-41d4-a716-446655440000",
            "email": "user@example.com",
            "isActive": false,
            "createdAt": "2024-01-15T10:30:00+00:00"
        }

Data Transformations

Complete Transformation Chain

1. Raw JSON (HTTP)
   โ†“
2. RegisterUserRequest DTO (Infrastructure - Input validation)
   โ†“
3. RegisterUserCommand (Application - Use case intent)
   โ†“
4. Email + Password (strings)
   โ†“
5. Email Value Object + HashedPassword Value Object (Domain - Business validation)
   โ†“
6. User Entity (Domain - Business logic)
   โ†“
7. Doctrine Entity Metadata (Infrastructure - ORM mapping)
   โ†“
8. SQL INSERT (Infrastructure - Database)
   โ†“
9. Database Row (Persistence)
   โ†“
10. User Entity (Domain - Loaded from DB)
   โ†“
11. UserResponse DTO (Infrastructure - Output formatting)
   โ†“
12. JSON Response (HTTP)

Why So Many Transformations?

Each transformation serves a purpose:

Transformation Purpose Layer
JSON โ†’ Request DTO Input validation, HTTP concerns Infrastructure
Request DTO โ†’ Command Use case intent, application concern Application
Command โ†’ Value Objects Business validation Domain
Value Objects โ†’ Entity Business logic encapsulation Domain
Entity โ†’ SQL Persistence mapping Infrastructure
SQL โ†’ Database Row Storage Infrastructure
Entity โ†’ Response DTO Output formatting, hide internals Infrastructure
Response DTO โ†’ JSON HTTP serialization Infrastructure

Key Principle: Each layer has its own representation, preventing coupling.


Real Example: Register User

Complete Code Flow

// 1. INFRASTRUCTURE: Controller
namespace App\User\Infrastructure\Controller;

#[Route('/api/users', methods: ['POST'])]
final readonly class RegisterUserController extends AbstractController
{
    public function __invoke(Request $request): JsonResponse
    {
        // Deserialize + validate
        $dto = $this->serializer->deserialize(
            $request->getContent(),
            RegisterUserRequest::class,
            'json'
        );

        $violations = $this->validator->validate($dto);
        if (count($violations) > 0) {
            throw new ValidationException($violations);
        }

        // Create command
        $command = new RegisterUserCommand(
            email: $dto->email,
            password: $dto->password
        );

        // Dispatch
        $this->messageBus->dispatch($command);

        // Fetch result
        $user = $this->users->findByEmail($command->email);

        // Create response
        return $this->json(
            new UserResponse(
                id: $user->getId()->toString(),
                email: $user->getEmail()->value,
                isActive: $user->isActive()
            ),
            Response::HTTP_CREATED
        );
    }
}

// 2. APPLICATION: Command (DTO)
namespace App\User\Application\Command;

final readonly class RegisterUserCommand
{
    public function __construct(
        public string $email,
        public string $password,
    ) {}
}

// 3. APPLICATION: Handler
namespace App\User\Application\Handler;

#[AsMessageHandler]
final readonly class RegisterUserHandler
{
    public function __construct(
        private UserRepositoryInterface $users,
        private EventDispatcherInterface $eventDispatcher,
    ) {}

    public function __invoke(RegisterUserCommand $command): void
    {
        // Check uniqueness (application concern - needs repository)
        if ($this->users->existsByEmail($command->email)) {
            throw new EmailAlreadyExistsException($command->email);
        }

        // Create user (domain logic in factory)
        $user = UserFactory::create($command->email, $command->password);

        // Persist (infrastructure concern)
        $this->users->save($user);

        // Dispatch event (infrastructure concern)
        $this->eventDispatcher->dispatch(
            new UserRegisteredEvent($user->getId())
        );
    }
}

// 4. DOMAIN: Factory
namespace App\User\Domain\Factory;

final class UserFactory
{
    public static function create(string $email, string $password): User
    {
        return new User(
            id: UserId::generate(),
            email: new Email($email),           // Validates format
            password: HashedPassword::fromPlaintext($password), // Validates + hashes
            isActive: false,
            createdAt: new \DateTimeImmutable()
        );
    }
}

// 5. DOMAIN: Value Objects
namespace App\User\Domain\ValueObject;

final readonly class Email
{
    public function __construct(public string $value)
    {
        if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
            throw new InvalidEmailException($value);
        }
    }
}

final readonly class HashedPassword
{
    private function __construct(public string $hash) {}

    public static function fromPlaintext(string $plaintext): self
    {
        if (strlen($plaintext) < 8) {
            throw new PasswordTooShortException();
        }

        return new self(password_hash($plaintext, PASSWORD_ARGON2ID));
    }
}

// 6. DOMAIN: Entity
namespace App\User\Domain\Model;

class User
{
    public function __construct(
        private UserId $id,
        private Email $email,
        private HashedPassword $password,
        private bool $isActive,
        private \DateTimeImmutable $createdAt,
    ) {}

    public function activate(): void
    {
        if ($this->isActive) {
            throw new UserAlreadyActiveException();
        }

        $this->isActive = true;
    }

    // Getters...
}

// 7. DOMAIN: Port (Interface)
namespace App\User\Domain\Port;

interface UserRepositoryInterface
{
    public function save(User $user): void;
    public function existsByEmail(string $email): bool;
    public function findByEmail(string $email): ?User;
}

// 8. INFRASTRUCTURE: Adapter (Doctrine Implementation)
namespace App\User\Infrastructure\Persistence;

final class DoctrineUserRepository implements UserRepositoryInterface
{
    public function __construct(
        private EntityManagerInterface $entityManager
    ) {}

    public function save(User $user): void
    {
        $this->entityManager->persist($user);
        $this->entityManager->flush();
    }

    public function existsByEmail(string $email): bool
    {
        return $this->entityManager->createQueryBuilder()
            ->select('COUNT(u.id)')
            ->from(User::class, 'u')
            ->where('u.email = :email')
            ->setParameter('email', $email)
            ->getQuery()
            ->getSingleScalarResult() > 0;
    }

    public function findByEmail(string $email): ?User
    {
        return $this->entityManager
            ->getRepository(User::class)
            ->findOneBy(['email' => $email]);
    }
}

Error Flow

Domain Exception Path

sequenceDiagram
    participant Client
    participant Controller
    participant Handler
    participant Factory
    participant Email

    Client->>Controller: POST /api/users<br/>{"email": "invalid", "password": "secret"}
    Controller->>Handler: dispatch(command)
    Handler->>Factory: create("invalid", "secret")
    Factory->>Email: new Email("invalid")
    Email->>Email: validate format
    Email-->>Factory: โŒ InvalidEmailException
    Factory-->>Handler: โŒ InvalidEmailException
    Handler-->>Controller: โŒ InvalidEmailException
    Controller->>Controller: catch & transform
    Controller-->>Client: 400 Bad Request<br/>{"error": "Invalid email format"}

Infrastructure Exception Path

sequenceDiagram
    participant Handler
    participant Port
    participant Adapter
    participant DB

    Handler->>Port: save(user)
    Port->>Adapter: save(user)
    Adapter->>DB: INSERT INTO users...
    DB-->>Adapter: โŒ Duplicate key violation
    Adapter-->>Port: โŒ UniqueConstraintViolationException
    Port-->>Handler: โŒ UniqueConstraintViolationException
    Handler->>Handler: catch & wrap
    Handler-->>Handler: โŒ EmailAlreadyExistsException

Exception Translation: Infrastructure exceptions are caught and translated to domain exceptions.


Performance Considerations

Query Optimization

// โŒ BAD: N+1 Query Problem
public function listUsers(): array
{
    $users = $this->users->findAll(); // 1 query

    foreach ($users as $user) {
        $user->getOrders(); // N queries!
    }

    return $users;
}

// โœ… GOOD: Eager Loading
public function listUsers(): array
{
    return $this->entityManager->createQueryBuilder()
        ->select('u', 'o')
        ->from(User::class, 'u')
        ->leftJoin('u.orders', 'o')
        ->getQuery()
        ->getResult(); // 1 query
}

Caching Strategy

// Add caching at infrastructure layer
final class CachedUserRepository implements UserRepositoryInterface
{
    public function __construct(
        private UserRepositoryInterface $decorated,
        private CacheInterface $cache,
    ) {}

    public function findByEmail(string $email): ?User
    {
        return $this->cache->get(
            "user:email:{$email}",
            fn() => $this->decorated->findByEmail($email)
        );
    }
}

Database Connection Pooling

Configure in config/packages/doctrine.yaml:

doctrine:
    dbal:
        connections:
            default:
                url: '%env(resolve:DATABASE_URL)%'
                driver: 'pdo_pgsql'
                server_version: '15'
                options:
                    # Connection pooling
                    persistent: true
                    # Prepared statement caching
                    cache_prepared_statements: true

Key Takeaways

  1. Layered Transformations: Data transforms at each boundary to maintain separation
  2. Direction Matters: Dependencies always point inward (Infrastructure โ†’ Application โ†’ Domain)
  3. Ports at Boundaries: All domain access to infrastructure goes through ports
  4. DTOs Everywhere: Input DTO, Command DTO, Entity, Response DTOโ€”each has a purpose
  5. Exception Translation: Infrastructure exceptions become domain exceptions
  6. Performance via Infrastructure: Caching, query optimization happen in adapters, not domain

Next: Port Interface Design Principles โ†’