# RICH Bundle for Symfony This bundle makes it easy to incorporate the RICH architecture into your Symfony application. RICH stands for Request, Input, Command, Handler, and its goal is to make backend web application development as easy, straightforward, and futureproof as possible. RICH applications apply the single responsibility principle to each action a user can take with your application which guarantees that it can mutate over time without unintended consequences. ## RICH philosophy The central philosophy behind a RICH application is to apply to backend engineering what Tailwind CSS did to frontend engineering without introducing the unnecessary complexity of hexagonal architecture, domain driven design, and CQRS. Tailwind decoupled CSS into atomic components that you can apply as classes to HTML elements. This essentially undoes the Cascading in Cascading Style Sheets, but for good reason: you can safely modify the style of one element without much fear that it will radically alter the layout of your application. This is incredibly powerful for teams of developers: the tenured team member knows that the CSS class `.btn-blue` renders a full width block level button with a red background, but the developer that started last week doesn't, and he accidentally destroyed the styling of the application with his first PR. RICH applies these sames principles to backend engineering: each input, command, and handler are separate PHP classes that only have a single responsibility. The naming of these classes should describe an action that can be performed in your application. For example, if your application allows users to be created and updated, you would have `CreateUserInput` and `UpdateUserInput` as your input classes, `CreateUserCommand` and `UpdateUserCommand` as your command classes, and you guessed it, `CreateUserHandler` and `UpdateUserHandler` as your handler classes. Like Tailwind, this may seem redundant and a source of code duplication, but the benefits an architecture like this provide far outweigh the negatives. ## RICH structure **Request** Everything starts with an action taken by an outside actor. The request is content and metadata associated with that action. Almost anything can generate a request: another system that has integrated with your REST API, a user submitting a form, a developer running a console command, or even another module from within your application. **Input** Once sent, the request content and metadata is mapped onto an input object and validated. By default, this bundle uses the Symfony serializer and validator components, but you're welcome to manually map data and validate it however you see fit. Input objects can contain some basic logic, but should generally rely on no additional dependencies outside of the standard PHP library. In this bundle, all input objects must implement the interface defined in `OneToMany\RichBundle\Contract\Action\InputInterface`. **Command** The input object creates the command object once the request is successfully mapped and validated. A command object is as simple of a class as you can get in PHP. Ideally, it should be `final`, `readonly`, and use constructor promotion to ensure immutability. A command object is a POPO - Plain Old PHP Object - and should do its best to use scalar primitives (`null`, `bool`, `int`, `float`, and `string`), basic arrays, standard PHP classes, or other value objects. In other words, a command object would use an `int` (or a simple value object) to refer to the primary key of a Doctrine entity rather than the entity itself. Command objects should be so simple they can easily be serialized and deserialized so they can used in an asynchronous message queue. In this bundle, all command objects must implement the interface defined in `OneToMany\RichBundle\Contract\Action\CommandInterface`. **Handler** Once created, the command object is passed to the handler. For the vast majority of applications, this can (and should) be done manually - using an asynchronous message queue is not necessary. A handler should hydrate the environment it needs without assuming it already exists. It should not be aware of an HTTP request, session data, cookie data, or that an entity it relies on is already being managed by Doctrine. The handler that runs synchronously today may need to be placed in a message queue tomorrow for a variety of reason and having the foresight to make it stateless today will save you endless headaches tomorrow. This is also why you want your handlers to rehydrate your entity map: an entity that existed when the command was pushed onto an asynchronous queue may not exist when the handler is executed. Each handler should contain the business logic necessary to handle the command passed to it. Ideally, handlers should be `final` and `readonly` as well to ensure they don't accidentally rely on any previous state to handle a command. ## Getting started Because this is a new bundle, you'll have to manually create the structure for each module in your application. My goal is to leverage the Symfony Maker Bundle to allow you to create the RICH structure for each action similar to how you would create a Doctrine entity. ### Install the bundle Install the bundle using Composer: ```shell composer require 1tomany/rich-bundle ``` ### Create the module structure Next, you'll need to create the directory structure for your first module. There is no strict definition on what a module is, other than a set of features that are loosely related to the same domain. It's easiest to think of a module as being related to each of your "primary" entities where a "primary" entity is one that can (mostly) exist without a parent entity. For example, `Invoice` would be a "primary" entity, but `InvoiceLine` would not be because an `InvoiceLine` can't exist without a parent `Invoice`. I recommend the following directory structure for each module: ``` src/ Module/ / Action/ Command/ Event/ Handler/ Exception/ Input/ Contract/ Enum/ Exception/ Repository/ Exception/ Framework/ Command/ Controller/ API/ Web/ ``` We'll get into the purpose of each of these soon. Use the following command to create this structure for a module named `Account` in your application: ```shell ./vendor/bin/create-rich-module Account ``` Moving forward, lets assume we're working on a module named `Account` for a Doctrine entity also named `Account` which uses a repository (shockingly) named `AccountRepository`. ### Create the module's contracts As the name implies, the `Contract` directory stores contracts to interact with this module. You should have, at minimum, two contracts to start with: a `RepositoryInterface` and `ExceptionInterface`. In the `Contract/Repository` directory, you'll find a file named `AccountRepositoryInterface.php` with the following scaffolding: ```php */ class AccountRepository extends ServiceEntityRepository implements AccountRepositoryInterface { public function __construct(ManagerRegistry $registry) { parent::__construct($registry, Account::class); } /** * @see App\Module\Account\Contract\Repository\AccountRepositoryInterface */ public function findOneById(?int $accountId): ?Account { return $accountId ? $this->find($accountId) : null; } } ``` We also need an interface that all exceptions from this module originate from. This makes it easy for any other module using handlers from this module to catch all thrown exceptions. You'll find a file named `ExceptionInterface.php` in the `src/Module/Account/Contract/Exception` directory with the following code: ```php */ final readonly class CreateAccountInput implements InputInterface { public function __construct( #[SourceUser] #[Assert\Email] #[Assert\Length(max: 128)] public ?UserInterface $user, #[SourceRequest] #[Assert\NotBlank] #[Assert\Length(min: 4, max: 128)] public ?string $name, #[SourceRequest] #[Assert\Length(min: 4, max: 48)] public ?string $company, #[SourceRequest] #[Assert\NotBlank] #[Assert\Email] #[Assert\Length(max: 128)] public ?string $email, #[SourceRequest(nullify: true)] #[Assert\Length(max: 1024)] public ?string $notes, #[SourceRequest(nullify: true)] #[Assert\Range( min: '1900-01-01', max: 'today', )] public ?\DateTimeImmutable $founded = null, #[SourceIpAddress(nullify: true)] #[Assert\Ip(version: 'all')] public ?string $ipAddress = null, ) { } /** * @see OneToMany\RichBundle\Contract\Action\CommandInterface */ public function toCommand(): CommandInterface { return new CreateAccountCommand($this->user?->getId(), (string) $this->name, (string) $this->company, (string) $this->email, $this->notes, $this->founded, $this->ipAddress); } } ``` While the input class is also fairly simple in nature, it accomplishes a lot. Classes that implement the `OneToMany\RichBundle\Contract\Action\InputInterface` interface should use the `@implements` annotation to indicate the type of command the `toCommand()` method creates. #### Property sources You'll also notice some new attributes: `#[SourceUser]`, `#[SourceRequest]`, and `#[SourceIpAddress]`. These allow you to indicate where in the request the data should come from. The `#[MapRequestPayload]` attribute that was announced in Symfony 6.3 is powerful, but limiting in that it assumes everything comes from the request content. There are 15 attributes provided by this bundle that allow you to specify the source of the data from the request. All source attributes extend from a common base attribute named `PropertySource` which has a constructor with the following arguments: - `?string $name = null` The name of the _key_ to find in the request data. The `InputParser` uses the property name if this argument is left `null`. - `bool $trim = true` If string values should be trimmed: `'Vic '` would become `'Vic'`. - `bool $nullify = false` If empty string values should be nullified: `''` would become `null`. Combining this with `$trim` being `true` would convert `' '` to `null`. - `?callback $callback = null` A callback to apply to the value before it is injected. Any valid arguments to `call_user_func()` are allowed here. The `Request` class below refers to the `Symfony\Component\HttpFoundation\Request` class. - `#[SourceAttributesBag]` Returns the results of `Request::$attributes->all()`. The `$name`, `$trim`, and `$nullify` arguments are ignored. - `#[SourceContainer(name: 'app.config_setting')]` Fetches a parameter named `app.config_setting` from the container bag. While the `$name` argument is not strictly required, unless the container property is named identically to the class property, you'll need to supply it. - `#[SourceContent]` Returns `Request::getContent()`. - `#[SourceFile(name: 'avatar')]` Fetches a file named `avatar` from `Request::$files`. The property should be type hinted with the `Symfony\Component\HttpFoundation\File\UploadedFile` class. The `$trim` and `$nullify` properties are ignored. - `#[SourceHeader(name: 'Content-Type')]` Fetches the `Content-Type` header from `Request::$headers`. The name is not case-sensitive, and underscores and dashes can be swapped: `CONTENT_TYPE` would result in the same value. - `#[SourceHeadersBag]` Returns the results of `Request::$headers->all()`. The `$name`, `$trim`, and `$nullify` arguments are ignored. - `#[SourceIpAddress]` Returns `Request::getClientIp()`. - `#[SourceQuery(name: 'id')]` Fetches a value named `id` from `Request::$query`. - `#[SourceQueryBag]` Returns the results of `Request::$query->all()`. The `$name`, `$trim`, and `$nullify` arguments are ignored. - `#[SourceRequest(name: 'user')]` Fetches a value named `user` from the request content. This bundle uses HTTP content negotiation via the `Content-Type` request header to attempt to determine the type of content submitted. A standard Symfony installation allows you to use `form`, `json`, and `xml` formats by default. - `#[SourceRequestBag]` Returns the results of `Request::$request->all()`. The `$name`, `$trim`, and `$nullify` arguments are ignored. - `#[SourceRoute(name: 'productId')]` Fetches a value named `productId` from the route parameters. - `#[SourceServer(name: 'REQUEST_URI')]` Fetches a value named `REQUEST_URI` from `Request::$server`. Unlike the `#[SourceHeader]` attribute, this attribute **is** case sensitive and underscores and hyphens are not interchangeable. - `#[SourceQueryBag]` Returns the results of `Request::$server->all()`. The `$name`, `$trim`, and `$nullify` arguments are ignored. - `#[SourceUser]` Fetches the authenticated user and returns `null` or an instance of the `Symfony\Component\Security\Core\User\UserInterface` interface. The `$name`, `$trim`, and `$nullify` arguments are ignored. - `#[PropertyIgnored]` Indicates that the value resolver should ignore this property. If a property is not explicitly ignored or sourced, the value resolver will assume it uses the `#[SourceRequest]` attribute. Most source attributes are also repeatable. This allows you to support multiple versions of an API without having to change the underlying input object. For example, the first version if your API might use a key named `email` but the second version of your API changed that to `username`. The attributes `#[SourceAttributesBag]`, `#[SourceHeadersBag]`, `#[SourceIpAddress]`, `#[SourceQueryBag]`, `#[SourceRequestBag]`, `#[SourceServerBag]`, and `#[SourceUser]` are not repeatable. In the example below, the `$username` property could be mapped from either of the following URLs: - `/api/v1/accounts?email=vic@1tomany.com` - `/api/v2/accounts?username=vic@1tomany.com` ```php */ final readonly class ReadAccountInput implements InputInterface { public function __construct( #[SourceQuery('email')] #[SourceQuery('username')] #[Assert\Email] #[Assert\NotBlank] #[Assert\Length(max: 128)] public string $username, ) { } /** * @see OneToMany\RichBundle\Contract\Action\CommandInterface */ public function toCommand(): CommandInterface { return new ReadAccountCommand($this->username); } } ``` The value resolver will attempt to extract a value from all configured sources until it finds one. Behind the scenes, `array_key_exists()` is used to determine if a value was found for a property, so `NULL` or falsy values are still considered "found". For instance, given the query string `email=&username=vic@1tomany.com`, the resolver would map an empty string to the `$username` property because the key `email` is present and comes before the `username` key. You can also combine chained sources. For example, you can have both a `#[SourceRequest]` and `#[SourceContainer]` attribute on a property: if the value wasn't found in the request content, then it would be retrieved from the container parameters. #### Additional property source arguments Each source attribute has boolean `$trim` and `$nullify` arguments as well. By default, `$trim` is `true` and `$nullify` is `false`. When `$trim` is `true`, the resolver will convert any scalar value to a string and run the `trim()` function in it. The value will then be coerced back to the type specified by the property of the input class during denormalization. When `$nullify` is `true`, the resolver will convert any scalar value to `null` if the value is exactly an empty string. The underlying property must also allow null values, otherwise an `OneToMany\RichBundle\ValueResolver\Exception\PropertyIsNotNullableException` exception is thrown. Take the following input class as an example: ```php > */ final readonly class CreateAccountHandler implements HandlerInterface { public function __construct( private UserRepositoryInterface $userRepository, private EntityManagerInterface $entityManager, ) { } /** * @see OneToMany\RichBundle\Contract\Action\HandlerInterface */ public function handle(CommandInterface $command): ResultInterface { // Create the Account entity $account = Account::create(...[ 'name' => $command->name, 'company' => $command->company, 'email' => $command->email, 'notes' => $command->notes, 'founded' => $command->founded, 'ipAddress' => $command->ipAddress, ]); // Attempt to find author if (null !== $command->author) { $author = $this->userRepository->findOneById(...[ 'userId' => $command->userId, ]); if (null === $author) { throw new UserNotFoundForCreatingAccountException($command->author); } $account->setAuthor($author); } $this->entityManager->persist($account); $this->entityManager->flush(); return HandlerResult::created($account); } } ``` Classes that implement the `OneToMany\RichBundle\Contract\Action\HandlerInterface` should use the `@implements` annotation to indicate the type of command the handler handles and the type of result the handler returns. Next, if an author was provided, the handler attempts to find that record. If not found, an exception is thrown. This is personal preference: my feeling is that if a nullable property has a value and that value isn't valid, an exception should be thrown rather than silently discarding the value. Finally, the new `App\Entity\Account` object is persisted, flushed, and returned. If there were other actions that needed to be taken, such as creating a record in another system, you could easily inject the Symfony message bus and enqueue a command to create a new customer record in Stripe, for instance. #### Unnecessary queries The query to find the author user may seem unnecessary - if the request was previously authenticated by Symfony and we _know_ the author user, why query for them again? Your intuition is right, in a simple environment, this is likely a redundant query that returns the same user object that was already loaded and hydrated by the Symfony security system. However, we want to build robust systems that can operate in a variety of different contexts, so you can't always make that assumption: - This handler may be placed behind a message queue on a server unaware of the HTTP request. - This handler may be used from the command line which is unaware of any authenticated users. - This handler may be used for bulk data imports where each row in a CSV file can have a different author. In each of these scenarios, the handler is unaware of the HTTP context, so it can't rely on it to exist to function properly. Ensuring each handler can operate in isolation also makes them easier to test: you can write a functional test without worrying about bootstrapping an HTTP environment. #### Handler exceptions A very specifically named exception `App\Module\Account\Action\Handler\Exception\UserNotFoundForCreatingAccountException` is thrown when the author user can't be found. I prefer to create a new exception class for each exception state. From just the name of the class, any developer can quickly tell what caused it to be thrown. A unique class for each exception also lets you standardize the error message and exception code. ```php */ public function __invoke(CreateAccountInput $createAccountInput): ResultInterface { $result = $this->createAccountHandler->handle(...[ 'command' => $createAccountInput->toCommand(), ]); return $result->withGroups(['read']); } } ``` It's that simple! You can now start the Symfony development server and make a request to your API: ```bash curl --location 'https://localhost:8000/api/accounts' \ --header 'Accept: application/json' \ --header 'Content-Type: application/json' \ --data-raw '{ "name": "Modesto Herman", "company": "Flurp Plumbing, LLC", "notes": "Plumbing company based out of Dallas, TX", "founded": "2002-08-25" }' ``` ## Conclusion I've been using this bundle (or rather, the code in this bundle) in production applications for over a year, and it's provided a delightful developer experience. Each class has a single purpose, I can easily throw a long running handler into a message queue, testing is much simpler, and most importantly, I can change individual components without fear they'll break something unrelated. Make your application RICH!