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:
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:
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.