Skip to content

Allow implicit creation of Aggregates or defintition of custom repositories #769

@fritz-gerneth

Description

@fritz-gerneth

We sometimes have Aggregates that do not have an explicit create event, they just come into existence by whatever event is recorded first (often this is some ID managed outside the context of that particular Aggregate, e.g. some external IDs like users coming from external IDPs).

Right now we have to create a custom repository with a load function somewhat like this:

    public function load(Uuid $aggregateId): MyAggregate
    {
        return $this->repository->has($aggregateId)
            ? $this->repository->load($aggregateId)
            : MyAggregate::create($aggregateId);
    }

As we cannot define our own repository (easily) to be used by the DefaultRepositoryMananger, we loose the benefits of defining #[Handler] directly in the aggregate and all the loading/saving coming with it. Instead we have to go manual and create custom handler classes for all commands of this aggregate (using Symfony):

    #[AsMessageHandler]
    public function myCommandHanlder(MyCommand $command): void
    {
        $aggregate = $this->repository->load($command->id);
        $aggregate->myCommand($command);
        $this->repository->save($aggregate);
    }

As I see it, this could be solved by two means:

  • Allow extension of the default repository class, and extend the Aggregate attribute to allow specification of the repository class to use. This is what Doctrine does with their entity repositories: #[Entity(repositoryClass: MyRepository::class) and would tie nicely with the repository manager. E.g. $repositoryManager->get(MyAggregate::class); would return the custom implementation specified in the attribute. Note that doctrine bundle also just uses a proxy for the actual implementation for convenience of passing arguments.
  • The alternative would be to add an optional argument allow_implicit_create argument to the Aggregate attribute. The DefaultRepository::load method already knows about the Id property, so it could instantiate a new aggregate instance and set the ID to return an empty aggregate root.

Personally I prefer the first option - it leaves more room for extension than just pilling flags on the Aggregate that inherently should affect the repository, not the aggregate. Using the SymfonyBundle, this currently would entail to overwrite the DefaultRepositoryManager.

I guess this would mean to:

  • extend the AggregateRootMetadata with an additional property for the repository class
  • extend the DefaultRepositoryManager to check for the property instead of instantiating the default repository
  • find some pattern to hide away all the extra parameters the DefaultRepository does need

This is the current implementation we use by decorating the RepositoryManager (simply using a static array of aggregates to handle different):

#[AsDecorator(decorates: DefaultRepositoryManager::class)]
class ImplicitCreateRepositoryManager implements RepositoryManager
{
    private static array $implicitlyCreatedAggregates = [
        FeatureFlagOverwrite::class => true,
    ];

    public function __construct(
        private readonly RepositoryManager $repositoryManager,
        private readonly AggregateRootMetadataFactory $metadataFactory,
    ) {
    }

    public function get(string $aggregateClass): Repository
    {
        $repository = $this->repositoryManager->get($aggregateClass);

        return array_key_exists($aggregateClass, self::$implicitlyCreatedAggregates)
            ? new ImplicitCreateRepository($repository, $this->metadataFactory->metadata($aggregateClass))
            : $repository;
    }
}

class ImplicitCreateRepository implements Repository
{
    public function __construct(
        private readonly Repository $repository,
        private readonly AggregateRootMetadata $metadata,
    ) {
    }

    public function load(AggregateRootId $id): AggregateRoot
    {
        if ($this->repository->has($id)) {
            return $this->repository->load($id);
        }

        $reflectionClass = new ReflectionClass($this->metadata->className);
        
        $aggregate = $reflectionClass->newInstanceWithoutConstructor();
        $reflectionClass
            ->getProperty($this->metadata->idProperty)
            ->setValue($aggregate, $id);

        return $aggregate;
    }

    public function has(AggregateRootId $id): bool
    {
        return $this->repository->has($id);
    }

    public function save(AggregateRoot $aggregate): void
    {
        $this->repository->save($aggregate);
    }
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions