Example of a Symfony application using Domain-Driven Design (DDD) and Command Query Responsibility Segregation (CQRS) principles keeping the code as simple as possible.
git clone https://github.com/salletti/symfony-ddd-example.git
cd symfony-ddd-example
docker-compose build --pull --no-cache
to build fresh imagesdocker-compose up
(the logs will be displayed in the current shell)https://localhost
in your favorite web browser and accept the auto-generated TLS certificatedocker-compose down --remove-orphans
to stop the Docker containers.A very simple application (absolutely not complete) to manage a blog. The goal is to show how to initialize a project with a DDD structure and how to make the different Bounded Contexts communicate.
$ tree -L 4 src
src
|-- Blog // Company subdomain / Bounded Context: Features related to one of the company business lines / products
| `-- Post // Some Module inside the Mooc context
| |-- Application
| | |-- Controller // Inside the application layer all is structured by actions
| | | |-- Api
| | | | |-- GetArticlesController
| | | | |-- PostArticleController.php
| | | | |-- PostCommentController.php
| | |-- Event
| | |-- EventSubscriber
| | |-- Model // The Data transformer objects for CQRS
| | | |-- CreateArticleCommand.php
| | | |-- CreateCommandCommand.php
| | | |-- FindArticleQuery.php
| | |-- ParamConverter
| | |-- Service (the applications layer services)
| |-- Domain
| | |-- Entity (The entities and the value objects)
| | | |-- Article.php // The Aggregate Root of the Bounded Context
| | | |-- ArticleId.php // Value Object
| | | |-- AuthorId.php // Value Object
| | | |-- Comment.php // Entity that depends from Aggregate Root
| | | |-- CommentId.php // Value Object
| | | |-- Email.php // Value Object
| | |-- Event // Domain Events
| | | |-- ArticleCreatedEvent.php
| | | |-- CommentCreatedEvent.php
| | |-- Repository
| | | |-- ArticleRepositoryInterface.php
| | | |-- CommentRepositoryInterface.php
| `-- Infrastructure // The infrastructure layer
| |-- DoctrineMapping
| | |-- Article.orm.xml
| | |-- Comment.orm.xml
| | |-- Email.orm.xml
| `-- Repository (the concrete repositories)
| `--ArticleRepository.php // An implementation of the repository
| `--CommentRepository.php // An implementation of the repository
This projects follows the Hexagonal Architecture pattern.
The application layer can only use domain implementations and the infrastructure layer must implement domain interfaces in order to be completely independent.
For example the repository which is closely related to Doctrine and which is located in the infrastructure layer implements a domain interface.
//App\Blog\Post\Domain\Repository\ArticleRepositoryInterface interface ArticleRepositoryInterface { public function find($id, $lockMode = null, $lockVersion = null); public function save(Article $article): void; }
Inside the Application / Model directory of the Bounded Contexts there are the "Command" or "Query" depending on the action to be performed. These are dispatched via the Symfony message bus which will forward them to the associated service handler.
Example of Command:
//App\Blog\Post\Application\EventSubscriber\OnPublicationApprovedEventSubscriber $createArticleCommand = new CreateArticleCommand(); $createArticleCommand->setTitle($event->getTitle()); $createArticleCommand->setBody($event->getBody()); $createArticleCommand->setAuthor($event->getAuthor()); $createArticleCommand->setCategory($event->getCategoryId()); $this->messageBus->dispatch($createArticleCommand);
Example of Query
//App\Blog\Post\Application\Controller\Api\GetArticleController $article = $this->handle(new FindArticleQuery($id));
Our repositories try to be as simple as possible usually only containing 2 methods save
and find
.
There are three different Bounded Contexts:
Each BC has one and only one Aggregate Root.
An Aggregate is a cluster of associated object that we treat as a single unit. Each Aggregate has a single root Entity and a boundary that marks what is inside and what is outside the Aggregate.
A Comment
object only really make sense in the context of a Article
because we canβt have a Comment
without a Article
.
The root Entity is the only Entity that is globally accessible in the application. In the example from above, Article
would be the root Entity.
Inside the boundary we would have all the associated objects of this Aggregate. For example, we would have the Comment
Entity as well as any other Entities or Value Objects that make up the Aggregate.
The Comment
object can hold references to each other internally to the Aggregate, but no other external object can hold a reference to any object internally to the Aggregate that is not the root Entity.
The only way to access the Comment
Entity is to retrieve the Article root Entity and traverse itβs associated objects.
The BCs must be independent, this means that they must not know anything about the other BCs. For this reason, communication between BCs should ideally be done with an event-driven system.
In our example, when we want to create an article, the payload contains the category slug and the user id. But how do we get the category id and make sure the user id is correct? This data is managed in other bounded contexts.
Below is the sequence diagram showing the steps to publish an article.
An OnPublicationRequestedEvent
is dispatched and listened in the user BC which check if the user exists.
The UserVerifiedEvent
event is then dispatched and listened in the category BC which retrieves the category id by the slug.
A new OnPublicationApprovedEvent
is then dispatched and listened in the Post BC.
Now the creation of the article can be done.
1)
$ docker-compose exec php sh2) Create user:
$ bin/concole app:create-user s.alletti@gmail.com p4$$word ROLE_EDITOR3) Create category:
POST https://localhost/api/categories/
{ "name": "Sport", "slug": "sport" }
4) Create Article:
POST https://localhost/api/articles/
<pre>
{
"title": "article",
"body": "body",
"author": *author_id*,
"categorySlug": "sport"
}</pre>
5) Create Comment:
POST https://localhost/api/comments/
{ "article_id": *author_id*, "email": "s.alletti@gmail.com", "message": "test message" }
6) Get Article:
GET https://localhost/api/articles/*author_id*