php-ddd icon indicating copy to clipboard operation
php-ddd copied to clipboard

Hiding technical implementation for uploading Files with Metadata using MongoDB GridFS

Open webdevilopers opened this issue 5 years ago • 3 comments

Code examples:

  • https://gist.github.com/webdevilopers/160409917c942e0ba83ba948250aec18

Doctrine MongoDB offers a Default GridFS Repository to upload files and map them to a MongoDB Document. This document has default fields e.g. "uploadDate" that get populated when the document was successfully added to the bucket / collections. In addition custom data can be stored to a Metadata document.

In our Domain Models we do not want to care about MongoDBs way of storing their native ObjectIds in a "_id" field. That's why we try to hide this "Surrogate Key" by extending the SurrogateIdEntity. We then map the auto-generated MongoDB ObjectId to a column named surrogateId`.

Internally we generated our own UUIDs. Since we want this as our primary key we map the id() method to the UUID mapped to "metadata" since this is the only place we can put it.

These are the first compromises between integrating the infrastructure and keeping the domain "clean".

Next: Object creation

When uploading the file to GridFS it directly adds it to the bucket AND returns a proxy of the created object. There is no way to create the object and then store it separately.

In order to offer an API without caring about the GridFS implementation we added a factory for the InvoiceDocument.

The factory / domain service takes the required arguments (should be value objects BTW). Then it passes it to the repository implementation. There is an interface in the domain layer that defines the required method.

This method then uploads the file and returns it to the factory. Done.

What we don't like about this approach:

  • The repository method does two things: saving the object to the bucket AND returning it
  • The save method is returning something at all

We also thought about moving the repository method to the factory. But then it would mix up persistence implementation and object creation.

Are there better approaches? Are these acceptable comprimises due to infrastructure choices?

Thank you for your feedback!

webdevilopers avatar Jan 16 '20 16:01 webdevilopers

@webdevilopers I am wondering why you need the document to be returned by the repository, is it necessary.

If it is required by the handler, or a controller, the UploadInvoiceDocument could probably have a unique id (not related to infrastructure). From there the repos could just fetch this document using the InvoiceDocument::getId(): DocumentId.

The mongo mapping would need a new column identity for the id you created , and from there, the mongo id would become private to the infrastructure. I do the same thing with auto increment on the db side.

yvoyer avatar Jan 16 '20 17:01 yvoyer

Indeed the repository does not have to return the document. But the factory has to. So you recommend to make the repository method simply store the document but return void instead of the proxy. Then use the e.g. documentID inside my factory to query for the document and then return it.

final class MongoInvoiceDocumentRepository extends DefaultGridFSRepository implements InvoiceDocumentRepository
{
    public function save(string $source, string $filename, InvoiceDocumentMetadata $metadata): void
    {
        $uploadOptions = new UploadOptions();
        $uploadOptions->metadata = $metadata;

        $this->uploadFromFile($source, $filename, $uploadOptions);
    }
}

final class InvoiceDocumentFactory
{
    public function build(
        int $invoiceId, InvoiceDocumentId $documentId, $source, $filename, $contentType
    ): InvoiceDocument
    {
        $metadata = new InvoiceDocumentMetadata($documentId, $contentType, $invoiceId);

        $this->repository->save($source, $filename, $metadata);

        return $this->repository->ofId($documentId);
    }
}

webdevilopers avatar Jan 17 '20 09:01 webdevilopers

What I still dislike is the fact the creating the object also means "persisting" it. How about this?

final class UploadInvoiceDocumentHandler
{
    /** @var InvoiceDocumentRepository */
    private $repository;

    public function __construct(InvoiceDocumentRepository $repository)
    {
        $this->repository = $repository;
    }

    public function __invoke(UploadInvoiceDocument $command): void
    {
        $invoiceId = $command->invoiceId;

        /** @var UploadedFile $file */
        $file = $command->file();

        /** @var InvoiceDocument $document */
        $document = InvoiceDocument::withFile(
              $invoiceId,
              Uuid::uuid4(),
              $file->getPathname(),
              $file->getClientOriginalName(),
              $file->getClientMimeType()
        );
        
        $this->repository->save($document);
    }
}

final class MongoInvoiceDocumentRepository extends DefaultGridFSRepository implements InvoiceDocumentRepository
{
    public function save(InvoiceDocument $document): void 
    {
        $uploadOptions = new UploadOptions();
        $uploadOptions->metadata = $document->metadata();

        return $this->uploadFromFile($document->source(), $document->filename(), $uploadOptions);
    }
}

It requires some more "accessors" on the document for the repository to get source and filename.

webdevilopers avatar Jan 18 '20 08:01 webdevilopers