Skip to main content

2 posts tagged with "Domain Driven Design"

Concept of domain driven design

View All Tags

Domain driven design

· 3 min read
Muneer shafi
Senior software engineer @ qbiltrade

This document explains how to design and implement a Product and Group relationship using the principles of Domain-Driven Design (DDD) in a Symfony application. The example demonstrates creating a Product object while adhering to DDD principles.


Key DDD Concepts

1. Aggregate Root

  • The Group entity is the Aggregate Root in this scenario.
  • A Product is part of the Group aggregate and cannot exist independently.
  • All operations on Product objects must go through the Group entity.

2. Encapsulation of Logic

  • Business rules for managing Product objects are encapsulated within the Group entity.
  • Direct instantiation of Product is not allowed outside the Group context.

3. Value Objects (Optional)

  • Value objects can be used for attributes like Price to encapsulate validation and logic.

Implementation Steps

1. Group Entity

The Group entity is responsible for managing its associated Product objects.

<?php

namespace App\Entity;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
class Group
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;

#[ORM\Column(length: 255)]
private ?string $name = null;

#[ORM\OneToMany(mappedBy: 'group', targetEntity: Product::class, cascade: ['persist', 'remove'])]
private Collection $products;

public function __construct(string $name)
{
$this->name = $name;
$this->products = new ArrayCollection();
}

public function getId(): ?int
{
return $this->id;
}

public function getName(): ?string
{
return $this->name;
}

public function getProducts(): Collection
{
return $this->products;
}

public function addProduct(string $name, float $price, string $description): Product
{
$product = new Product($this, $name, $price, $description);
$this->products->add($product);

return $product;
}
}

2. Product Entity

The Product entity is designed to ensure it is always created within the context of a Group.

<?php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
class Product
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;

#[ORM\Column(length: 255)]
private ?string $name = null;

#[ORM\Column(type: 'float')]
private ?float $price = null;

#[ORM\Column(type: 'text')]
private ?string $description = null;

#[ORM\ManyToOne(targetEntity: Group::class, inversedBy: 'products')]
#[ORM\JoinColumn(nullable: false)]
private ?Group $group = null;

public function __construct(Group $group, string $name, float $price, string $description)
{
$this->group = $group;
$this->name = $name;
$this->price = $price;
$this->description = $description;
}

public function getId(): ?int
{
return $this->id;
}

public function getName(): ?string
{
return $this->name;
}

public function getPrice(): ?float
{
return $this->price;
}

public function getDescription(): ?string
{
return $this->description;
}

public function getGroup(): ?Group
{
return $this->group;
}
}

3. Service Layer for Product Creation

All operations involving domain logic should be handled in a service or application layer.

use App\Entity\Group;
use Doctrine\ORM\EntityManagerInterface;

class ProductService
{
private EntityManagerInterface $entityManager;

public function __construct(EntityManagerInterface $entityManager)
{
$this->entityManager = $entityManager;
}

public function createProductForGroup(int $groupId, string $name, float $price, string $description): void
{
// Fetch the Group
$groupRepository = $this->entityManager->getRepository(Group::class);
$group = $groupRepository->find($groupId);

if (!$group) {
throw new \DomainException('Group not found');
}

// Add Product via Group
$group->addProduct($name, $price, $description);

// Persist changes
$this->entityManager->persist($group); // Products are persisted via cascade
$this->entityManager->flush();
}
}

4. Advantages of This Design

  • Encapsulation: Business logic for creating Product objects is centralized in the Group entity.
  • Aggregate Consistency: Ensures Product objects are always created within a valid Group.
  • Validation: Business rules for Product creation can be enforced in the addProduct method of the Group entity.

Example Usage

$productService = new ProductService($entityManager);
$productService->createProductForGroup(1, 'Sample Product', 100.0, 'Sample Description');

Conclusion

By following the principles of DDD, this approach ensures:

  • Clear separation of concerns.
  • Consistent and valid domain models.
  • Adherence to the Aggregate Root pattern, making the domain more maintainable and expressive.

Dependency Injection in Symfony

· 6 min read
Muneer shafi
Senior software engineer @ qbiltrade

Table of Contents

Understanding Dependency Injection

What is Dependency Injection?

Dependency Injection (DI) is a design pattern that implements Inversion of Control (IoC) for resolving dependencies. Instead of creating objects directly within a class, dependencies are "injected" into the class from the outside.

Benefits

  • Improved modularity
  • Easier unit testing
  • More maintainable code
  • Reduced coupling between classes
  • Better separation of concerns
  • Enhanced code reusability

Core Concepts

1. Service

A service is any PHP object that performs a specific task. Examples include:

  • Database connections
  • Mailers
  • Logger services
  • Custom business logic classes

2. Service Container

The service container, also known as DI container, is responsible for:

  • Managing service instantiation
  • Resolving dependencies
  • Managing service lifecycle

3. Configuration

Services are configured using:

  • YAML files
  • XML files
  • PHP configuration
  • Attributes/Annotations

Types of Dependency Injection

1. Constructor Injection

// Most recommended approach
class UserService
{
private EmailService $emailService;
private LoggerInterface $logger;

public function __construct(
EmailService $emailService,
LoggerInterface $logger
) {
$this->emailService = $emailService;
$this->logger = $logger;
}
}

2. Setter Injection

class UserService
{
private EmailService $emailService;

public function setEmailService(EmailService $emailService): void
{
$this->emailService = $emailService;
}
}

3. Property Injection

class UserService
{
#[Inject]
private EmailService $emailService;
}

Symfony Service Container

Basic Service Configuration (services.yaml)

services:
# Default configuration
_defaults:
autowire: true
autoconfigure: true
public: false

# Registers services in src/ directory
App\:
resource: '../src/*'
exclude: '../src/{DependencyInjection,Entity,Tests,Kernel.php}'

Manual Service Configuration

services:
App\Service\CustomService:
arguments:
$apiKey: '%env(API_KEY)%'
$logger: '@logger'
calls:
- setMailer: ['@mailer']
tags: ['app.custom_service']

Practical Examples

1. Basic Service Class

namespace App\Service;

class NewsletterManager
{
public function __construct(
private readonly MailerInterface $mailer,
private readonly LoggerInterface $logger,
private readonly string $sender
) {}

public function send(string $subject, string $content, array $recipients): void
{
try {
$email = (new Email())
->from($this->sender)
->subject($subject)
->text($content);

foreach ($recipients as $recipient) {
$email->addTo($recipient);
}

$this->mailer->send($email);
$this->logger->info('Newsletter sent successfully');
} catch (\Exception $e) {
$this->logger->error('Failed to send newsletter: ' . $e->getMessage());
throw $e;
}
}
}

2. Controller Using Services

namespace App\Controller;

use App\Service\NewsletterManager;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

class NewsletterController extends AbstractController
{
public function __construct(
private readonly NewsletterManager $newsletterManager
) {}

#[Route('/send-newsletter', name: 'send_newsletter')]
public function send(): Response
{
$this->newsletterManager->send(
'Weekly Newsletter',
'Newsletter content...',
['user1@example.com', 'user2@example.com']
);

return new Response('Newsletter sent!');
}
}

3. Service with Configuration

namespace App\Service;

class PaymentProcessor
{
public function __construct(
private readonly string $apiKey,
private readonly bool $testMode,
private readonly LoggerInterface $logger
) {}

public function processPayment(float $amount, string $currency): bool
{
$this->logger->info('Processing payment', [
'amount' => $amount,
'currency' => $currency,
'mode' => $this->testMode ? 'test' : 'live'
]);

// Payment processing logic...
return true;
}
}

Configuration in services.yaml:

services:
App\Service\PaymentProcessor:
arguments:
$apiKey: '%env(PAYMENT_API_KEY)%'
$testMode: '%env(bool:PAYMENT_TEST_MODE)%'

Best Practices

1. Constructor Injection

  • Prefer constructor injection over other types
  • Makes dependencies explicit
  • Ensures required dependencies are provided

2. Interface Injection

interface MessageSenderInterface
{
public function send(string $message): void;
}

class EmailSender implements MessageSenderInterface
{
public function send(string $message): void
{
// Send email implementation
}
}

class SMSSender implements MessageSenderInterface
{
public function send(string $message): void
{
// Send SMS implementation
}
}

class NotificationService
{
public function __construct(
private readonly MessageSenderInterface $sender
) {}

public function notify(string $message): void
{
$this->sender->send($message);
}
}

3. Service Configuration

  • Use autowiring when possible
  • Configure services as private by default
  • Use interfaces for type-hinting
  • Use environment variables for configuration

Advanced Concepts

1. Tagged Services

interface HandlerInterface
{
public function handle(mixed $data): void;
}

#[AsTaggedItem('app.handler')]
class LogHandler implements HandlerInterface
{
public function handle(mixed $data): void
{
// Log handling logic
}
}

class HandlerManager
{
/**
* @param iterable<HandlerInterface> $handlers
*/
public function __construct(
#[TaggedIterator('app.handler')]
private readonly iterable $handlers
) {}

public function process(mixed $data): void
{
foreach ($this->handlers as $handler) {
$handler->handle($data);
}
}
}

2. Factory Services

namespace App\Factory;

class PaymentProcessorFactory
{
public function __construct(
private readonly LoggerInterface $logger,
private readonly string $apiKey
) {}

public function create(string $type): PaymentProcessorInterface
{
return match ($type) {
'stripe' => new StripeProcessor($this->apiKey, $this->logger),
'paypal' => new PayPalProcessor($this->apiKey, $this->logger),
default => throw new \InvalidArgumentException('Invalid processor type')
};
}
}

3. Compiler Passes

namespace App\DependencyInjection;

use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;

class HandlerPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
if (!$container->has(HandlerManager::class)) {
return;
}

$definition = $container->findDefinition(HandlerManager::class);
$handlers = [];

foreach ($container->findTaggedServiceIds('app.handler') as $id => $tags) {
$handlers[] = new Reference($id);
}

$definition->setArgument('$handlers', $handlers);
}
}

Common Pitfalls and Solutions

1. Circular Dependencies

  • Avoid circular dependencies between services
  • Use service locator pattern if necessary
  • Consider redesigning the architecture

2. Service Scope

use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;

#[Autoconfigure(tags: ['controller.service_arguments'])]
class ScopedService implements ServiceSubscriberInterface
{
public static function getSubscribedServices(): array
{
return [
'request_stack' => RequestStack::class,
];
}
}

3. Performance Optimization

  • Use compiled container in production
  • Configure service sharing appropriately
  • Use lazy loading for heavy services
services:
App\Service\HeavyService:
lazy: true

Testing with Dependency Injection

1. Unit Testing

namespace App\Tests\Service;

use PHPUnit\Framework\TestCase;

class NewsletterManagerTest extends TestCase
{
private NewsletterManager $manager;
private MockObject $mailer;
private MockObject $logger;

protected function setUp(): void
{
$this->mailer = $this->createMock(MailerInterface::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->manager = new NewsletterManager(
$this->mailer,
$this->logger,
'sender@example.com'
);
}

public function testSendNewsletter(): void
{
$this->mailer
->expects($this->once())
->method('send');

$this->manager->send(
'Test Subject',
'Test Content',
['recipient@example.com']
);
}
}

2. Integration Testing

namespace App\Tests\Controller;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

class NewsletterControllerTest extends WebTestCase
{
public function testSendNewsletter(): void
{
$client = static::createClient();

$client->request('GET', '/send-newsletter');

$this->assertResponseIsSuccessful();
}
}

Conclusion

Dependency Injection in Symfony provides a robust foundation for building maintainable and testable applications. By following the principles and practices outlined in this guide, you can create more modular and flexible code that's easier to maintain and test.

Remember to:

  • Use constructor injection when possible
  • Leverage autowiring for simple cases
  • Configure services explicitly when needed
  • Follow Symfony's best practices
  • Write tests for your services