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

Techniques to Design Better Object Oriented So...

Techniques to Design Better Object Oriented Softwares

Designing softwares with an object oriented approach is hard... really hard! In fact, making good object oriented design (aka OOD) is very difficult for many developers as it goes far beyond basic concepts like classes, objects, inheritance and interfaces. This talk will provide tips and techniques to help you design better object oriented code. We'll cover topics like SOLID principles, composition vs inheritance, value objects, entities, etc.

Hugo Hamon

March 08, 2018
Tweet

More Decks by Hugo Hamon

Other Decks in Programming

Transcript

  1. Confoo 2018 / Mar. 8th / Montréal / Canada Hugo

    Hamon Designing Better Object Oriented Softwares
  2. Dependency Injection Dependency Injection is where components are given their

    dependencies through their constructors, methods, or directly into fields. Those components do not get their dependencies themselves, or instantiate them directly. — picocontainer.com/injection.html
  3. class ChessGameLoader { private $repository; private $cache; private $serializer; public

    function __construct() { $this->repository = new InMemoryChessGameRepository(); $this->cache = new RedisCache(); $this->serializer = new Serializer(); } public function load(UuidInterface $id): ?ChessGame { // … } }
  4. class ChessGameLoader { private $repository; private $cache; private $serializer; public

    function __construct( InMemoryChessGameRepository $repository, RedisCache $cache, Serializer $serializer ) { $this->repository = $repository; $this->cache = $cache; $this->serializer = $serializer; } }
  5. Pros • Code is unit testable • Dependencies can be

    mocked • Dependencies can be changed Cons • Client code is still tightly coupled • Client code doesn’t rely on abstractions
  6. Dependency Injection Container A dependency injection container is an object

    that enables to standardize and centralize the way objects are constructed and configured in an application. — symfony.com
  7. $loader = new ChessGameLoader( new InMemoryChessGameRepository(), new RedisCache(new Predis\Client('tcp://...')), new

    Serializer(new JsonDecoder()) ); $game = $loader->load(Uuid::fromString('1f809a73-...')); Complex Construction
  8. parameters: env(REDIS_DSN): 'tcp://10.0.0.1:6379' services: App\Serializer\Serializer: arguments: ['@App\Serializer\Decoder\JsonDecoder'] Predis\Client: arguments: ['%env(resolve:REDIS_DSN)%']

    App\Cache\RedisCache: arguments: ['@Predis\Client'] App\ChessGame\InMemoryChessGameRepository: ~ App\ChessGame\ChessGameLoader: arguments: - '@App\ChessGame\InMemoryChessGameRepository' - '@App\Cache\RedisCache' - '@App\Serializer\Serializer'
  9. Object Composition In computer science, object composition is a way

    to combine simple objects or data types into more complex ones. — wikipedia.com
  10. class ChessGameLoader { // ... public function __construct( InMemoryChessGameRepository $repository,

    RedisCache $cache, Serializer $serializer ) { // ... } public function load(UuidInterface $id): ?ChessGame { if ($this->cache->has($key = sprintf('game/%s', $id))) { return $this->serializer->deserialize( ChessGame::class, $this->cache->get($key) ); } return $this->repository->byId($id); } } RedisRepository
  11. class RedisChessGameRepository implements ChessGameRepository { public function byId(UuidInterface $id): ?ChessGame

    { $key = sprintf('game/%s', $id); if (!$this->cache->has($key)) { return null; } return $this->serializer->deserialize( ChessGame::class, $this->cache->get($key) ); } }
  12. class ChessGameLoader { // ... public function __construct( InMemoryChessGameRepository $inMemoryRepository,

    RedisChessGameRepository $redisRepository ) { // ... } public function load(UuidInterface $id): ?ChessGame { if ($game = $this->redisRepository->byId($id)) { return $game; } return $this->repository->byId($id); } }
  13. class ChainChessGameRepository implements ChessGameRepository { private $repositories = []; public

    function add(ChessGameRepository $repository): void { $this->repositories[] = $repository; } public function byId(UuidInterface $uuid): ?ChessGame { foreach ($this->repositories as $repository) { if ($game = $repository->byUuid($uuid)) { return $game; } } return null; } }
  14. class ChessGameLoader { // ... public function __construct( ChessGameRepository $repository,

    LoggerInterface $logger ) { // ... } public function load(UuidInterface $id): ?ChessGame { $this->logger->log(sprinf('Load game %s', $id)); return $this->repository->byId($id); } }
  15. $repository = new ChainChessGameRepository(); $repository->add(new RedisChessGameRepository(...)); $repository->add(new InMemoryChessGameRepository()); $loader =

    new ChessGameLoader( $repository, new NullLogger() ); $game = $loder->load(Uuid::fromString('1f809a73-...'));
  16. Single Responsibility A class should have one, and only one,

    reason to change. — Robert C. Martin
  17. class ChessGameRunner { // ... public function startNewGame(ChessGameContext $context): ChessGame

    { $game = new ChessGame( Uuid::uuid4(), $this->loadPlayer($context->getPlayerOne()), $this->loadPlayer($context->getPlayerTwo()) ); $this->gameRepository->save($game); return $game; } private function loadPlayer(string $player): Player { return Player::fromUserAccount($this->userRepository->byUsername($player)); } }
  18. class ChessGameRunner { public function startNewGame(ChessGameContext $context): ChessGame { $game

    = new ChessGame( Uuid::uuid4(), $this->loadPlayer($context->getPlayerOne()), $this->loadPlayer($context->getPlayerTwo()) ); $this->gameRepository->save($game); return $game; } } Persistence Object Construction
  19. class ChessGameFactory { private $userRepository; public function __construct(UserAccountRepository $repository) {

    $this->userRepository = $repository; } public function create(string $player1, string $player2): ChessGame { return new ChessGame( Uuid::uuid4(), Player::fromUserAccount($this->userRepository->byUsername($player1)), Player::fromUserAccount($this->userRepository->byUsername($player2)) ); } }
  20. class ChessGameRunner { private $gameRepository; private $gameFactory; public function __construct(

    ChessGameRepository $repository, ChessGameFactory $factory ) { $this->gameRepository = $repository; $this->gameFactory = $factory; } }
  21. class ChessGameRunner { // ... public function startNewGame(ChessGameContext $context): ChessGame

    { $game = $this->gameFactory->create( $context->getPlayerOne(), $context->getPlayerTwo() ); $this->gameRepository->save($game); return $game; } }
  22. Open Closed Principle You should be able to extend a

    classes behavior, without modifying it. — Robert C. Martin
  23. class ChessGameFactory { private $userRepository; public function __construct(UserAccountRepository $repository) {

    $this->userRepository = $repository; } public function create(string $player1, string $player2): ChessGame { return new ChessGame( Uuid::uuid4(), Player::fromUserAccount($this->userRepository->byUsername($player1)), Player::fromUserAccount($this->userRepository->byUsername($player2)) ); } }
  24. interface SerialGenerator { public function nextIdentity(): UuidInterface; } class FixedSerialGenerator

    implements SerialGenerator { public function nextIdentity(): UuidInterface { return new Uuid::fromString('1f809a73-63d5-40dd-9bc0-f7bc6813a4bc'); } } class DefaultSerialGenerator { public function nextIdentity(): UuidInterface { return new Uuid::uuid4(); } }
  25. class ChessGameFactory { private $userRepository; private $identityGenerator; public function __construct(

    UserAccountRepository $repository, SerialGenerator $identityGenerator ) { $this->userRepository = $repository; $this->identityGenerator = $identityGenerator; } public function create(string $player1, string $player2): ChessGame { return new ChessGame( $this->identityGenerator->nextIdentity(), $this->loadPlayer($player1), $this->loadPlayer($player2) ); } }
  26. class ChessGameRunner { // ... public function loadGame(UuidInterface $id): ChessGame

    { try { return $this->gameRepository->byId($id); } catch (ChessGameNotFound $e) { throw ChessGameUnavailable::gameNotFound($id, $e); } } }
  27. class InMemoryChessGameRepository implements ChessGameRepository { private $games = []; //

    ... public function byId(UuidInterface $uuid): ChessGame { $uuid = $uuid->toString(); if (!isset($this->games[$uuid])) { throw new ChessGameNotFound($uuid); } return $this->games[$uuid]; } }
  28. class DoctrineChessGameRepository implements ChessGameRepository { private $repository; public function __construct(ManagerRegistry

    $registry) { $this->repository = $registry->getRepository(ChessGame::class); } public function byId(UuidInterface $uuid): ChessGame { if (!$game = $this->repository->find($uuid->toString())) { throw new ChessGameNotFound($uuid->toString()); } return $game; } }
  29. $runner = new ChessGameRunner( new InMemoryChessGameRepository(), new ChessGameFactory(...) ); $runner

    = new ChessGameRunner( new DoctrineChessGameRepository(...), new ChessGameFactory(...) ); $runner->loadGame(Uuid::fromString('1f809a73-...'));
  30. interface ChessGameRepository { public function byId(UuidInterface $uuid): ChessGame; } interface

    UserAccountRepository { public function byUsername(string $username): User; } interface ChessGameFactory { public function create(string $player1, string $player2): ChessGame; }
  31. class ChessGameRunner { private $gameRepository; private $gameFactory; public function __construct(

    ChessGameRepository $repository, ChessGameFactory $factory ) { $this->gameRepository = $repository; $this->gameFactory = $factory; } } Interfaces
  32. Object Calisthenics Calisthenics are gymnastic exercises designed to develop physical

    health and vigor, usually performed with little or no special apparatus. — dictionary.com
  33. 1. One level of indentation per method 2.Don’t use the

    ELSE keyword 3.Wrap primitive types and strings 4.Two instance operators per line 5.Don’t abbreviate 6.Make short and focused classes 7. Keep number of instance properties low 8.Treat lists as custom collection objects 9.Avoid public accessors and mutators
  34. class ChessGame { /** @var ChessBoard */ private $board; public

    function makeMove( Pawn $pawn, int $originRow, int $originCol, int $targetRow, int $targetCol ): void { $this->ensureValidMove( $pawn, $originRow, $originCol, $targetRow, $targetCol ); $this->board->getSquare($targetRow, $targetCol)->add($pawn); } } Square
  35. class Square { private $row; private $col; public function __construct(int

    $row, int $col) { $range = range(1, 8); if (!in_array($row, $range, true)) { throw new \InvalidArgumentException('Invalid row.'); } if (!in_array($col, $range, true)) { throw new \InvalidArgumentException('Invalid col.'); } $this->row = $row; $this->col = $col; } // ... }
  36. class ChessGame { /** @var ChessBoard */ private $board; public

    function makeMove( Pawn $pawn, Square $origin, Square $target ) { $this->ensureValidMove($pawn, $origin, $target); $this ->board ->getSquare($target->row(), $target->col()) ->add($pawn); } } Move
  37. class Move { private $pawn; private $originSquare; private $targetSquare; public

    function __construct(Pawn $pawn, Square $from, Square $to) { $this->pawn = $pawn; $this->originSquare = $from; $this->targetSquare = $to; } // ... }
  38. class ChessGame { /** @var ChessBoard */ private $board; public

    function makeMove(Move $move): void { $this->ensureValidMove($move); $this ->board ->getSquare($move->getTargetSquare()) ->add($pawn); } }
  39. class ChessGame { private $finished = false; public function makeMove(Move

    $move): void { if (!$this->finished) { if ($this->isValidMove($move)) { $this->performMove($move); } } } } 0 1 2
  40. class ChessGame { private $finished = false; public function makeMove(Move

    $move): void { if ($this->finished) { throw new GameAlreadyFinished(); } if (!$this->isValidMove($move)) { throw new InvalidGameMove(); } $this->performMove($move); } } 0 1 0 1 0
  41. class ChessGame { private $finished = false; public function makeMove(Move

    $move): void { $this->ensureGameNotFinished(); $this->ensureValidMove($move); $this->performMove($move); } private function ensureGameNotFinished(): void { if ($this->finished) { throw new GameAlreadyFinished(); } } private function ensureValidMove(Move $move): void { if (!$this->isValidMove($move)) { throw new InvalidGameMove(); } } } 0 1 1
  42. class ChessGame { // ... private $finished = false; public

    function makeMove(Move $move): void { if (!$this->finished) { $this->ensureValidMove($move); $this->performMove($move); } else { throw new GameAlreadyFinished(); } } }
  43. class ChessGame { // ... private $finished = false; public

    function makeMove(Move $move): void { if ($this->finished) { return; } $this->ensureValidMove($move); $this->performMove($move); } }
  44. class ChessGame { // ... private $finished = false; public

    function makeMove(Move $move): void { if ($this->finished) { throw new GameAlreadyFinished(); } $this->ensureValidMove($move); $this->performMove($move); } }
  45. class ChessGame { // ... private $finished = false; public

    function makeMove(Move $move): void { $this->ensureGameNotFinished(); $this->ensureValidMove($move); $this->performMove($move); } }
  46. class ChessGame { /** @var ChessBoard */ private $board; public

    function makeMove(Move $move): void { $this->ensureValidMove($move); $this ->board ->getSquare($move->getTargetSquare()) ->add($pawn); } } 3
  47. class ChessGame { /** @var ChessBoard */ private $board; public

    function makeMove(Move $move): void { $this->ensureValidMove($move); $this->board->placePawnOnSquare( $move->getTargetSquare(), $move->getPawn() ); } } 2
  48. Assertions Library Assertions libraries provide useful set of assertions and

    guard methods for input validation in business model. They help reducing the number of code execution paths, thus reducing methods complexity by having a linear code.
  49. Assertion::alnum(mixed $value); Assertion::base64(string $value); Assertion::between(mixed $value, mixed $lowerLimit, mixed $upperLimit);

    Assertion::betweenLength(mixed $value, int $minLength, int $maxLength); Assertion::boolean(mixed $value); Assertion::choice(mixed $value, array $choices); Assertion::choicesNotEmpty(array $values, array $choices); Assertion::classExists(mixed $value); Assertion::contains(mixed $string, string $needle); Assertion::count(array|\Countable $countable, int $count); Assertion::date(string $value, string $format); Assertion::defined(mixed $constant); Assertion::digit(mixed $value); Assertion::directory(string $value); Assertion::e164(string $value); Assertion::email(mixed $value); Assertion::endsWith(mixed $string, string $needle); // ...
  50. class Square { public function __construct(int $row, int $col) {

    $range = range(1, 8); if (!in_array($row, $range, true)) { throw new \InvalidArgumentException('Invalid row.'); } if (!in_array($col, $range, true)) { throw new \InvalidArgumentException('Invalid col.'); } // ... } }
  51. class Square { private $row; private $col; public function __construct(int

    $row, int $col) { Assertion::between($row, 1, 8); Assertion::between($col, 1, 8); $this->row = $row; $this->col = $col; } // ... }
  52. Ubiquitous Language Ubiquitous Language is the term Eric Evans uses

    in Domain Driven Design for the practice of building up a common, rigorous language between developers and users. This language should be based on the Domain Model used in the software - hence the need for it to be rigorous, since software doesn't cope well with ambiguity. — Martin Fowler
  53. Domain Example •An invoice is issued with a unique number

    •The invoice is due within X days •The invoice is associated to a customer account •A payment is recorded for this invoice •The invoice is partially paid •Waiting for the remaining amount to be collected •The invoice is fully paid •The invoice is closed •The invoice is overpaid •Overdue amount must be refunded
  54. Value Objects A value object is an object representing an

    atomic value or concept. The value object is responsible for validating the consistency of its own state. It’s designed to always be in a valid, consistent and immutable state.
  55. Value Object Properties •They don’t have an identity •They’re responsible

    for validating their state •They are immutable by design •They are always valid by design •Equality is based on their fields •They are interchangeable without side effects
  56. final class Currency { private $code; public function __construct(string $code)

    { if (!in_array($code, ['EUR', 'USD', 'CAD'], true)) { throw new \InvalidArgumentException('Unsupported currency.'); } $this->code = $code; } public function equals(self $other): bool { return $this->code === $other->code; } }
  57. final class Money { private $amount; private $currency; public function

    __construct(int $amount, Currency $currency) { $this->amount = $amount; $this->currency = $currency; } // ... }
  58. final class Money { // ... public function add(self $other):

    self { $this->ensureSameCurrency($other->currency); return new self($this->amount + $other->amount, $this->currency); } private function ensureSameCurrency(Currency $other): void { if (!$this->currency->equals($other)) { throw new \RuntimeException('Currency mismatch'); } } }
  59. $a = new Money(100, new Currency('EUR')); // 1€ $b =

    new Money(500, new Currency('EUR')); // 5€ $c = $a->add($b); // 6€ $c->add(new Money(300, new Currency('USD'))); // Error
  60. Entities An entity is an in-memory representation of a business

    object. It’s uniquely identified and must always be in a valid state at every step of its lifecycle. Thus, its methods convey the ubiquitous language operations.
  61. class Invoice { private $number; private $customerId; private $issueDate; private

    $dueDate; private $dueAmount; private $remainingDueAmount; public function __construct( InvoiceId $number, CustomerId $customerId, Money $dueAmount, \DateTimeImmutable $dueDate ) { $this->number = $number; $this->customerId = $customerId; $this->issueDate = new \DateTimeImmutable('today', new \DateTimeZone('UTC')); $this->dueDate = $dueDate; $this->dueAmount = $dueAmount; $this->remainingDueAmount = clone $dueAmount; } }
  62. Issue an Invoice $invoice = new Invoice( new InvoiceId('INV-20180306-66'), new

    CustomerId('3429234'), new Money(9990, new Currency('EUR')), new \DateTimeImmutable('+30 days') );
  63. class Invoice { // ... private $overdueAmount; private $closingDate; private

    $payments = []; public function collectPayment(Payment $payment): void { $amount = $payment->getAmount(); $this->remainingDueAmount = $this->remainingDueAmount->subtract($amount); $this->overdueAmount = $this->remainingDueAmount->absolute(); $zero = new Money(0, $this->remainingDueAmount->getCurrency()); if ($this->remainingDueAmount->lessThanOrEqual($zero)) { $this->closingDate = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); } $this->payments[] = new CollectedPayment( $payment->getReceptionDate(), $amount, $payment->getSource() // wire, check, cash, etc. ); } }
  64. class Invoice { public function isClosed(): bool { return $this->closingDate

    instanceof \DateTimeImmutable; } public function isPaid(): bool { $zero = new Money(0, $this->remainingDueAmount->getCurrency()); return $this->remainingDueAmount->lessThanOrEqual($zero); } public function isOverpaid(): bool { $zero = new Money(0, $this->remainingDueAmount->getCurrency()); return $this->remainingDueAmount->lessThan($zero); } }
  65. Collecting Payments $invoice->collectPayment(new Payment( new \DateTimeImmutable('2018-03-04'), new Money(4900, new Currency('EUR')),

    new WireTransferPayment('450357035') )); $invoice->collectPayment(new Payment( new \DateTimeImmutable('2018-03-08'), new Money(5100, new Currency('EUR')), new WireTransferPayment('248748484') ));
  66. Named Constructor Static methods can be used as an alternative

    to the regular constructor to provide a simplified and semantic way to produce an object.
  67. $time = Time::fromString('12:30'); $time = Time::fromSecondsSinceMidnight(21600); // 06:00 $time =

    Time::fromMinutesSinceMidnight(510); // 08:30 $color = Color::fromRGB(0, 255, 0); $color = Color::fromHex('#00FF00'); $color = Color::black(); $color = Color::white(); $code = PinCode::generate(); $uuid = Uuid::fromString(‘14bc56cc-f23e-4338-a758-d616dc515ea3'); $uuid = Uuid::uuid4(); $temp = Temperature::fromCelsius(-273.15); $temp = Temperature::fromFahrenheit(-459.67); $temp = Temperature::fromKelvin(0);
  68. class Invoice { // ... public static function issue( string

    $number, string $customerId, Money $amount ): self { return new static( new InvoiceId($number), new CustomerId($customerId), $amount, new \DateTimeImmutable('+30 days'), ); } }
  69. Collection Objects Simple lists structures should be encapsulated into Collection

    objects to offer dedicated manipulation operations.
  70. class Invoice { // ... private $payments; public function __construct(…)

    { // ... $this->payments = new ArrayCollection(); } public function collectPayment(Payment $payment): void { // ... $this->payments->add(new CollectedPayment( $payment->getReceptionDate(), $amount, $payment->getSource() // wire, check, cash, etc. )); } }
  71. Filtering the collection class Invoice { // ... public function

    countPaymentsReceivedAfterDueDate(): int { return $this ->payments ->filter(function (CollectedPayment $payment) { return $payment->getReceptionDate() > $this->dueDate; }) ->count(); } }
  72. Custom Collection Type class CollectedPaymentCollection extends ArrayCollection { public function

    receivedAfter(\DateTimeImmutable $origin): self { $filter = function (CollectedPayment $payment) use ($origin) { return $payment->getReceptionDate() > $origin; }; return $this->filter($filter); } }
  73. class Invoice { // … public function __construct(…) { //

    ... $this->payments = new CollectedPaymentCollection(); } public function countPaymentsReceivedAfterDueDate(): int { return $this ->payments ->receivedAfter($this->dueDate) ->count(); } }
  74. The Service Layer Defines an application's boundary with a layer

    of services that establishes a set of available operations and coordinates the application's response in each operation. — Randy Stafford
  75. Services are global behavioral objects that encapsulate business logic. They

    take place between the controller and the domain layers as they manipulate one or several entities at the same time.
  76. class AccountingDepartment { // ... public function recordPayment(InvoiceId $invoiceId, Payment

    $payment): void { if (!$invoice = $this->repository->byId($invoiceId)) { throw InvoiceNotFound($invoiceId); } $invoice->collectPayment($payment); $this->repository->save($invoice); if ($invoice->isPaid()) { $this->dispatcher->dispatch('invoice.paid', new InvoiceWasPaid($invoice)); } } }
  77. class InvoiceController extends Controller { public function collectPayment( Request $request,

    AccountingDepartment $service ): Response { $invoiceId = new InvoiceId($request->get('invoiceId')); $form = $this->createForm(CollectPaymentType::class); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $service->recordPayment($invoiceId, $form->getData()); return $this->redirectToRoute('payment_recorded', ['invoiceId' => $invoiceId]); } return $this->render('invoice/payment.html.twig', [ 'form' => $form->createView(), ]); } }
  78. Data Transfer Objects An object that carries data between processes

    in order to reduce the number of method calls. — Martin Fowler
  79. class RecordPaymentCommand implements Command { private $invoiceId; private $paymentData; public

    function __construct(string $invoiceId, array $paymentData) { $this->invoiceId = $invoiceId; $this->paymentData = $paymentData; } public function getInvoiceId(): string { return $this->invoiceId; } public function getPaymentData(): array { return $this->paymentData; } }
  80. class RecordPaymentCommandHandler implements CommandHandler { private $repository; public function __construct(InvoiceRepository

    $repository) { $this->repository = $repository; } public function handle(RecordPaymentCommand $command): void { $invoice = $this->repository->byId(new InvoiceId($command->getInvoiceId())); $invoice->collectPayment(Payment::fromPayload($command->getPaymentData())); $this->repository->save($invoice); } }
  81. View Model Objects MVVM facilitates a separation of development of

    the graphical user interface from development of the business logic. The view model is a value converter, meaning the view model is responsible for exposing the data objects from the model in such a way that objects are easily managed and presented. — Wikipedia.com
  82. class InvoiceView { public $invoiceNumber; public $customerName; public $customerAddress; public

    $dueDate; public $dueAmount; public $paidAmount; public $remainingAmount; }
  83. class InvoiceViewBuilder { // ... public function buildView(Invoice $invoice, string

    $locale): InvoiceView { $customer = $this->customerRepository->byAccountId($invoice->getCustomerId()); $view = new InvoiceView(); $view->invoiceNumber = $invoice->getNumber()->toString(); $view->customerName = $customer->getName(); $view->customerAddress = $customer->getAddress()->toString(); $view->dueAmount = $this->formatMoney($invoice->getDueAmount(), $locale); $view->paidAmount = $this->formatMoney($invoice->getPaidAmount(), $locale); $view->remainingAmount = $this->formatMoney($invoice->getRemainingAmount(), $locale); return $view; } }
  84. class InvoiceController extends Controller { public function summary( Request $request,

    InvoiceViewBuilder $builder ): Response { $invoice = $this->repository->byId($request->get('invoiceId')); $this->denyAccessUnlessGranted('VIEW', $invoice); $view = $builder->buildView($invoice, $request->getLocale(); return $this->render('invoice/summary.html.twig', [ 'invoiceView' => $view), ]); } }
  85. <html> <body> <h1>{{ invoice.number }}</h1> <p> Total due: {{ invoice.dueAmount}}<br/>

    Total paid: {{ invoice.paidAmount }}<br/> Total remaining: {{ invoice.remainingAmount }} </p> </body> </html>