Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Best practices to work with relations in Api Platform 3 and DDD #31

Open
tomasattifabio opened this issue Oct 19, 2022 · 7 comments
Open

Comments

@tomasattifabio
Copy link

I have two entities in a one-to-many relation.
To refer to your example of the BookStore imagine that a Book has a Category in a one-to-many relation. A Category is in relation with many Book entities.
I have created a sort of "copy" of the book store for the folders and files structure for both my two entities, with create commands, other commands, item and collection provider, repositories, resources, etc etc. Same as the book store.
My problem is in the creation of a Book by BookResource and the various commands and command handlers when I have to save the related Category at the same time. Let me explain with the code.

The BookResource has the POST operation defined like this:

new Post(
    validationContext: ['groups' => ['create']],
    processor: CreateBookProcessor::class,
),

Your BookResource class is the seguent:

final class BookResource
{
    public function __construct(
        #[ApiProperty(identifier: true, readable: false, writable: false)]
        public ?AbstractUid $id = null,

        #[Assert\NotNull(groups: ['create'])]
        #[Assert\Length(min: 1, max: 255, groups: ['create', 'Default'])]
        public ?string $name = null,

        #[Assert\NotNull(groups: ['create'])]
        #[Assert\Length(min: 1, max: 1023, groups: ['create', 'Default'])]
        public ?string $description = null,

        #[Assert\NotNull(groups: ['create'])]
        #[Assert\Length(min: 1, max: 255, groups: ['create', 'Default'])]
        public ?string $author = null,

        #[Assert\NotNull(groups: ['create'])]
        #[Assert\Length(min: 1, max: 65535, groups: ['create', 'Default'])]
        public ?string $content = null,

        #[Assert\NotNull(groups: ['create'])]
        #[Assert\PositiveOrZero(groups: ['create', 'Default'])]
        public ?int $price = null,
    ) {
    }

    public static function fromModel(Book $book): static
    {
        return new self(
            $book->id->value,
            $book->name->value,
            $book->description->value,
            $book->author->value,
            $book->content->value,
            $book->price->amount,
        );
    }
}

Now, how can I define a new property to associate the category to the book? Which is the type that I must assign to the category property?
My question is about what I see in the swagger and in which way I define and use the property in the command and command handler.
If I define category only as the category id, for example a string for uuid, the swagger show me a thing like this in the request body:

"category": "string",

If I accept the category as a string, for example the uuid, then I receive the uuid in the $data in your CreateBookProcessor.

final class CreateBookProcessor implements ProcessorInterface
{
    public function __construct(
        private CommandBusInterface $commandBus,
    ) {
    }

    /**
     * @param mixed $data
     *
     * @return BookResource
     */
    public function process($data, Operation $operation, array $uriVariables = [], array $context = []): mixed
    {
        Assert::isInstanceOf($data, BookResource::class);

        Assert::notNull($data->name);
        Assert::notNull($data->description);
        Assert::notNull($data->author);
        Assert::notNull($data->content);
        Assert::notNull($data->price);
        Assert::notNull($data->category); // The category received

        $command = new CreateBookCommand(
            new BookName($data->name),
            new BookDescription($data->description),
            new Author($data->author),
            new BookContent($data->content),
            new Price($data->price),
        );

        /** @var Book $model */
        $model = $this->commandBus->dispatch($command);

        return BookResource::fromModel($model);
    }
}

The processor make a new CreateBookCommand to dispatch to the command bus. The command is defined like this.

final class CreateBookCommand implements CommandInterface
{
    public function __construct(
        public readonly BookName $name,
        public readonly BookDescription $description,
        public readonly Author $author,
        public readonly BookContent $content,
        public readonly Price $price,
    ) {
    }
}

The CreateBookCommandHandler is defined like this:

final class CreateBookCommandHandler implements CommandHandlerInterface
{
    public function __construct(private BookRepositoryInterface $bookRepository)
    {
    }

    public function __invoke(CreateBookCommand $command): Book
    {
        $book = new Book(
            $command->name,
            $command->description,
            $command->author,
            $command->content,
            $command->price,
        );

        $this->bookRepository->add($book);

        return $book;
    }
}

To use the category id received in the processor I think that I have to add in the create command a $category property of type CategoryId that is the category id value object like this:

final class CreateBookCommand implements CommandInterface
{
    public function __construct(
        public readonly BookName $name,
        public readonly BookDescription $description,
        public readonly Author $author,
        public readonly BookContent $content,
        public readonly Price $price,
        public readonly CategoryId $category, // The category property
    ) {
    }
}

If I add the category like this, I have to initialize the command like that in the processor.

        $command = new CreateBookCommand(
            new BookName($data->name),
            new BookDescription($data->description),
            new Author($data->author),
            new BookContent($data->content),
            new Price($data->price),
            new CategoryId(Uuid::fromString($data->category)) // The category
        );

The command handler is now a big problem because I don't know in which way I can save the category to the new book because the Book entity has the Category public property and not a string like the uuid received in the handler.
My actual solution is to find the category with the provided id by the category reporitory and set the found object in the Book entity. The use the book repository to save the new book.

final class CreateBookCommandHandler implements CommandHandlerInterface
{
    public function __construct(private BookRepositoryInterface $bookRepository)
    {
    }

    public function __invoke(CreateBookCommand $command): Book
    {
        $book = new Book(
            $command->name,
            $command->description,
            $command->author,
            $command->content,
            $command->price,
        );

        $foundCategory = $this->courseRepository->ofId($command->categoryId);
        if (is_null($foundCategory)) {
            throw new NotFoundHttpException("Category not found by id " . $command->categoryId->value->toRfc4122());
        }
        
        $book->category = $foundCategory;

        $this->bookRepository->add($book);

        return $book;
    }
}

With this code I can save correctly the category to the book but with the category defined like this in all the classes I have my big problem when getting the data. Like the processor, the state provider uses the fromModel method of the BookResource. The method return a new self object and this is the problem because I defined the category as a string and in the swagger result I only can send a string and nothing else, like the Category object for example.

How can I accept a string in the category property in the book resource and send back the IRI of the associated Category when I retrieve a book item or books collection?

If I define the category property in the book resource as a Category object, and then in all the other classes, I see all the Category properties in the POST request body and I don't want to create a new category when I create a new Book. I only want to create the new book and associate the category to it! This last solution send in output the whole category in the book single item but I don't want this solution because it's not the IRI at the same way of the first solution!

If it's not clear ask me!

@tomasattifabio
Copy link
Author

Another question I asked myself is if I should use groups and normalization/denormalization contexts to post an identifier and get the resource of my relation. It's a littlebit confusing DDD as explained in the workshop and relate my problem with the actual documentation that don't speak about api platform and DDD.

@cedricvazille
Copy link

Bonjour tomasattifabio, je me retrouve actuellement avec les mêmes problématiques que vous.
Comment vous en êtes-vous sorti ? Est-ce que vous auriez un exemple plus avancé avec des relations OneToMany suivant le modèle de APIP-DDD ?
Merci beaucoup, très bonne journée

@Jarzebowsky
Copy link

Jarzebowsky commented Nov 13, 2023

Any update on it from anyone so maybe I will not be reinventing the wheel? I have a some relations between my entities ManyToMany, ManyToOne but they are not even registered properly by migrations (database schema) to even go further.

That's my second attempt into going with DDD as it's really gives what I want - isolation and single responsibility comparing to what we have OOTB approach.

@qualeo
Copy link

qualeo commented Nov 13, 2023

@Jarzebowsky Have you by chance watched Ryan Weaver's latest SymfonyCasts that is essentially a deep dive into the marriage of APIPv3 & DDD -- the core topic of this thread?

@tomasattifabio
Copy link
Author

@qualeo can you link the page please?

@Jarzebowsky
Copy link

@qualeo @cedricvazille Thank you for linking those. Perhaps that will solve my problem of isolation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants