Skip to main content

Symfony GraphQL Architecture Guide

tip

This page is current and ready for use.

Overview

This guide outlines the architecture patterns for handling entities in a Symfony application with GraphQL using the overblog/graphql-bundle. The architecture is divided into several layers, each with specific responsibilities.

Architecture Layers

1. Entity Resolvers (GraphQL Layer)

Entity Resolvers act as the entry point for GraphQL operations. They handle the translation between GraphQL queries/mutations and your domain logic.

When to Use Direct Entity Resolver

  • Custom complex operations
  • Operations requiring specific business rules
  • Complex input transformations
  • Operations involving multiple services
class UserMutationResolver extends BaseEntityResolver
{
public function createUser(ArgumentInterface $args): User
{
$dto = CreateUserDTO::fromArray($args['input']);
return $this->userService->createUser($dto);
}
}

2. BaseEntityResolver (Abstract GraphQL Layer)

BaseEntityResolver provides reusable functionality for common CRUD operations.

When to Use BaseEntityResolver Methods

  • Standard CRUD operations
  • Pagination
  • Filtering
  • Simple property updates
  • Basic entity retrieval
class ProductResolver extends BaseEntityResolver
{
public function getProducts(ArgumentInterface $args): Connection
{
return $this->getPaginatedEntities(Product::class, $args);
}

public function updateProduct(ArgumentInterface $args): Product
{
return $this->validateAndProcessEntity(Product::class, $args);
}
}

3. Services (Business Logic Layer)

Services contain the business logic and orchestrate complex operations.

When to Create a Service

  • Complex business logic
  • Operations involving multiple entities
  • Transaction management
  • Event dispatching
  • External service integration
class UserService
{
public function createUser(CreateUserDTO $dto): User
{
// Transaction handling
$this->repository->beginTransaction();
try {
$user = new User();
// Set user properties
$this->repository->save($user);

// Add related entities
$this->emailService->addEmail($user, $dto->email);
$this->mobileService->addMobile($user, $dto->mobile);

$this->repository->commit();

// Dispatch events
$this->dispatcher->dispatch(new UserCreatedEvent($user));

return $user;
} catch (\Exception $e) {
$this->repository->rollback();
throw $e;
}
}
}

4. Repositories (Data Access Layer)

Repositories handle data persistence and retrieval.

When to Create Repository Methods

  • Complex database queries
  • Custom finder methods
  • Specific data retrieval patterns
  • Performance optimizations
interface UserRepositoryInterface
{
public function save(User $user): void;
public function findByMobile(string $mobile): ?User;
public function findActiveWithVerifiedEmail(): array;
}

class UserRepository implements UserRepositoryInterface
{
public function findActiveWithVerifiedEmail(): array
{
return $this->createQueryBuilder('u')
->innerJoin('u.userEmails', 'ue')
->where('ue.isVerified = :verified')
->setParameter('verified', true)
->getQuery()
->getResult();
}
}

5. Collections (Data Transfer Layer)

Collections handle groups of entities and provide methods for bulk operations.

When to Use Collections

  • Bulk operations
  • Entity relationship management
  • Custom iteration logic
  • Specialized filtering
class UserCollection implements \IteratorAggregate
{
private array $users = [];

public function add(User $user): void
{
$this->users[] = $user;
}

public function getActiveUsers(): self
{
return new self(
array_filter($this->users, fn(User $user) => $user->isActive())
);
}

public function getIterator(): \Traversable
{
return new \ArrayIterator($this->users);
}
}

Decision Flow Chart

When handling an operation, follow this decision flow:

  1. Is it a simple CRUD operation?

    • Yes → Use BaseEntityResolver methods
    • No → Continue to 2
  2. Does it require complex business logic?

    • Yes → Create a Service method
    • No → Continue to 3
  3. Is it a specialized data query?

    • Yes → Create a Repository method
    • No → Continue to 4
  4. Does it handle multiple entities as a group?

    • Yes → Use or create a Collection
    • No → Use BaseEntityResolver

Best Practices

  1. Separation of Concerns

    • Keep GraphQL logic in Resolvers
    • Keep business logic in Services
    • Keep data access in Repositories
  2. Use DTOs

    • For input validation
    • For data transformation
    • To decouple layers
  3. Transaction Management

    • Handle transactions in Services
    • Use try-catch blocks
    • Always include rollback logic
  4. Event Handling

    • Dispatch events after successful operations
    • Handle events asynchronously when possible
    • Use event subscribers for cross-cutting concerns
  5. Error Handling

    • Create custom exceptions
    • Log errors appropriately
    • Return meaningful GraphQL errors

Example Flow

Here's an example of how these components work together:

mutation {
createUser(input: {
name: "John Doe",
email: "[email protected]",
mobile: "+1234567890"
}) {
id
name
email {
address
isVerified
}
}
}
  1. GraphQL request → UserMutationResolver
  2. Resolver validates input and creates DTO
  3. Resolver calls UserService
  4. UserService orchestrates the operation:
    • Starts transaction
    • Creates User entity
    • Uses EmailService and MobileService
    • Saves via Repository
    • Commits transaction
    • Dispatches events
  5. Response returns to client

Conclusion

This architecture provides a clear separation of concerns while maintaining flexibility. Use BaseEntityResolver for simple CRUD operations, and create custom services for complex business logic. Always consider the appropriate layer for each piece of functionality.