Domain-driven-design: Projections in practice with API Platform and Elasticsearch

Roland Franssen
May 6, 2018 · 3 min read

This is the 3rd post in a series about creating a Car domain layer in a Symfony application using API platform and Elasticsearch. Before continuing, make sure you read:

So far we have:

  • A Symfony application, Doctrine ORM, API Platform & Elasticsearch setup
  • A Car entity and CarRepositoryInterface
  • A CarProjection model and CarDocumentTransformer
  • Wired projection infrastructure from the MsgPHP base domain library

As a result we have our first API routes, e.g. GET /api/cars and
GET /api/cars/{id}. And because I’m cherry-picking the easiest use cases first, obviously the next step is adding DELETE /api/cars/{id}.

But first we’ll revise our projection infrastructure a bit …

Projections re-defined

In the previous post we’ve created config/packages/elasticsearch.yaml and manually wired all related services. I hesitated a lot if it was worth a dedicated bundle in an effort to simplify it, but eventually decided to stick with manually wiring (bundle-less).

Nevertheless, I managed to simplify it a lot still 😏

The new CarProjection model looks like:

Notable changes are:

  • Changed namespace from App\Car\Projection\ to App\Api\Projection\
  • Implemented a DocumentMappingProviderInterface tied to Elasticsearch
  • Configured the DELETE endpoint controller (we’ll create it later)

The CarDocumentTransformer remains the same, but is moved to the App\Api\Projection\DocumentTransformer\ namespace.

I removed the PSR implementation of DomainProjectionDocumentTransformerInterface in the MsgPHP library, we wired previously. We’ll now implement it directly at the application level:

What happens?

In an effort to implement DomainProjectionDocumentTransformerInterface we also implement ServiceSubscriberInterface to subscribe to all individual document transformer services. Remember those are just invokable objects, thus a callable type at the language level.

For convenience the object to transformer mapping is defined in a constant so we can create a somewhat dynamic implementation.

The new config/packages/elasticsearch.yaml looks like:

Compared to the previous configuration, this time we only wire the common Elasticsearch services.

We move all projection related services to a new config/packages/api_projections.yaml:

Notable changes:

  • Define all managed projections using a projection_types parameter
  • Bind each iterable|callable[] $dataProviders argument to a list of callables providing the source objects (i.e. entities)
  • Bind each iterable|DomainProjectionDocument[] $documentProvider argument to a built-in iterator provided by the MsgPHP library
  • Built-in CLI command names are renamed from domain:projection:... to projection:...
  • Wire the built-in API Platform data provider, which I seem to have forgotten in the previous post 😕

The DELETE endpoint

In the above @ApiResource() annotation on our CarProjection model we configured the DELETE endpoint to map to a controller called App\Api\Endpoint\DeleteCarEndpoint, let’s build it:

A somewhat regular single action controller, except that API Platform will manage the return value (raw data) and turn it into an API Response object. In this case, we use null to imply an empty response.

When invoked (i.e. the API endpoint is actually requested) we remove the car from the database as well as Elasticsearch to make sure the data is synchronized on-the-fly.

Of course it’s also possible to create a more generic DeleteProjectionEndpoint long term, or whatever suits your application.

We can simply do it in config/services.yaml along with our regular application controllers:

We’re done 😃

Try it in action by visiting /api in your application, or follow the MsgPHP demo application and see the whole approach applied in practice with the User domain.


Thanks to Gawain Lynch.

Roland Franssen

Written by

Symfony Developer & Contributor, living and working in the Netherlands 🌷 / Purist / Opinionated / Into UX & DX / @