Using Symfony Forms with Rich Domain Models

Using Symfony Forms with Rich Domain Models

With the popularisation of DDD people started shifting from anemic models with only getters and setters to a rich model describing the state changes in specific methods. This way of designing models does not play well with Symfony forms. User provided input is inherently invalid while we want to maintain certain invariants in our domain model. A common approach to overcome these limitations is to create data transfer objects our forms are then bound to. This can lead to lots of mapping & glue code that might be cumbersome to write and maintain. But couldn’t we do better? In this talk we will discuss the different aspects of a rich domain model that makes it hard to use it in conjunction with the Form component. We will then look at the possibilities to hook into the data flow of the form handling and discover how we can modify it to interact seamlessly with our model.

Bundle: https://github.com/sensiolabs-de/rich-model-forms-bundle
Demo App: https://github.com/sensiolabs-de/rich-model-forms-demo

0bee6a2886272e60be8888ae48baf42d?s=128

Christopher Hertel

December 06, 2018
Tweet

Transcript

  1. 2.

    @xabbuh && @el_stoffel Christopher Hertel Consultant & Trainer @ SensioLabs

    Symfony User Group Berlin Christian Flothmann Software Developer @ SensioLabs Symfony Core & Docs Member
  2. 14.

    @xabbuh && @el_stoffel class Product { private $id; /** *

    @Assert\Length(min=3) */ private $name; /** * @Assert\NotNull */ private $category; /** * @Assert\GreaterThan(0) */ private $priceAmount; /** * @Assert\GreaterThanOrEqual(0) */ private $priceTax; /** * @Assert\Currency */ private $priceCurrency; public function getId(): ?int { Model @xabbuh && @el_stoffel src/Entity/Product.php
  3. 15.

    @xabbuh && @el_stoffel * @Assert\Currency */ private $priceCurrency; public function

    getId(): ?int { return $this->id; } public function getName(): ?string { return $this->name; } public function setName(?string $name): void { $this->name = $name; } public function getCategory(): ?Category { return $this->category; } public function setCategory(?Category $category): void { $this->category = $category; } public function getPriceAmount(): ?int { return $this->priceAmount; } public function setPriceAmount(?int $priceAmount): void { $this->priceAmount = $priceAmount; } Model @xabbuh && @el_stoffel src/Entity/Product.php
  4. 17.

    @xabbuh && @el_stoffel HTML Form POST /product HTTP/1.1 Host: localhost:8000

    Connection: keep-alive Content-Length: 133 Cache-Control: max-age=0 [...] Accept-Language: en;q=0.9 product[name]: rocket product[category]: 18 product[priceAmount]: 0.59 product[priceTax]: 7 product[priceCurrency]: EUR HTTP Request Form Submit @xabbuh && @el_stoffel
  5. 18.

    @xabbuh && @el_stoffel POST /product HTTP/1.1 Host: localhost:8000 Connection: keep-alive

    Content-Length: 133 Cache-Control: max-age=0 [...] Accept-Language: en;q=0.9 product[name]: rocket product[category]: 18 product[priceAmount]: 0.59 product[priceTax]: 7 product[priceCurrency]: EUR HTTP Request HttpFoundation Front Controller @xabbuh && @el_stoffel
  6. 23.

    @xabbuh && @el_stoffel Symfony Front Controller HttpFoundation Form Validator Glue

    Code Controller FormType Domain Product Category Price
  7. 26.

    @xabbuh && @el_stoffel Anemic Domain Model focus on data structured

    easy to implement and to maintain contains little or no logic no guarantee to be valid or consistent
  8. 28.

    @xabbuh && @el_stoffel Rich Domain Model combines data and logic

    valid by design easy to test defined state transitions
  9. 39.

    @xabbuh && @el_stoffel "how not to agree about software design"

    - sp00m on stackoverflow - @xabbuh && @el_stoffel
  10. 40.

    @xabbuh && @el_stoffel Anemic Models Prototyping Ease of use Easily

    generated Rich Models Clean Code Testability Truly OOP Your Choice!
  11. 42.

    @xabbuh && @el_stoffel class Product { private $id; private $name;

    private $category; private $price; public function __construct(string $name, Category $category, Price $price) { $this->validateName($name); $this->name = $name; $this->category = $category; $this->price = $price; } public function getId(): int { return $this->id; } public function getName(): string { return $this->name; } public function rename(string $name): void { $this->validateName($name); Model src/Entity/Product.php @xabbuh && @el_stoffel
  12. 43.

    @xabbuh && @el_stoffel public function getId(): int { return $this->id;

    } public function getName(): string { return $this->name; } public function rename(string $name): void { $this->validateName($name); $this->name = $name; } public function getCategory(): Category { return $this->category; } public function moveToCategory(Category $category): void { $this->category = $category; } public function getPrice(): Price { return $this->price; } public function costs(Price $price): void { $this->price = $price; } Model src/Entity/Product.php @xabbuh && @el_stoffel
  13. 44.

    @xabbuh && @el_stoffel $this->name = $name; } public function getCategory():

    Category { return $this->category; } public function moveToCategory(Category $category): void { $this->category = $category; } public function getPrice(): Price { return $this->price; } public function costs(Price $price): void { $this->price = $price; } private function validateName(string $name): void { if (strlen($name) < 3) { throw ProductException::invalidName($name); } } } Model src/Entity/Product.php @xabbuh && @el_stoffel
  14. 50.

    @xabbuh && @el_stoffel final class Product { public $id; /**

    * @Assert\Length(min=3) */ public $name; /** * @Assert\NotNull */ public $category; /** * @Assert\GreaterThan(0) */ public $priceAmount; /** * @Assert\GreaterThanOrEqual(0) */ public $priceTax; /** * @Assert\Currency */ public $priceCurrency; public static function fromEntity(ProductEntity $product = null): self DTO src/Form/DTO/Product.php @xabbuh && @el_stoffel
  15. 51.

    @xabbuh && @el_stoffel /** * @Assert\Currency */ public $priceCurrency; public

    static function fromEntity(ProductEntity $product = null): self { $self = new self(); if (null === $product) { return $self; } $self->id = $product->getId(); $self->name = $product->getName(); $self->category = $product->getCategory(); $self->priceAmount = $product->getPrice()->getAmount(); $self->priceTax = $product->getPrice()->getTax(); $self->priceCurrency = $product->getPrice()->getCurrency(); return $self; } public function toEntity(ProductEntity $product = null): ProductEntity { $price = new Price((int) $this->priceAmount, $this->priceTax, $this- >priceCurrency); DTO src/Form/DTO/Product.php @xabbuh && @el_stoffel
  16. 52.

    @xabbuh && @el_stoffel $self->category = $product->getCategory(); $self->priceAmount = $product->getPrice()->getAmount(); $self->priceTax

    = $product->getPrice()->getTax(); $self->priceCurrency = $product->getPrice()->getCurrency(); return $self; } public function toEntity(ProductEntity $product = null): ProductEntity { $price = new Price((int) $this->priceAmount, $this->priceTax, $this->priceCurrency); if (null === $product) { return new ProductEntity($this->name, $this->category, $price); } if ($product->getName() !== $this->name) { $product->rename($this->name); } if ($product->getCategory() !== $this->category) { $product->moveToCategory($this->category); } if (!$price->equals($product->getPrice())) { $product->costs($price); } return $product; } } DTO src/Form/DTO/Product.php @xabbuh && @el_stoffel
  17. 56.

    @xabbuh && @el_stoffel HTML Formular POST /product HTTP/1.1 Host: localhost:8000

    Connection: keep-alive Content-Length: 133 Cache-Control: max-age=0 [...] Accept-Language: en;q=0.9 product[name]: rocket product[category]: 18 product[priceAmount]: 0.59 product[priceTax]: 7 product[priceCurrency]: EUR HTTP Request Form Submit @xabbuh && @el_stoffel
  18. 57.

    @xabbuh && @el_stoffel POST /product HTTP/1.1 Host: localhost:8000 Connection: keep-alive

    Content-Length: 133 Cache-Control: max-age=0 [...] Accept-Language: en;q=0.9 product[name]: rocket product[category]: 18 product[priceAmount]: 0.59 product[priceTax]: 7 product[priceCurrency]: EUR HTTP Request HttpFoundation Front Controller @xabbuh && @el_stoffel
  19. 63.

    @xabbuh && @el_stoffel Symfony Front Controller HttpFoundation Form Validator Glue

    Code Controller FormType DTO Domain Product Category Price
  20. 65.

    @xabbuh && @el_stoffel DTO Solution enables us to use Rich

    Domain Models easy to implement and maintain additional Glue Code redundant validation rules
  21. 74.

    @xabbuh && @el_stoffel HTML Formular Form Submit POST /product HTTP/1.1

    Host: localhost:8000 Connection: keep-alive Content-Length: 133 Cache-Control: max-age=0 [...] Accept-Language: en;q=0.9 product[name]: rocket product[category]: 18 product[price][amount]: 0.59 product[price][tax]: 7 product[price][currency]: EUR HTTP Request @xabbuh && @el_stoffel
  22. 75.

    @xabbuh && @el_stoffel Front Controller POST /product HTTP/1.1 Host: localhost:8000

    Connection: keep-alive Content-Length: 133 Cache-Control: max-age=0 [...] Accept-Language: en;q=0.9 product[name]: rocket product[category]: 18 product[price][amount]: 0.59 product[price][tax]: 7 product[price][currency]: EUR HTTP Request HttpFoundation @xabbuh && @el_stoffel
  23. 80.

    @xabbuh && @el_stoffel Symfony Front Controller HttpFoundation Form Validator Glue

    Code Controller FormType DataMapper Domain Product Category Price
  24. 81.

    @xabbuh && @el_stoffel Form Type Model HTTP Request Front Controller

    Controller Form Glue Code Domain Symfony Model Data Mapper Data Mapper
  25. 82.

    @xabbuh && @el_stoffel DataMapper Solution enables us to use Rich

    Domain Models requires advanced knowledge about Forms additional Glue Code hard to test
  26. 104.

    @xabbuh && @el_stoffel HTML Formular Form Submit POST /product HTTP/1.1

    Host: localhost:8000 Connection: keep-alive Content-Length: 133 Cache-Control: max-age=0 [...] Accept-Language: en;q=0.9 product[name]: rocket product[category]: 18 product[price][amount]: 0.59 product[price][tax]: 7 product[price][currency]: EUR HTTP Request
  27. 105.

    @xabbuh && @el_stoffel POST /product HTTP/1.1 Host: localhost:8000 Connection: keep-alive

    Content-Length: 133 Cache-Control: max-age=0 [...] Accept-Language: en;q=0.9 product[name]: rocket product[category]: 18 product[price][amount]: 0.59 product[price][tax]: 7 product[price][currency]: EUR HTTP Request HttpFoundation Front Controller
  28. 112.

    @xabbuh && @el_stoffel RichModelFormsBundle to the rescue! generalizes the previous

    approach additional form config options tailored for rich model form needs released, but still in development
  29. 113.

    @xabbuh && @el_stoffel Final Disclaimer not feature complete covers common

    use cases we discovered needs input for more use cases no, not yet in production
  30. 115.

    @xabbuh && @el_stoffel DTOs New Bundle DataMapper hard to implement

    hard to test additional glue code easy to implement duplicated validation additional glue code no glue code needed experimental future core feature? Using Symfony Forms with Rich Domain Models