salletti / symfony-ddd-example

MIT License
161 stars 25 forks source link

DDD, Hexagonal Architecture & CQRS with Symfony and Doctrine

Example of a Symfony application using Domain-Driven Design (DDD) and Command Query Responsibility Segregation (CQRS) principles keeping the code as simple as possible.

Environment Setup

Needed tools

  1. Install Docker
  2. Clone this project: git clone https://github.com/salletti/symfony-ddd-example.git
  3. Move to the project folder: cd symfony-ddd-example

Application execution

  1. If not already done, install Docker Compose
  2. Run docker-compose build --pull --no-cache to build fresh images
  3. Run docker-compose up (the logs will be displayed in the current shell)
  4. Open https://localhost in your favorite web browser and accept the auto-generated TLS certificate
  5. Run docker-compose down --remove-orphans to stop the Docker containers.

Project explanation

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.

Structure

$ 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

Hexagonal Architecture

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;
}

CQRS

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));

Repository pattern

Our repositories try to be as simple as possible usually only containing 2 methods save and find.

Bounded Contexts

There are three different Bounded Contexts:

Aggregates

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 CommentEntity 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.

Communication between Bounded Contexts

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 OnPublicationRequestedEventis dispatched and listened in the user BC which check if the user exists. The UserVerifiedEventevent is then dispatched and listened in the category BC which retrieves the category id by the slug. A new OnPublicationApprovedEventis then dispatched and listened in the Post BC. Now the creation of the article can be done.

How to use

1)

$ docker-compose exec php sh
2) Create user:
$ bin/concole app:create-user s.alletti@gmail.com p4$$word ROLE_EDITOR
3) 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*

About Me

Resources