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. @xabbuh && @el_stoffel

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

    Symfony User Group Berlin Christian Flothmann Software Developer @ SensioLabs Symfony Core & Docs Member
  3. @xabbuh && @el_stoffel Our Use Case

  4. @xabbuh && @el_stoffel Simple Product CRUD

  5. @xabbuh && @el_stoffel @xabbuh && @el_stoffel

  6. @xabbuh && @el_stoffel Forms

  7. @xabbuh && @el_stoffel @xabbuh && @el_stoffel

  8. @xabbuh && @el_stoffel Domain

  9. @xabbuh && @el_stoffel Product Name Category Price Category Name Parent

    Price Amount Taxrate Currency Category
  10. @xabbuh && @el_stoffel Standard Forms 1. Solution

  11. @xabbuh && @el_stoffel Implementation

  12. @xabbuh && @el_stoffel Controller src/Controller/ProductController.php @xabbuh && @el_stoffel

  13. @xabbuh && @el_stoffel FormType @xabbuh && @el_stoffel src/Form/ProductType.php

  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
  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
  16. @xabbuh && @el_stoffel Data Flow

  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
  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
  19. @xabbuh && @el_stoffel HttpFoundation Controller Controller @xabbuh && @el_stoffel

  20. @xabbuh && @el_stoffel HttpFoundation Controller Controller @xabbuh && @el_stoffel

  21. @xabbuh && @el_stoffel Controller FormType Request Handler @xabbuh && @el_stoffel

  22. @xabbuh && @el_stoffel Code

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

    Code Controller FormType Domain Product Category Price
  24. @xabbuh && @el_stoffel FormType Model HTTP Request Front Controller Controller

    Form Glue Code Domain Symfony Model
  25. @xabbuh && @el_stoffel Anemic Domain Model

  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
  27. @xabbuh && @el_stoffel Rich Domain Model

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

    valid by design easy to test defined state transitions
  29. @xabbuh && @el_stoffel Anemic vs. Rich Model

  30. @xabbuh && @el_stoffel @xabbuh && @el_stoffel

  31. @xabbuh && @el_stoffel @xabbuh && @el_stoffel

  32. @xabbuh && @el_stoffel @xabbuh && @el_stoffel

  33. @xabbuh && @el_stoffel @xabbuh && @el_stoffel

  34. @xabbuh && @el_stoffel @xabbuh && @el_stoffel

  35. @xabbuh && @el_stoffel @xabbuh && @el_stoffel

  36. @xabbuh && @el_stoffel @xabbuh && @el_stoffel

  37. @xabbuh && @el_stoffel @xabbuh && @el_stoffel

  38. @xabbuh && @el_stoffel @xabbuh && @el_stoffel

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

    - sp00m on stackoverflow - @xabbuh && @el_stoffel
  40. @xabbuh && @el_stoffel Anemic Models Prototyping Ease of use Easily

    generated Rich Models Clean Code Testability Truly OOP Your Choice!
  41. @xabbuh && @el_stoffel Let's use Rich Domain Models with Symfony

    Forms
  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
  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
  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
  45. @xabbuh && @el_stoffel Model src/Entity/Price.php @xabbuh && @el_stoffel

  46. @xabbuh && @el_stoffel Model src/Entity/Category.php @xabbuh && @el_stoffel

  47. @xabbuh && @el_stoffel @xabbuh && @el_stoffel

  48. @xabbuh && @el_stoffel 2. Solution Easy: Data ransfer bject T

    O s
  49. @xabbuh && @el_stoffel Implementation

  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
  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
  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
  53. @xabbuh && @el_stoffel FormType src/Form/ProductType.php @xabbuh && @el_stoffel

  54. @xabbuh && @el_stoffel Controller src/Controller/ProductController.php @xabbuh && @el_stoffel

  55. @xabbuh && @el_stoffel Data Flow

  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
  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
  58. @xabbuh && @el_stoffel HttpFoundation Controller Controller @xabbuh && @el_stoffel

  59. @xabbuh && @el_stoffel HttpFoundation Controller Controller @xabbuh && @el_stoffel

  60. @xabbuh && @el_stoffel Controller FormType Request Handler ProductDto ProductDto:: @xabbuh

    && @el_stoffel
  61. @xabbuh && @el_stoffel HttpFoundation Controller Controller @xabbuh && @el_stoffel

  62. @xabbuh && @el_stoffel Code

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

    Code Controller FormType DTO Domain Product Category Price
  64. @xabbuh && @el_stoffel FormType HTTP Request Front Controller Controller Form

    Glue Code Domain Symfony DTO Model Model DTO
  65. @xabbuh && @el_stoffel DTO Solution enables us to use Rich

    Domain Models easy to implement and maintain additional Glue Code redundant validation rules
  66. @xabbuh && @el_stoffel Hard: DataMapper 3. Solution

  67. @xabbuh && @el_stoffel Implementation

  68. @xabbuh && @el_stoffel Controller src/Controller/ProductController.php @xabbuh && @el_stoffel same as

    first solution
  69. @xabbuh && @el_stoffel FormType src/Form/ProductType.php @xabbuh && @el_stoffel

  70. @xabbuh && @el_stoffel FormType src/Form/ProductType.php @xabbuh && @el_stoffel

  71. @xabbuh && @el_stoffel FormType src/Form/ProductType.php @xabbuh && @el_stoffel

  72. @xabbuh && @el_stoffel FormType src/Form/ProductType.php @xabbuh && @el_stoffel

  73. @xabbuh && @el_stoffel Data Flow

  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
  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
  76. @xabbuh && @el_stoffel HttpFoundation Controller Controller @xabbuh && @el_stoffel

  77. @xabbuh && @el_stoffel HttpFoundation Controller Controller @xabbuh && @el_stoffel

  78. @xabbuh && @el_stoffel Request Handler Controller FormType DataMapper @xabbuh &&

    @el_stoffel
  79. @xabbuh && @el_stoffel Code

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

    Code Controller FormType DataMapper Domain Product Category Price
  81. @xabbuh && @el_stoffel Form Type Model HTTP Request Front Controller

    Controller Form Glue Code Domain Symfony Model Data Mapper Data Mapper
  82. @xabbuh && @el_stoffel DataMapper Solution enables us to use Rich

    Domain Models requires advanced knowledge about Forms additional Glue Code hard to test
  83. @xabbuh && @el_stoffel Still not satisfied?

  84. @xabbuh && @el_stoffel We feel you …

  85. @xabbuh && @el_stoffel Introducing …

  86. @xabbuh && @el_stoffel New: RichModelFormsBundle 4. Solution

  87. @xabbuh && @el_stoffel sensiolabs-de/rich-model-forms-bundle Disclaimer: Still Experimental

  88. @xabbuh && @el_stoffel Installation

  89. @xabbuh && @el_stoffel composer require \ sensiolabs-de/rich-model-forms-bundle

  90. @xabbuh && @el_stoffel Features

  91. @xabbuh && @el_stoffel Different read/write property paths

  92. @xabbuh && @el_stoffel Different read/write property paths

  93. @xabbuh && @el_stoffel Exception Handling

  94. @xabbuh && @el_stoffel Exception Handling

  95. @xabbuh && @el_stoffel Mandatory Constructor Arguments

  96. @xabbuh && @el_stoffel Mandatory Constructor Arguments

  97. @xabbuh && @el_stoffel Immutable Value Object

  98. @xabbuh && @el_stoffel Immutable Value Object

  99. @xabbuh && @el_stoffel Implementation

  100. @xabbuh && @el_stoffel Controller src/Controller/ProductController.php still same as first solution

    @xabbuh && @el_stoffel
  101. @xabbuh && @el_stoffel FormType src/Form/ProductType.php @xabbuh && @el_stoffel

  102. @xabbuh && @el_stoffel FormType src/Form/ProductType.php @xabbuh && @el_stoffel

  103. @xabbuh && @el_stoffel Data Flow

  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
  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
  106. @xabbuh && @el_stoffel HttpFoundation Controller Controller @xabbuh && @el_stoffel

  107. @xabbuh && @el_stoffel HttpFoundation Controller Controller @xabbuh && @el_stoffel

  108. @xabbuh && @el_stoffel Request Handler Controller FormType RichModelFormsBundle @xabbuh &&

    @el_stoffel
  109. @xabbuh && @el_stoffel Code

  110. @xabbuh && @el_stoffel Symfony Front Controller HttpFoundation Form New Bundle

    Glue Code Controller FormType Domain Model
  111. @xabbuh && @el_stoffel FormType Model HTTP Request Front Controller Controller

    Form Glue Code Domain Symfony Model
  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
  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
  114. @xabbuh && @el_stoffel Your turn! Help us make it stable!

  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
  116. @xabbuh && @el_stoffel Thank you! Questions?