Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Immutability to Save an Ever-Changing World

Immutability to Save an Ever-Changing World

Andrew Cassell
Thursday 18 May 2023 from 11:00 to 12:00
Talk in English - US at php[tek] 2023
Short URL: https://joind.in/talk/ff357

Want to build software that is more testable, easier to modify, and has fewer lines of code? Architecture with more immutable objects that are always in a valid state is the most important lesson I have learned in building better software applications. Using immutable value objects will lead to less checking, fewer bugs, and more DRY code, and will help avoid the “spooky action at a distance” problem in PHP. We will also learn how to use immutable objects and immutable collections to improve design of our mutable entities. Lastly, we’ll see how our immutable modeling approach can drastically reduce the complexity of things in our systems that are mutable.

Andrew Cassell

May 06, 2024
Tweet

More Decks by Andrew Cassell

Other Decks in Programming

Transcript

  1. 2

  2. $mutableDate = new \DateTime('2023-01-01'); echo $mutableDate->format('Y-m-d H:i:s'); // Outputs "2023-01-01

    00:00:00" $newDate = $mutableDate->add(new \DateInterval(‘P14D')); echo $newDate->format('Y-m-d H:i:s'); echo $mutableDate->format('Y-m-d H:i:s'); Mutable
  3. $mutableDate = new \DateTime('2023-01-01'); echo $mutableDate->format('Y-m-d H:i:s'); // Outputs "2023-01-01

    00:00:00" $newDate = $mutableDate->add(new \DateInterval(‘P14D')); echo $newDate->format('Y-m-d H:i:s'); // Outputs "2023-01-15 00:00:00” echo $mutableDate->format('Y-m-d H:i:s'); Mutable
  4. $mutableDate = new \DateTime('2023-01-01'); echo $mutableDate->format('Y-m-d H:i:s'); // Outputs "2023-01-01

    00:00:00" $newDate = $mutableDate->add(new \DateInterval(‘P14D')); echo $newDate->format('Y-m-d H:i:s'); // Outputs "2023-01-15 00:00:00” echo $mutableDate->format('Y-m-d H:i:s'); // Outputs "2023-01-15 00:00:00" Mutable
  5. $immutableDate = new \DateTimeImmutable('2023-01-01'); echo $immutableDate->format('Y-m-d H:i:s'); // Outputs "2023-01-01

    00:00:00" $newDate = $immutableDate->add(new \DateInterval('P14D')); echo $newDate->format('Y-m-d H:i:s'); echo $immutableDate->format('Y-m-d H:i:s'); Immutable
  6. $immutableDate = new \DateTimeImmutable('2023-01-01'); echo $immutableDate->format('Y-m-d H:i:s'); // Outputs "2023-01-01

    00:00:00" $newDate = $immutableDate->add(new \DateInterval('P14D')); echo $newDate->format('Y-m-d H:i:s'); // Outputs "2023-01-15 00:00:00" echo $immutableDate->format('Y-m-d H:i:s'); Immutable
  7. $immutableDate = new \DateTimeImmutable('2023-01-01'); echo $immutableDate->format('Y-m-d H:i:s'); // Outputs "2023-01-01

    00:00:00" $newDate = $immutableDate->add(new \DateInterval('P14D')); echo $newDate->format('Y-m-d H:i:s'); // Outputs "2023-01-15 00:00:00" echo $immutableDate->format('Y-m-d H:i:s'); // Outputs "2023-01-01 00:00:00" Immutable
  8. class BrewSession { private \DateTime $start; public function __construct(\DateTime $start)

    { $this->start = $start; } public function getStart(): \DateTime { return $this->start; } } $session = new BrewSession(new \DateTime('2023-01-01 15:00')); $brewDate = $session->getStart(); $timeToBottle = $brewDate->add(new \DateInterval('P14D')); echo $session->getStart()->format('Y-m-d H:i:s');
  9. } public function getStart(): \DateTime { return $this->start; } }

    $session = new BrewSession(new \DateTime('2023-01-01 15:00')); $brewDate = $session->getStart(); $timeToBottle = $brewDate->add(new \DateInterval('P14D')); echo $session->getStart()->format('Y-m-d H:i:s');
  10. } public function getStart(): \DateTime { return $this->start; } }

    $session = new BrewSession(new \DateTime('2023-01-01 15:00')); $brewDate = $session->getStart(); $timeToBottle = $brewDate->add(new \DateInterval('P14D')); echo $session->getStart()->format('Y-m-d H:i:s'); // 2023-01-15 15:00:00
  11. class BrewSession { private \DateTimeImmutable $start; public function __construct(\DateTimeImmutable $start)

    { $this->start = $start; } public function getStart(): \DateTimeImmutable { return $this->start; } } $session = new BrewSession(new \DateTimeImmutable('2023-01-01 20:00')); $brewDate = $session->getStart(); $timeToBottle = $brewDate->add(new \DateInterval('P14D')); echo $session->getStart()->format('Y-m-d H:i:s'); // Outputs '2023-01-01 20:00:00'
  12. public function getStart(): \DateTime { return $this->start; } } $session

    = new BrewSession(new \DateTime('2023-01-01 15:00’)); $brewDate = clone $session->getStart(); $timeToBottle = $brewDate->add(new \DateInterval('P14D')); echo $session->getStart()->format('Y-m-d H:i:s'); // 2023-01-01 15:00:00 💩
  13. My pragmatic summary: A large fraction of the flaws in

    software development are due to programmers not fully understanding all the possible states their code may execute in.
  14. •No setters, all parameters are passed into constructor • Declare

    all class properties as private • No properties that are mutable objects Immutable Objects
  15. // PHP 8.1 class RecipeName { public function __construct(private readonly

    string $name) { } public function getValue(): string { return $this->name; } }
  16. Plain Old PHP Objects (POPOs) • Declare Class Properties as

    Private • No Setters (behaviors will return new) • No References to Mutable Objects • Throw Exceptions in Constructor Value Objects
  17. First Name Last Name Full Name Email Address Recipe Name

    Date Brewed Recipe ID Temperature Liters Gallons Kilograms Pounds Value Objects
  18. cassell:beeriously cassell$ docker run --rm --interactive --tty --network beeriously_default --volume

    `pwd`:/app --user : --workdir /app beeriously_php-fpm /app/vendor/bin/phpunit --configuration /app/src/Tests/Unit/ phpunit.xml.dist PHPUnit 6.4.3 by Sebastian Bergmann and contributors. FEE 3 / 3 (100%) Time: 2.34 seconds, Memory: 4.00MB There were 2 errors: 1) Beeriously\Tests\Unit\Domain\Recipe\RecipeNameTest::testGetter Error: Class 'Beeriously\Domain\Recipe\RecipeName' not found /app/src/Tests/Unit/Domain/Recipe/RecipeNameTest.php:20 2) Beeriously\Tests\Unit\Domain\Recipe\RecipeNameTest::testToString Error: Class 'Beeriously\Domain\Recipe\RecipeName' not found /app/src/Tests/Unit/Domain/Recipe/RecipeNameTest.php:26 -- There was 1 failure: 1) Beeriously\Tests\Unit\Domain\Recipe\RecipeNameTest::testEmptyFails Failed asserting that exception of type "Error" matches expected exception "Beeriously\Domain\Recipe\InvalidRecipeNameException". Message was: "Class 'Beeriously\Domain\Recipe\RecipeName' not found" at /app/src/Tests/Unit/Domain/Recipe/RecipeNameTest.php:15 . ERRORS! Tests: 3, Assertions: 1, Errors: 2, Failures: 1.
  19. cassell:beeriously cassell$ docker run --rm --interactive --tty --network beeriously_default --volume

    `pwd`:/app --user : --workdir /app beeriously_php-fpm /app/vendor/bin/phpunit --configuration /app/src/Tests/ Unit/phpunit.xml.dist PHPUnit 6.4.3 by Sebastian Bergmann and contributors. ... 3 / 3 (100%) Time: 192 ms, Memory: 4.00MB OK (3 tests, 4 assertions)
  20. class RecipeName { public function __construct(private readonly string $name) {

    if (empty(trim($this->name))) { throw new InvalidArgumentException('The recipe name cannot be empty.’); } } public function getName(): string { return $this->name; } }
  21. class BrewedOn { public function __construct( private readonly DatetimeInterface $datetime

    ) { if ($this->datetime < new DateTimeImmutable('2023-05-18')) { throw new InvalidArgumentException('The brew date cannot be before May 16, 2023.'); } if ($this->datetime > new DateTimeImmutable(‘2123-05-18')) { throw new InvalidArgumentException('The brew date cannot be that far in the future.'); } } public function getValue(): DatetimeImmutable
  22. class Pounds { public function __construct( private float $weight )

    { if ($this->weight < 0) { throw new InvalidArgumentException('The weight in pounds must be greater than } } public function asFloat(): float { return $this->weight; } }
  23. <?php var_dump(-0.0 > 0); // false var_dump(-0.0 < 0); //

    false var_dump(-0.0 === 0.0); // true (float) var_dump(-0.0 === 0); // false (int) var_dump(-0.0 == false); // true (bool) var_dump(-0.0 == null); // true (bool) var_dump(-0.0 == “”); // true var_dump(-0.0 == "negativezero"); // true (string)
  24. class Pounds { public function __construct( private float $weight )

    { if ($this->weight < 0) { throw new InvalidArgumentException('The weight in pounds must be greater than } if($this->weight === -0) { $this->weight = 0; } } public function getAsFloat(): float { return $this->weight; } }
  25. class FirstName { public function __construct( public readonly string $value,

    ) { if (empty(trim($this->value))) { throw new InvalidArgumentException("First name cannot be e } } } class LastName { public function __construct( public readonly string $value, ) { if (empty(trim($this->value))) { throw new InvalidArgumentException("Last name cannot be em }
  26. class FullName { public function __construct( public readonly FirstName $firstName,

    public readonly LastName $lastName, ) {} public function getFullName(): string { return $this->firstName->value . ' ' . $this->lastName->value; } } // Usage $firstName = new FirstName("Adolphus"); $lastName = new LastName("Busch"); $fullName = new FullName($firstName, $lastName); echo $fullName->getFullName(); // Output: Adolphus Busch
  27. class Pounds { public function decreaseBy(Pounds $amount): self { return

    new self($this->weight - $amount->weight); } public function isGreaterThan(Pounds $b): bool { return $this->weight > $b->weight; } public function toKilograms(): Kilograms { return new Kilograms($this->weight * 0.453592); } }
  28. use PHPUnit\Framework\TestCase; class PoundsTest extends TestCase { public function testConstructorThrowsExceptionOnZeroWeight()

    { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('The weight in pounds must be greater than zero.'); new Pounds(0); } public function testConstructorThrowsExceptionOnNegativeWeight() { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('The weight in pounds must be greater than zero.'); new Pounds(-10); } public function testConstructorSetsWeightToZeroForNegativeZero() { $pounds = new Pounds(-0); $this->assertSame(0.0, $pounds->asFloat());
  29. $this->expectExceptionMessage('The weight in pounds must be greater than zero.'); new

    Pounds(-10); } public function testConstructorSetsWeightToZeroForNegativeZero() { $pounds = new Pounds(-0); $this->assertSame(0.0, $pounds->asFloat()); } public function testDecreaseBy() { $pounds1 = new Pounds(10); $pounds2 = new Pounds(5); $result = $pounds1->decreaseBy($pounds2); $this->assertInstanceOf(Pounds::class, $result); $this->assertSame(5.0, $result->asFloat()); } }
  30. $pounds1 = new Pounds(5); $pounds2 = new Pounds(10); $result =

    $pounds1->decreaseBy($pounds2); class Pounds { public function decreaseBy(Pounds $amount): self { return new self($this->weight - $amount->weight); } }
  31. if(!is_numeric($a)) { throw new \Exception(…) } if(!is_numeric($b)) { throw new

    \Exception(…) } if($a < $b) { throw new \Exception(…) } $pounds = $a - $b;
  32. use PHPUnit\Framework\TestCase; class PoundsTest extends TestCase { public function testCantReduceBelowZero()

    { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('The weight in pounds must be greater $pounds1 = new Pounds(5); $pounds2 = new Pounds(10); $pounds1->decreaseBy($pounds2); } }
  33. class Pounds { public static function fromKilograms(Kilograms $kilos): Pounds {

    return new self($kilos->getWeight() * 2.20462); } } class Kilograms { public static function fromPounds(Pounds $lbs): Pounds { return new self($lbs->getWeight() * 0.453592); } }
  34. echo abs(-5); // Outputs: 5 echo abs(abs(-5)); // Outputs: 5

    echo abs(abs(abs(-5))); // Outputs: 5 Idempotent
  35. Value Object No Identity (Only Values) *Immutable Amount Paid Acidity

    Temperature Entity Identifiable Mutable Lifecycle Contains Value Objects Line Item Brewer Water Chemistry Aggregate Entity Responsible For Child Entities Transaction Boundary Invoice Recipe Brew Session
  36. Sugar + Yeast = Alcohol + CO 2 C H

    O ->2C H OH + 2CO 6 12 6 2 5 2
  37. class User { //☝ properties… public function __construct( Username $username,

    FullName $fullName, EmailAddress $emailAddress, PasswordHash $hash ) { $this->id = Uuid::uuid4()->toString(); $this->username = $username; $this->fullName = $fullName; $this->emailAddress = $emailAddress; $this->hash = $hash; $this->deleted = false; } } Value Objects Identity
  38. class User { //☝ properties… public function __construct( Username $username,

    FullName $fullName, EmailAddress $emailAddress, PasswordHash $hash ) { $this->id = Uuid::uuid4()->toString(); $this->username = $username; $this->fullName = $fullName; $this->emailAddress = $emailAddress; $this->hash = $hash; $this->deleted = false; } } Immutable
  39. public static function registerNewBrewer( Username $username, FullName $fullName, EmailAddress $emailAddress,

    PasswordHash $hash, DateTimeImmutable $now ): self { $user = self::create([ 'id' => (string) Uuid::uuid4(), 'username' => (string) $username, 'full_name' => $fullName->toArray(), 'email_address' => (string) $emailAddress, 'hash' => (string) $hash, ]); $dispatcher->dispatch(new NewUserRegistered($user->getId(), $now)); return $brewer;
  40. $book->setPrice(16.96); $amount = new Amount(16.96, new Currency(‘USD’); $price = new

    BookPrice($book, $amount); $price->change(new Amount(15.99, new Currency(‘USD’)); Mutable Immutable
  41. class IngredientAddedToRecipe implements Event { public function __construct( private RecipeId

    $recipeId, private IngredientId $ingredientId, private IngredientName $ingredientName, private IngredientType $ingredientType, private Pounds $weightInPounds, private DateTimeImmutable $occurredOn ) {}
  42. class Recipe { // … public function addIngredient( IngredientId $ingredientId,

    IngredientName $ingredientName, IngredientType $ingredientType, Pounds $weightInPounds ): void { $this->events[] = new IngredientAddedToRecipe( $this->recipeId, $ingredientId, $ingredientName, $ingredientType, $weightInPounds, new DateTimeImmutable() ); }
  43. <script src="immutable.min.js"></ script> <script> var map1 = Immutable.Map({ a: 1,

    b: 2, c: 3 }); var map2 = map1.set('b', 50); map1.get('b'); // 2 map2.get('b'); // 50 </script>
  44. abstract class ImmutableArray extends SplFixedArray { public function __construct(array $items)

    { parent::__construct(\count($items)); $i = 0; foreach ($items as $item) { $this->guardType($item); parent::offsetSet($i++, $item); } } abstract protected function guardType($item): void; }
  45. class FermentationGravityReadings extends ImmutableArray { public function __construct(array $readings) {

    parent::__construct($readings); $lastReading = GravityReading::MAX; /** @var GravityReading $reading */ foreach ($readings as $reading) { if ($reading->getValue() > $lastReading) { throw new \InvalidArgumentException; } $lastReading = $reading->getValue(); } } protected function guardType($item) { if (!($item instanceof GravityReading)) { throw new \InvalidArgumentException; } } } Value Object
  46. class FermentationGravityReadings extends ImmutableArray { public function __construct(array $readings) {

    parent::__construct($readings); $lastReading = GravityReading::MAX; /** @var GravityReading $reading */ foreach ($readings as $reading) { if ($reading->getValue() > $lastReading) { throw new \InvalidArgumentException; } $lastReading = $reading->getValue(); } } protected function guardType($item) { if (!($item instanceof GravityReading)) { throw new \InvalidArgumentException; } } } Custom Exception
  47. class FermentationGravityReadings extends ImmutableArray { public function __construct(array $readings) {

    parent::__construct($readings); $lastReading = GravityReading::MAX; /** @var GravityReading $reading */ foreach ($readings as $reading) { if ($reading->getValue() > $lastReading) { throw new \InvalidGravityReadingException; } $lastReading = $reading->getValue(); } } protected function guardType($item) { if (!($item instanceof GravityReading)) { throw new \InvalidArgumentException; } } }
  48. class FermentationGravityReadings extends ImmutableArray { public function calculateAbv(): AlcoholByVolume {

    return new AlcoholByVolume( new GravityRange( $this->first(), $this->last() ) ); } }
  49. class Grain { public function __construct( GrainName $name, GrainType $grainType,

    Lovibond $lovibond, DegreesLintner $lintner ) { $this->id = GrainId::newId(); $this->name = $name; $this->grainType = $grainType; $this->lovibond = $lovibond; $this->lintner = $lintner; } }
  50. class GrainBill { private Collection $grains; public function __construct(Collection $grains)

    { $this->grains = $grains; } public function add(MeasuredGrain $grain): void { $this->grains->add($grain); } public function getTotalWeightOfIngredients(): Pounds { $totalWeight = new Pounds(0); foreach ($this->grains as $grain) { $totalWeight = $totalWeight->increaseBy($grain->weightInPounds());
  51. public function add(MeasuredGrain $grain): void { $this->grains->add($grain); } public function

    getTotalWeightOfIngredients(): Pounds { $totalWeight = new Pounds(0); foreach ($this->grains as $grain) { $totalWeight = $totalWeight->increaseBy($grain->weightInPounds()); } return $totalWeight; }
  52. class GrainBill { public function doesConvert(): bool { $lintnerPounds =

    0; $totalWeight = new Pounds(0); foreach($this->grains as $grain) { $degreesLintner = $grain->getDegreesLintner(); $weight = $grain->getWeightInPounds(); $lintnerPounds += $degreesLintner->asFloat() * $weight->asFloat(); $totalWeight = $totalWeight->add($weight); } $average = new DegreesLintner($lintnerPounds / $totalWeight->asFloat()); return $average->doesConvert(); } }
  53. { "recipe": { "name": "Needle Haystack", "style": "Weissbier", "recipeAuthor": "Andy

    Cassell", "batchSize": 5.0, "boilSize": 6.5, "boilTime": 60, "og": 1.050, "fg": 1.015, "abv": 6.0, "ibu": 10, "color": 12, "fermentables": [ { "name": "American Pilsner", "amount": 4.5, "unit": "lbs" }, { "name": "American Wheat", "amount": 6.5,
  54. "color": 12, "fermentables": [ { "name": "American Pilsner", "amount": 4.5,

    "unit": "lbs" }, { "name": "American Wheat", "amount": 6.5, "unit": "lbs" }, { "name": "American Munich 10L", "amount": 0.5, "unit": "lbs" } ], "hops": [ { "name": "Tettnang", "amount": 1.0, "unit": "oz",
  55. ], "hops": [ { "name": "Tettnang", "amount": 1.0, "unit": "oz",

    "use": "Boil", "time": 60 }, { "name": "Hallertau", "amount": 0.5, "unit": "oz", "use": "Boil", "time": 30 }, { "name": "Tettnang", "amount": 0.5, "unit": "oz", "use": "Flameout" } ]
  56. package main import ( "encoding/json" "fmt" "io/ioutil" "os" ) type

    Recipe struct { Name string `json:"name"` Style string `json:"style"` RecipeAuthor string `json:"recipeAuthor"` BatchSize float64 `json:"batchSize"` BoilSize float64 `json:"boilSize"`
  57. type Recipe struct { Name string `json:"name"` Style string `json:"style"`

    RecipeAuthor string `json:"recipeAuthor"` BatchSize float64 `json:"batchSize"` BoilSize float64 `json:"boilSize"` BoilTime int `json:"boilTime"` OG float64 `json:"og"` FG float64 `json:"fg"` ABV float64 `json:"abv"` IBU int `json:"ibu"` Color int `json:"color"` Fermentables []Fermentables `json:"fermentables"` Hops []Hops `json:"hops"` }
  58. } type Fermentables struct { Name string `json:"name"` Amount float64

    `json:"amount"` Unit string `json:"unit"` } type Hops struct { Name string `json:"name"` Amount float64 `json:"amount"` Unit string `json:"unit"` Use string `json:"use"` Time int `json:"time"` }
  59. class Recipe { // ... existing class definitions ... public

    static function fromJson(string $json): Recipe { $data = json_decode($json, true); $fermentables = array_map(function($item) { return new Fermentable($item['name'], $item['amount'], $item['unit']); }, $data['fermentables']); $hops = array_map(function($item) { return new Hop($item['name'], $item['amount'], $item['unit'], $item['use'], $item['time']); }, $data['hops']); return new Recipe( new RecipeName($data[‘name’]), $fermentables, $hops ); } }
  60. $recipe = new Recipe($history + //…); $recipe->addThisIngredient($history + //…); $recipe->addWater($history

    + //…); $recipe->addThatIngredient($history + //…); $recipe->boil($history + //…); $recipe->getEstimatedABV($history);
  61. Y-Combinator function Y($F) { return function ($n) use ($F) {

    $g = function ($h) { return function ($x) use ($h) { return $h($h)($x); }; }; return $F($g($g))($n); }; }
  62. $brewingProcess = new BrewingProcess([ 'duration' => 15, 'temp' => 115,

    'next' => [ 'duration' => 25, 'temp' => 145, 'next' => [ 'duration' => 15, 'temp' => 158, 'next' => [ 'duration' => 5, 'temp' => 172, 'next' => null, ] ] ] ]);
  63. class BrewingProcess { public function __construct(private readonly array $mashingSteps) {

    } public function calculateTotalTime() { $recursiveTimeCalculation = $Y(function ($f) { return function ($step) use ($f) { if ($step === null) { return 0; } $totalTime = $step['duration']; if (isset($step['next'])) { $totalTime += $f($step['next']); } return $totalTime; }; }); $totalTime = 0; foreach ($this->mashingSteps as $step) { $totalTime += $recursiveTimeCalculation($step); } return $totalTime; } }
  64. ---- MODULE Brewing ---- EXTENDS Naturals, Sequences CONSTANT Steps ASSUME

    Steps = << "Milling", "Mashing", "Boiling", "Fermenting", "Conditioning", "Packaging", "Drinking" >> VARIABLES process, state (* Define the initial state *) Init == /\ process = Steps[1] /\ state = "Brewing" (* Define the transition from one state to another *)
  65. ---- MODULE Brewing ---- EXTENDS Naturals, Sequences CONSTANT Steps ASSUME

    Steps = << "Milling", "Mashing", "Boiling", "Fermenting", "Conditioning", "Packaging", "Drinking" >> VARIABLES process, state (* Define the initial state *) Init == /\ process = Steps[1] /\ state = "Brewing" (* Define the transition from one state to another *)
  66. (* Define the transition from one state to another *)

    Next == IF process = "Drinking" THEN /\ process' = "Milling" /\ state' = "Drank" ELSE /\ process' = Steps[NextIndex(Steps, process)] /\ state' = "Brewing" (* Define the behavior of the system *) Spec == Init /\ [][Next]_<<process, state>> ====
  67. (* Define the behavior of the system *) Spec ==

    Init /\ [][Next]_<<process, state>> ==== NextIndex(S, elem) == LET i == CHOOSE n \in DOMAIN S: S[n] = elem IN IF i = Len(S) THEN 1 ELSE i + 1