Preface
I'm a big advocate for Domain Driven Design (DDD) and I have been applying it on several projects over the years. Recently, there has been a lot of discussion (especially on an unnamed blue bird app) where you have firm believers and those who say it is over-engineering.
I'm not here to say that either one of those sides is wrong or right, as the choice you make depends on the context you're working in.
Below I will be talking about my own approach, which I think is rather pragmatic. I try to leverage the framework where possible and useful, while still applying concepts and design patterns from DDD.
Our problem domain
On one project we have the concept of an attribute. Conceptually, this is a data point that can be of a certain type (string, number, boolean, date, etc.) that you can use to build up a decision tree that eventually leads to some content being displayed.
An attribute could be something simple, such as age
or language
. Our biggest challenge with this project is the following: How do we get the data for these attributes into our system?
For our initial version, we decided that this will be done through query parameters in the URL.
In order to solve this problem, we've introduced the concept of a data source. This describes how data for the attributes will be provided. Initially, we will have support for two types of data sources:
Data source type 1: Expansion by query parameter
For example, when you add ?segment=123
, our system would convert this to a fixed value for an attribute. The segment could represent a target audience such as people speaking French in their 50s.
Data source type 2: Forwarding a query parameter
This data source would allow you to say that a value provided for a specific query parameter should be set as the value of an attribute. For example, the ?locale=en
query parameter could be forwarded to the language
attribute directly (after validation).
These data sources would need to be configurable through a user interface in our application and, depending on the type you chose, a different form would appear with the relevant fields.
Our model
In our code, we've modeled this concept with a Configuration
interface and a specific implementation for each type of data source that exists.
These are objects that also utilize other objects such as AttributeWithValue
or a collection of those. Each configuration also has an isMatch
method which is used to determine if it applies to the given query parameters. Here’s an example that includes both data source types.
final readonly class ExpandHttpQueryParam implements Configuration
{
/**
* @param Collection<int, string> $value
*/
public function __construct(
public string $key,
public Collection $value,
public AttributesWithValue $attributes
) {
}
public function isMatch(QueryParameters $parameters): bool
{
$value = $parameters->get($this->key);
if (is_null($value)) {
return false;
}
if (is_array($value)) {
return $this->value->intersect($value)->isNotEmpty();
}
return $this->value->contains($value);
}
}
final readonly class ForwardHttpQueryParam implements Configuration
{
public function __construct(
public string $key,
public AttributeWithValue $attribute,
) {
}
public function isMatch(QueryParameters $parameters): bool
{
return $parameters->get($this->key) !== null;
}
}
We've also introduced an attribute accessor on the DataSource
Eloquent model that ensures the configuration
property is cast to the correct implementation based on the type.
Building our form
The next step is to handle the write side of things. We need to deal with the admin configuration form where you can choose the type and enter the details of each configuration.
We start by adding a CreateDataSource
command. This requires the parent content, the data source type, and the configuration interface.
final readonly class CreateDataSource
{
public function __construct(
public Content $content,
public DataSourceType $type,
public Configuration $configuration,
) {
}
}
Then, we add the command handler that takes care of the persistence.
final readonly class CreateDataSource
{
public function __construct(
private DataSourceRepository $dataSourceRepository,
) {
}
public function handle(Job $job): void
{
$content = $job->content;
$dataSource = new DataSource();
$dataSource->type = $job->type;
$dataSource->configuration = $job->configuration;
$dataSource->content()->associate($content);
$this->dataSourceRepository->save($dataSource);
}
}
This command can be used anywhere in our HTTP layer such as a controller or a Livewire component.
With the aforementioned approach I ran into two issues:
- The configuration object had to be passed to the command when the form got submitted. This meant that all of its dependencies (which are also objects) had to be passed along as well.
- The configuration object had to be transferred "over-the-wire" so its values can be populated in the form and validated upon submission of the form.
Transferring over-the-wire using Livewire
Initially, I looked at Livewire's Wireable properties. It's pretty straightforward.
You add a toLivewire
method that serializes the object to an array and binds the properties to your form element. To deserialize, you add a fromLivewire
method that rebuilds the object based on the given state. This is called hydration.
At this point there was already a little voice in my head going: Why should my domain objects know how to (de)serialize) themselves for what is really a UI concern?
I also ran into a limitation. Livewire isn't able to hydrate an interface, so I had to also pass the type of the data source along with the configuration. I ended up working around this limitation by handling the hydration manually, as a part of the component.
At this point, the form was working correctly but there was still that voice, growing louder, saying the component is doing a lot of domain-specific work.
What about DTOs?
Something was fundamentally wrong so I referred back to some previous discussions and blog posts I had seen. This led me to Data Transfer Objects (DTOs).
Up until now, the configuration we've been using was a value object. Matthias Noback wrote a great blog post about the difference between them and to summarize:
A DTO:
- Declares and enforces a schema for data: names and types
- Offers no guarantees about correctness of values
A value object:
- Wraps one or more values or value objects
- Provides evidence of the correctness of these values (i.e. validates using business rules)
This realization simplified things and made them a lot more clear to me. The first change was to update the commands to use the DTO and task the command's handler with the conversion to the value object.
For the DTO, I used the Fluent
object as a basis. The beauty is that we can very easily make this object Wireable
since it will never contain non-primitive types.
I also added some named constructors to convert the value object into the DTO, although this could also have been done in a separate service like a data mapper.
final class Configuration extends Fluent implements Wireable
{
public function toLivewire(): array
{
return $this->toArray();
}
/**
* @param array $value
*/
public static function fromLivewire($value): self
{
return new self($value);
}
public static function fromConfiguration(ConfigurationInterface $configuration): self
{
if ($configuration instanceof HttpQueryParam) {
return new self([
'key' => $configuration->key,
'value' => $configuration->value->toArray(),
'attributes' => $configuration->attributes->toBase()->map(fn (AttributeWithValue $attributeWithValue) => [
'name' => $attributeWithValue->attribute->name(),
'type' => $attributeWithValue->attribute->type(),
'value' => $attributeWithValue->formatForInput(),
])->toArray(),
]);
}
if ($configuration instanceof ForwardHttpQueryParam) {
return new self([
'key' => $configuration->key,
'attribute' => $configuration->attribute->attribute->name(),
]);
}
return new self([]);
}
public static function emptyForType(DataSourceType $type): self
{
$configuration = match ($type) {
DataSourceType::HTTP_QUERY_PARAM => HttpQueryParam::empty(),
DataSourceType::HTTP_QUERY_PARAM_FORWARD => ForwardHttpQueryParam::empty(),
};
return self::fromConfiguration($configuration);
}
}
In the command handler, we introduced a DataSourceConfigurationFactory
. This takes the DTO and creates the value object whilst taking care of retrieving all of its dependencies.
final readonly class CreateDataSource
{
public function __construct(
private DataSourceRepository $dataSourceRepository,
private DataSourceConfigurationFactory $dataSourceConfigurationFactory,
) {
}
public function handle(Job $job): void
{
$content = $job->content;
$dataSource = new DataSource();
$dataSource->type = $job->type;
$dataSource->configuration = $this->dataSourceConfigurationFactory->createForType(
$content,
$job->type,
$job->configuration
);
$dataSource->content()->associate($content);
$this->dataSourceRepository->save($dataSource);
}
}
final readonly class DataSourceConfigurationFactory
{
public function __construct(private AttributeRepository $attributeRepository)
{
}
private function createExpandHttpQueryParamConfiguration(Content $content, Configuration $configuration): HttpQueryParam
{
$attributes = $this->attributeRepository->findAllForContent($content)->withoutSystemAttributes();
return new HttpQueryParam(
key: $configuration['key'],
value: new Collection($configuration['value']),
attributes: (new Collection($configuration['attributes']))
->filter(fn (array $attribute) => $attributes->hasName($attribute['name']))
->map(fn (array $attribute) => new AttributeWithValue(
attribute: $attributes->findByName($attribute['name']),
value: $attribute['value'],
))
->pipeInto(AttributesWithValue::class),
);
}
private function createForwardHttpQueryParamConfiguration(Content $content, Configuration $configuration): ForwardHttpQueryParam
{
$attributes = $this->attributeRepository->findAllForContent($content)->withoutSystemAttributes();
return new ForwardHttpQueryParam(
key: $configuration['key'],
attribute: AttributeWithValue::placeholder($attributes->findByName($configuration['attribute'])),
);
}
public function createForType(Content $content, DataSourceType $type, Configuration $configuration): ConfigurationInterface
{
return match ($type) {
DataSourceType::HTTP_QUERY_PARAM => $this->createExpandHttpQueryParamConfiguration($content, $configuration),
DataSourceType::HTTP_QUERY_PARAM_FORWARD => $this->createForwardHttpQueryParamConfiguration($content, $configuration),
};
}
}
What about validation?
As mentioned above, using the DTO means you have no guarantee about the correctness of the data. This doesn't mean we cannot add request validation even before we create our DTO.
Using DTOs in future projects
This was the first time I encountered a use case for introducing a DTO to help simplify something. Does this mean that from now on I'll always use DTOs in commands? Probably not.
I feel that there are still use cases where you can reuse a value object as a DTO. Take, for example, an EmailAddress
value object. This object will have some behavior to retrieve the domain part of the value and an invariant to assert it is an e-mail address.
Should that prevent you from using it in a command? To me, it feels like a lot of unnecessary duplication. I'm curious to hear about your experiences with value objects and DTOs. Let me know in the comments!
Member discussion