phpdoctrinedomain-driven-designbounded-contexts

Doctrine2: Bounded Contexts of the Entity and SINGLE_TABLE inheritance mapping. Kinda confused on am I doing it the right way


Long story short.

I use Doctrine's Single Table Inheritance mapping to map three different contexts (classes) of the one common entity: NotActivatedCustomer, DeletedCustomer, and Customer. Also, there is an AbstractCustomer which contains the next:

App\Identity\Domain\Customer\AbstractCustomer:
  type: entity
  inheritanceType: SINGLE_TABLE
  discriminatorColumn:
    name: discr
    type: string
  discriminatorMap:
    Customer: App\Identity\Domain\Customer\Customer
    NotActivatedCustomer: App\Identity\Domain\Customer\NotActivatedCustomer
    DeletedCustomer: App\Identity\Domain\Customer\DeletedCustomer
  table: customer
  id:
    id:
      type: customer_id
      unique: true
      generator:
        strategy: CUSTOM
      customIdGenerator:
        class: Symfony\Bridge\Doctrine\IdGenerator\UuidV4Generator
  fields:
    email:
      type: email
      length: 180
      unique: true

A Subtype definition example:

<?php

declare(strict_types=1);

namespace App\Identity\Domain\Customer;

use App\Identity\Domain\User\Email;

class DeletedCustomer extends AbstractCustomer
{
    public const TYPE = 'DeletedCustomer';

    public function __construct(CustomerId $id)
    {
        $this->_setId($id);
        $this->_setEmail(new Email(sprintf('%s@mail.local', $id->value())));
    }
}

The Use Case:

<?php

declare(strict_types=1);

namespace App\Identity\Application\Customer\UseCase\DeleteCustomer;

use App\Identity\Application\Customer\CustomerEntityManager;
use App\Identity\Application\User\AuthenticatedCustomer;
use App\Identity\Domain\Customer\DeletedCustomer;
use App\Shared\Application\ImageManager;

final class DeleteCustomerHandler
{
    private CustomerEntityManager $customerEntityManager;
    private AuthenticatedCustomer $authenticatedCustomer;
    private ImageManager $imageManager;

    public function __construct(AuthenticatedCustomer $authenticatedCustomer,
                                CustomerEntityManager $customerEntityManagerByActiveTenant,
                                ImageManager $customerPhotoManager)
    {
        $this->customerEntityManager = $customerEntityManagerByActiveTenant;
        $this->authenticatedCustomer = $authenticatedCustomer;
        $this->imageManager = $customerPhotoManager;
    }

    public function handle(): void
    {
        $customer = $this->authenticatedCustomer->customer();

        $photo = (string) $customer->photo();

        $deletedCustomer = new DeletedCustomer($customer->id());

        // TODO OR return DeletedCustomer that way
        // $deletedCustomer = $customer->deactive();

        //  entityManager->merge() called here
        $this->customerEntityManager->sync($deletedCustomer);

        // simple entityManager->flush() under the hood
        $this->customerEntityManager->update();

        // that's a raw query to update discriminator field, hackish way I'm using
        // UPDATE customer SET discr = ? WHERE id = ?
        $this->customerEntityManager->updateInheritanceType($customer, DeletedCustomer::TYPE);

        if ($photo) {
            $this->imageManager->remove($photo);
        }
    }
}

So if you have already an existing Customer persisted and run DeleteCustomerHandler, the Customer will be updated, but its discriminator field won't! Googling that, there is no way to update the discriminator field not going some hackish way like I do (running raw query manually to update the field).

Also, I need to use the EntityManager->merge() method to add manually initialized DeletedCustomer to internal UnitOfWork. Looks a little bit dirty too, and it's a deprecated method for Doctrine 3, so the question also is there a better way to handle my case?

So, to conclude all the questions:

  1. Am I doing Customer's status change to DeletedCustomer completely wrong? I'm just trying to avoid Customer God Object, distinguish this Entity's bounded contexts, kinda that.
  2. How to avoid EntityManager->merge() there? AuthenticatedCustomer comes from session (JWT).

Solution

  • I think you're absolutely right to want to avoid Customer turning into a god object. Inheritance is one way to do it, but using it for customers in different statuses can lead to problems.

    The two key problems in my experience:

    1. As new statuses emerge, will you keep adding different inherited entities?
    2. What happens when you have a customer move through two different statuses, such as a customer that was a NotActivatedCustomer but is now a DeletedCustomer?

    So I keep inheritance only when the inherited type is genuinely more specific type, where a given entity will only ever be one of those types for its entire lifecycle. Cars don't become motorbikes, for example.

    I have two patterns for solving the problem differently to you. I tend to start with the first and move to the second.

    interface DeletedCustomer
    {
      public function getDeletedAt(): DateTime;
    }
    
    interface NotActivatedCustomer
    {
      public function getCreatedAt(): DateTime;
    }
    
    class Customer implements DeletedCustomer, NotActivatedCustomer
    {
      private $id;
      private $name;
      private DateTime $deletedAt;
      private bool $isActivated = false;
    
      public function getDeletedAt(): DateTime {...}
      public function getCreatedAt(): DateTime {...}
    }
    
    class DeletedCustomerRepository
    {
      public function findAll(): array
      {
        return $this->createQuery(<<<DQL
          SELECT customer 
          FROM   Customer 
          WHERE  customer.deletedAt IS NOT NULL
        >>>)->getQuery()->getResults();
      }
    }
    
    class NotActivatedCustomerRepository
    {
      public function findAll(): array
      {
        return $this->createQuery(<<<DQL
          SELECT customer 
          FROM   Customer 
          WHERE  customer.isActivated = false
        >>>)->getQuery()->getResults();
      }
    }
    
    class DeletedCustomerService
    {
      public function doTheThing(DeletedCustomer $customer) {}
    }
    
    

    This reduces coupling, which is one of the main problems with god objects. So when the columns start to proliferate, I can move them off to real entities that join to the Customer. Components that refer to DeletedCustomer will still receive one.

    The second pattern is event-sourcing-lite - have a many-to-one relationship with a "CustomerLifecycleEvent" entity. Query based on whether the customer has a "deleted" event. This second approach is much more complex, both to update and query. You can still have dedicated repositories that return entities like DeletedCustomer, but you'll need to do a bit more boilerplate.