Domain-Driven Design na prática com PHP: transformando conceitos em código
Depois de compartilhar minhas primeiras impressões sobre Domain-Driven Design, decidi transformar os conceitos iniciais do livro de Eric Evans em exemplos práticos utilizando PHP. Neste artigo, apresento como Entidades, Value Objects, Repositórios e Serviços de Aplicação podem representar regras de negócio de forma mais clara, mostrando como o código pode refletir o domínio do problema e não apenas a estrutura do banco de dados.
No meu post anterior, “Minhas primeiras impressões sobre Domain-Driven Design, de Eric Evans”, compartilhei os principais aprendizados que tive durante a leitura do primeiro capítulo do livro.
Naquele artigo, destaquei ideias como a importância de compreender profundamente o domínio do negócio, aproximar desenvolvedores dos especialistas e construir um modelo que represente a realidade do problema.
Também falei sobre a Linguagem Ubíqua, um dos conceitos mais conhecidos do Domain-Driven Design.
Depois de escrever aquele conteúdo, comecei a pensar em uma pergunta bastante importante:
Como esses conceitos aparecem no código?
Neste artigo, quero tentar responder essa pergunta utilizando exemplos em PHP.
Meu objetivo não é apresentar uma implementação completa de Domain-Driven Design, até porque ainda estou estudando o assunto. A intenção é demonstrar como alguns conceitos apresentados no início do livro podem ser representados de maneira prática no código.
Para tornar o conteúdo mais acessível, incluirei comentários em português nos exemplos. Dessa forma, desenvolvedores iniciantes poderão acompanhar melhor o raciocínio, enquanto profissionais mais experientes poderão observar a organização das responsabilidades e das regras do domínio.
O código precisa representar o negócio
Em muitos sistemas, é comum encontrarmos classes que funcionam apenas como estruturas de dados.
Elas possuem atributos, métodos de acesso e pouca ou nenhuma regra de negócio.
Um exemplo simples seria:
<?php
class Pedido
{
// Identificação única do pedido.
public string $id;
// Identificação do cliente responsável pelo pedido.
public string $clienteId;
// Situação atual do pedido.
public string $status;
// Valor total armazenado diretamente na classe.
public float $total;
}
Essa classe representa alguns dados de um pedido, mas não explica como o pedido funciona.
Ela não responde perguntas importantes, como:
- um pedido pode ser finalizado sem itens?
- é possível adicionar produtos depois da finalização?
- como o valor total deve ser calculado?
- quais estados um pedido pode possuir?
As regras provavelmente acabariam espalhadas entre controllers, serviços, telas, comandos SQL e validações.
No Domain-Driven Design, buscamos fazer com que o próprio modelo represente os comportamentos e as regras do negócio.
A Linguagem Ubíqua em PHP
Imagine um sistema de vendas no qual um cliente realiza um pedido.
Durante uma conversa com as pessoas responsáveis pelo negócio, alguns termos aparecem frequentemente:
- Cliente;
- Pedido;
- Produto;
- Item do pedido;
- Quantidade;
- Preço;
- Valor total;
- Finalização do pedido.
Esses mesmos termos devem aparecer no código.
Essa é uma aplicação prática da Linguagem Ubíqua.
Quando os especialistas dizem “adicionar um item ao pedido”, o código também pode possuir um método chamado adicionarItem().
Quando o negócio utiliza a expressão “finalizar o pedido”, o código pode possuir um método chamado finalizar().
Isso aproxima a implementação técnica da linguagem utilizada pelas pessoas que conhecem o negócio.
Criando o item do pedido
Podemos começar criando uma classe para representar um item do pedido.
<?php
namespace App\Domain\Pedidos;
use InvalidArgumentException;
/**
* Representa um produto adicionado a um pedido.
*
* A classe também protege regras relacionadas ao item,
* como quantidade e preço maiores que zero.
*/
final class ItemDoPedido
{
public function __construct(
// Nome ou identificação do produto vendido.
private string $produto,
// Quantidade desse produto adicionada ao pedido.
private int $quantidade,
// Preço de uma unidade do produto.
private float $precoUnitario
) {
// Evita a criação de um item sem produto.
if (trim($produto) === '') {
throw new InvalidArgumentException(
'O produto deve ser informado.'
);
}
// Uma quantidade igual ou menor que zero não faz sentido
// dentro das regras desse domínio.
if ($quantidade <= 0) {
throw new InvalidArgumentException(
'A quantidade deve ser maior que zero.'
);
}
// Impede que o item seja criado com preço inválido.
if ($precoUnitario <= 0) {
throw new InvalidArgumentException(
'O preço unitário deve ser maior que zero.'
);
}
}
/**
* Calcula o valor total deste item.
*
* Exemplo:
* 2 unidades × R$ 100,00 = R$ 200,00.
*/
public function calcularSubtotal(): float
{
return $this->quantidade * $this->precoUnitario;
}
/**
* Retorna o produto relacionado ao item.
*/
public function produto(): string
{
return $this->produto;
}
/**
* Retorna a quantidade comprada.
*/
public function quantidade(): int
{
return $this->quantidade;
}
/**
* Retorna o preço de uma unidade do produto.
*/
public function precoUnitario(): float
{
return $this->precoUnitario;
}
}
Essa classe não funciona apenas como um recipiente de dados.
Ela também protege algumas regras básicas:
- o produto precisa ser informado;
- a quantidade deve ser maior que zero;
- o preço unitário deve ser maior que zero.
Além disso, a própria classe sabe calcular seu subtotal.
public function calcularSubtotal(): float
{
// O comportamento fica próximo dos dados utilizados no cálculo.
return $this->quantidade * $this->precoUnitario;
}
Em vez de espalhar esse cálculo pelo sistema, colocamos o comportamento próximo das informações que ele utiliza.
Criando a entidade Pedido
Agora podemos criar uma entidade para representar o pedido.
Uma entidade possui identidade própria e permanece sendo o mesmo objeto mesmo quando suas informações mudam.
Um pedido pode receber novos itens, alterar seu status ou ser finalizado, mas continua sendo o mesmo pedido.
<?php
namespace App\Domain\Pedidos;
use DomainException;
use InvalidArgumentException;
/**
* Entidade que representa um pedido do sistema.
*
* O Pedido possui identidade própria e concentra as principais
* regras relacionadas à inclusão de itens e à sua finalização.
*/
final class Pedido
{
// Estados permitidos dentro deste exemplo de domínio.
private const STATUS_ABERTO = 'aberto';
private const STATUS_FINALIZADO = 'finalizado';
/**
* Lista de itens adicionados ao pedido.
*
* @var ItemDoPedido[]
*/
private array $itens = [];
/**
* Todo novo pedido começa aberto.
*/
private string $status = self::STATUS_ABERTO;
public function __construct(
// Identidade única do pedido.
private string $id,
// Identidade do cliente que realizou o pedido.
private string $clienteId
) {
// Uma entidade precisa possuir uma identificação válida.
if (trim($id) === '') {
throw new InvalidArgumentException(
'O identificador do pedido deve ser informado.'
);
}
// O pedido precisa estar relacionado a um cliente.
if (trim($clienteId) === '') {
throw new InvalidArgumentException(
'O cliente deve ser informado.'
);
}
}
/**
* Adiciona um item ao pedido.
*
* A própria entidade verifica se essa operação é permitida.
*/
public function adicionarItem(ItemDoPedido $item): void
{
// Depois de finalizado, o pedido não pode mais ser alterado.
if ($this->estaFinalizado()) {
throw new DomainException(
'Não é possível adicionar itens a um pedido finalizado.'
);
}
// Caso a regra seja respeitada, o item é incluído.
$this->itens[] = $item;
}
/**
* Calcula o valor total somando o subtotal de todos os itens.
*/
public function calcularTotal(): float
{
return array_reduce(
$this->itens,
// Para cada item, somamos seu subtotal ao valor acumulado.
fn (
float $total,
ItemDoPedido $item
): float => $total + $item->calcularSubtotal(),
// O cálculo começa em zero.
0.0
);
}
/**
* Finaliza o pedido, desde que todas as regras sejam atendidas.
*/
public function finalizar(): void
{
// Evita a repetição da mesma operação.
if ($this->estaFinalizado()) {
throw new DomainException(
'O pedido já foi finalizado.'
);
}
// Um pedido precisa possuir pelo menos um item
// antes de ser finalizado.
if ($this->estaVazio()) {
throw new DomainException(
'Um pedido sem itens não pode ser finalizado.'
);
}
// Somente depois das validações o status é alterado.
$this->status = self::STATUS_FINALIZADO;
}
/**
* Informa se o pedido já foi finalizado.
*/
public function estaFinalizado(): bool
{
return $this->status === self::STATUS_FINALIZADO;
}
/**
* Informa se o pedido ainda não possui itens.
*/
public function estaVazio(): bool
{
return count($this->itens) === 0;
}
/**
* Retorna a identidade do pedido.
*/
public function id(): string
{
return $this->id;
}
/**
* Retorna a identidade do cliente.
*/
public function clienteId(): string
{
return $this->clienteId;
}
/**
* Retorna o status atual do pedido.
*/
public function status(): string
{
return $this->status;
}
/**
* Retorna os itens do pedido.
*
* @return ItemDoPedido[]
*/
public function itens(): array
{
return $this->itens;
}
}
Nesse exemplo, a entidade Pedido possui comportamentos claros.
Ela determina que:
- um pedido finalizado não pode receber novos itens;
- um pedido vazio não pode ser finalizado;
- um pedido já finalizado não pode ser finalizado novamente;
- o valor total deve ser calculado a partir de seus itens.
Essas regras estão dentro do modelo de domínio.
Isso evita que diferentes partes do sistema implementem a mesma regra de maneiras diferentes.
Utilizando o modelo de domínio
A utilização das classes poderia ser feita da seguinte forma:
<?php
use App\Domain\Pedidos\ItemDoPedido;
use App\Domain\Pedidos\Pedido;
// Criamos um novo pedido para um cliente.
$pedido = new Pedido(
id: 'PED-2026-0001',
clienteId: 'CLI-150'
);
// Adicionamos duas unidades de um tênis ao pedido.
$pedido->adicionarItem(
new ItemDoPedido(
produto: 'Tênis esportivo',
quantidade: 2,
precoUnitario: 299.90
)
);
// Adicionamos uma mochila ao mesmo pedido.
$pedido->adicionarItem(
new ItemDoPedido(
produto: 'Mochila',
quantidade: 1,
precoUnitario: 189.90
)
);
// Solicitamos ao próprio pedido o cálculo do valor total.
$total = $pedido->calcularTotal();
// Exibimos o valor formatado no padrão brasileiro.
echo 'Total do pedido: R$ ';
echo number_format(
$total,
2,
',',
'.'
);
// Depois de adicionar os itens, finalizamos o pedido.
$pedido->finalizar();
O código possui uma leitura muito próxima da linguagem do negócio:
// Adiciona um produto ao pedido. $pedido->adicionarItem($item); // Calcula o valor de todos os itens. $pedido->calcularTotal(); // Finaliza o processo do pedido. $pedido->finalizar();
Isso é mais expressivo do que utilizar métodos genéricos:
// O nome não explica qual processamento será realizado. $pedido->processar(); // A ação executada também não fica clara. $pedido->executarAcao(); // O leitor precisaria descobrir o significado do número 3. $pedido->alterarStatus(3);
Métodos genéricos dificultam a compreensão.
Quando utilizamos nomes relacionados ao domínio, a intenção se torna mais clara.
Protegendo as regras do negócio
Agora imagine que alguém tente finalizar um pedido sem produtos.
<?php
use App\Domain\Pedidos\Pedido;
// Criamos um pedido, mas não adicionamos nenhum item.
$pedido = new Pedido(
id: 'PED-2026-0002',
clienteId: 'CLI-200'
);
// A entidade verificará suas regras antes de finalizar.
$pedido->finalizar();
A entidade não permitirá essa operação.
Um pedido sem itens não pode ser finalizado.
Também não será possível adicionar um novo item depois que o pedido estiver finalizado.
<?php
// Primeiro finalizamos o pedido.
$pedido->finalizar();
// Depois tentamos alterar um pedido que já está encerrado.
$pedido->adicionarItem(
new ItemDoPedido(
produto: 'Carteira',
quantidade: 1,
precoUnitario: 99.90
)
);
O resultado será uma exceção de domínio:
Não é possível adicionar itens a um pedido finalizado.
Isso demonstra um ponto importante.
A entidade não depende de um controller para proteger suas regras.
Mesmo que seja utilizada em uma API, em um comando de terminal ou em um processamento em segundo plano, o comportamento continuará sendo respeitado.
Entidades não devem ser apenas estruturas de dados
Em muitos projetos, encontramos o chamado modelo anêmico.
Nesse tipo de implementação, as classes possuem apenas atributos e métodos de leitura e alteração.
<?php
class Pedido
{
private string $status;
/**
* Altera o status sem proteger nenhuma regra do domínio.
*/
public function setStatus(string $status): void
{
$this->status = $status;
}
}
Esse código permite realizar alterações sem respeitar necessariamente as regras do negócio.
// Qualquer valor pode ser informado.
$pedido->setStatus('qualquer_valor');
Também permite alterar diretamente o status para finalizado sem verificar se o pedido possui itens.
Uma abordagem mais orientada ao domínio evita expor uma alteração genérica.
// O método representa uma operação real do negócio // e executa todas as validações necessárias. $pedido->finalizar();
O método finalizar() deixa clara a intenção e protege as regras.
Essa diferença pode parecer pequena, mas se torna muito importante em sistemas maiores.
Representando dinheiro com um Value Object
Até agora utilizamos float para representar valores monetários.
Porém, dinheiro não é apenas um número.
Ele possui regras, moeda, formatação e precisão.
Podemos representar esse conceito por meio de um Objeto de Valor, também conhecido como Value Object.
<?php
namespace App\Domain\Compartilhado;
use InvalidArgumentException;
/**
* Objeto de Valor responsável por representar dinheiro.
*
* O valor é armazenado em centavos para evitar parte dos
* problemas de precisão relacionados ao uso de números decimais.
*
* Como a classe é readonly, seus dados não podem ser alterados
* depois da criação do objeto.
*/
final readonly class Dinheiro
{
public function __construct(
// Valor monetário armazenado em centavos.
private int $centavos,
// Código da moeda utilizada.
private string $moeda = 'BRL'
) {
// Neste domínio, não permitimos valores negativos.
if ($centavos < 0) {
throw new InvalidArgumentException(
'O valor não pode ser negativo.'
);
}
// Todo valor precisa possuir uma moeda.
if (trim($moeda) === '') {
throw new InvalidArgumentException(
'A moeda deve ser informada.'
);
}
}
/**
* Cria um objeto Dinheiro a partir de um valor em reais.
*
* Exemplo:
* Dinheiro::reais(10.50) armazenará 1050 centavos.
*/
public static function reais(float $valor): self
{
return new self(
// Multiplicamos por 100 e arredondamos para obter centavos.
centavos: (int) round($valor * 100),
moeda: 'BRL'
);
}
/**
* Soma dois valores monetários.
*
* Como o objeto é imutável, um novo objeto é retornado.
*/
public function somar(self $outroValor): self
{
// Não podemos somar reais com dólares sem uma conversão.
if ($this->moeda !== $outroValor->moeda) {
throw new InvalidArgumentException(
'Não é possível somar valores de moedas diferentes.'
);
}
// Retornamos um novo objeto em vez de modificar o atual.
return new self(
centavos: $this->centavos + $outroValor->centavos,
moeda: $this->moeda
);
}
/**
* Multiplica o valor por uma quantidade.
*
* Pode ser utilizado, por exemplo, para calcular o subtotal
* de várias unidades do mesmo produto.
*/
public function multiplicar(int $quantidade): self
{
// Evita multiplicações por zero ou números negativos.
if ($quantidade <= 0) {
throw new InvalidArgumentException(
'A quantidade deve ser maior que zero.'
);
}
return new self(
centavos: $this->centavos * $quantidade,
moeda: $this->moeda
);
}
/**
* Retorna o valor armazenado em centavos.
*/
public function centavos(): int
{
return $this->centavos;
}
/**
* Retorna o código da moeda.
*/
public function moeda(): string
{
return $this->moeda;
}
/**
* Retorna o valor no formato monetário brasileiro.
*/
public function formatado(): string
{
return sprintf(
'R$ %s',
number_format(
// Convertemos os centavos novamente para reais.
$this->centavos / 100,
2,
',',
'.'
)
);
}
}
A utilização dessa classe ficaria assim:
<?php use App\Domain\Compartilhado\Dinheiro; // Criamos um preço unitário de R$ 299,90. $precoUnitario = Dinheiro::reais(299.90); // Calculamos o valor de duas unidades. $subtotal = $precoUnitario->multiplicar(2); // Exibimos o resultado formatado. echo $subtotal->formatado();
Resultado:
R$ 599,80
O objeto Dinheiro também evita que operações inválidas sejam realizadas.
Por exemplo, não seria possível somar valores em moedas diferentes.
<?php
use App\Domain\Compartilhado\Dinheiro;
// Representa R$ 100,00.
$valorEmReais = new Dinheiro(
centavos: 10000,
moeda: 'BRL'
);
// Representa US$ 100,00.
$valorEmDolares = new Dinheiro(
centavos: 10000,
moeda: 'USD'
);
// A operação será bloqueada porque as moedas são diferentes.
$total = $valorEmReais->somar($valorEmDolares);
O resultado seria uma exceção:
Não é possível somar valores de moedas diferentes.
Refatorando o item do pedido
Agora podemos substituir o float pelo objeto Dinheiro.
<?php
namespace App\Domain\Pedidos;
use App\Domain\Compartilhado\Dinheiro;
use InvalidArgumentException;
/**
* Representa um item do pedido utilizando um Value Object
* para proteger o valor monetário.
*/
final class ItemDoPedido
{
public function __construct(
// Produto vendido.
private string $produto,
// Quantidade comprada.
private int $quantidade,
// Preço unitário representado pelo objeto Dinheiro.
private Dinheiro $precoUnitario
) {
if (trim($produto) === '') {
throw new InvalidArgumentException(
'O produto deve ser informado.'
);
}
if ($quantidade <= 0) {
throw new InvalidArgumentException(
'A quantidade deve ser maior que zero.'
);
}
}
/**
* Calcula o subtotal usando o comportamento do Value Object.
*/
public function calcularSubtotal(): Dinheiro
{
return $this->precoUnitario->multiplicar(
$this->quantidade
);
}
public function produto(): string
{
return $this->produto;
}
public function quantidade(): int
{
return $this->quantidade;
}
public function precoUnitario(): Dinheiro
{
return $this->precoUnitario;
}
}
O cálculo do subtotal agora retorna um objeto Dinheiro.
Isso torna o código mais seguro e mais expressivo.
Refatorando o cálculo total do pedido
A entidade Pedido também pode ser alterada para calcular o total utilizando o Value Object.
Abaixo está apenas a parte que precisa ser modificada:
<?php
use App\Domain\Compartilhado\Dinheiro;
class Pedido
{
/**
* Calcula o valor total do pedido.
*/
public function calcularTotal(): Dinheiro
{
// O cálculo começa com um valor de R$ 0,00.
$total = Dinheiro::reais(0);
// Percorremos todos os itens adicionados ao pedido.
foreach ($this->itens as $item) {
// Cada subtotal é somado ao total acumulado.
//
// Como Dinheiro é imutável, o método somar()
// retorna um novo objeto.
$total = $total->somar(
$item->calcularSubtotal()
);
}
return $total;
}
}
A utilização ficaria assim:
<?php
use App\Domain\Compartilhado\Dinheiro;
use App\Domain\Pedidos\ItemDoPedido;
use App\Domain\Pedidos\Pedido;
// Criamos um novo pedido.
$pedido = new Pedido(
id: 'PED-2026-0003',
clienteId: 'CLI-300'
);
// Adicionamos duas unidades de um tênis.
$pedido->adicionarItem(
new ItemDoPedido(
produto: 'Tênis esportivo',
quantidade: 2,
precoUnitario: Dinheiro::reais(299.90)
)
);
// Adicionamos uma mochila.
$pedido->adicionarItem(
new ItemDoPedido(
produto: 'Mochila',
quantidade: 1,
precoUnitario: Dinheiro::reais(189.90)
)
);
// O cálculo retorna um objeto Dinheiro.
// Em seguida, solicitamos sua representação formatada.
echo $pedido->calcularTotal()->formatado();
Resultado:
R$ 789,70
Separando o domínio da infraestrutura
Outro aprendizado importante é evitar que o domínio dependa diretamente de tecnologias específicas.
A entidade Pedido não precisa conhecer:
- MySQL;
- PostgreSQL;
- Laravel;
- Eloquent;
- APIs externas;
- Redis;
- filas;
- arquivos.
Ela precisa conhecer apenas suas regras de negócio.
Para representar a persistência, podemos criar uma interface de repositório.
<?php
namespace App\Domain\Pedidos;
/**
* Contrato para armazenamento e recuperação de pedidos.
*
* O domínio informa quais operações precisa realizar,
* mas não determina qual tecnologia será utilizada.
*/
interface RepositorioDePedidos
{
/**
* Armazena o estado atual de um pedido.
*/
public function salvar(Pedido $pedido): void;
/**
* Procura um pedido pela sua identidade.
*
* Retorna null quando o pedido não for encontrado.
*/
public function buscarPorId(string $pedidoId): ?Pedido;
}
Essa interface descreve o que o domínio precisa.
Ele precisa salvar e localizar pedidos.
A interface não informa como isso será feito.
Criando um repositório em memória
Para realizar testes, podemos criar uma implementação simples em memória.
<?php
namespace App\Infrastructure\Pedidos;
use App\Domain\Pedidos\Pedido;
use App\Domain\Pedidos\RepositorioDePedidos;
/**
* Implementação de repositório que mantém os pedidos em memória.
*
* É útil para exemplos e testes automatizados porque não depende
* de um banco de dados real.
*/
final class RepositorioDePedidosEmMemoria
implements RepositorioDePedidos
{
/**
* Os pedidos são armazenados em um array.
*
* A chave será o identificador do pedido.
*
* @var array<string, Pedido>
*/
private array $pedidos = [];
/**
* Salva ou atualiza um pedido dentro do array.
*/
public function salvar(Pedido $pedido): void
{
// O identificador funciona como chave única.
$this->pedidos[$pedido->id()] = $pedido;
}
/**
* Busca o pedido pela sua identidade.
*/
public function buscarPorId(string $pedidoId): ?Pedido
{
// Caso o identificador não exista, retornamos null.
return $this->pedidos[$pedidoId] ?? null;
}
}
Posteriormente, poderíamos criar outra implementação utilizando Eloquent.
<?php
namespace App\Infrastructure\Pedidos;
use App\Domain\Pedidos\Pedido;
use App\Domain\Pedidos\RepositorioDePedidos;
/**
* Exemplo conceitual de um repositório utilizando Laravel Eloquent.
*/
final class RepositorioDePedidosEloquent
implements RepositorioDePedidos
{
public function salvar(Pedido $pedido): void
{
/*
* Aqui seria realizada a conversão da entidade de domínio
* para o formato esperado pelos Models do Eloquent.
*
* O código foi omitido porque depende da estrutura
* das tabelas e dos Models da aplicação.
*/
}
public function buscarPorId(string $pedidoId): ?Pedido
{
/*
* Aqui o Eloquent faria a consulta no banco.
*
* Depois, os dados encontrados seriam utilizados para
* reconstruir uma entidade Pedido.
*/
return null;
}
}
O domínio continua utilizando a mesma interface.
Isso significa que suas regras não ficam presas ao framework.
Criando um serviço de aplicação
Também podemos criar um serviço para coordenar o caso de uso de finalização de um pedido.
<?php
namespace App\Application\Pedidos;
use App\Domain\Pedidos\RepositorioDePedidos;
use DomainException;
/**
* Caso de uso responsável por finalizar um pedido.
*
* Esta classe coordena a operação, mas não implementa
* a regra de negócio da finalização.
*/
final class FinalizarPedido
{
public function __construct(
// O serviço depende do contrato, não de uma implementação específica.
private RepositorioDePedidos $repositorio
) {
}
/**
* Executa o caso de uso.
*/
public function executar(string $pedidoId): void
{
// Primeiro buscamos o pedido no repositório.
$pedido = $this->repositorio->buscarPorId(
$pedidoId
);
// O serviço precisa tratar o caso em que o pedido não existe.
if ($pedido === null) {
throw new DomainException(
'Pedido não encontrado.'
);
}
/*
* Solicitamos que a própria entidade seja finalizada.
*
* A entidade verificará se possui itens e se ainda
* não foi finalizada anteriormente.
*/
$pedido->finalizar();
// Depois da alteração, salvamos o novo estado.
$this->repositorio->salvar($pedido);
}
}
Esse serviço coordena três etapas:
- busca o pedido;
- solicita que o próprio pedido seja finalizado;
- salva o resultado.
Observe que a regra de finalização continua dentro da entidade:
// A entidade é responsável por verificar suas próprias regras. $pedido->finalizar();
O serviço não precisa verificar se o pedido possui itens.
Ele também não precisa verificar se o pedido já está finalizado.
Essas responsabilidades pertencem ao próprio pedido.
Serviço de aplicação não é serviço de domínio
Nesse exemplo, FinalizarPedido funciona como um serviço de aplicação.
Sua responsabilidade é coordenar o fluxo do caso de uso.
Ele não contém a regra principal do negócio.
A regra continua na entidade.
Essa diferença é importante porque nem toda classe chamada Service representa um Serviço de Domínio.
Um Serviço de Domínio costuma ser utilizado quando uma regra de negócio não pertence naturalmente a uma única entidade ou Objeto de Valor.
Como este artigo é introdutório, pretendo estudar e abordar essa diferença com mais profundidade em um conteúdo futuro.
Um exemplo com controller no Laravel
Em uma aplicação Laravel, um controller poderia receber a requisição e chamar o serviço de aplicação.
<?php
namespace App\Http\Controllers;
use App\Application\Pedidos\FinalizarPedido;
use DomainException;
use Illuminate\Http\JsonResponse;
/**
* Controller responsável pelo endpoint de finalização do pedido.
*
* Sua responsabilidade está relacionada à camada HTTP:
* receber a requisição e produzir uma resposta.
*/
final class FinalizarPedidoController
{
public function __construct(
// O caso de uso é recebido pelo container de dependências.
private FinalizarPedido $finalizarPedido
) {
}
/**
* Executa o controller como uma classe invocável.
*/
public function __invoke(string $pedidoId): JsonResponse
{
try {
// Encaminhamos a operação para o serviço de aplicação.
$this->finalizarPedido->executar(
$pedidoId
);
// Em caso de sucesso, retornamos uma resposta HTTP.
return response()->json([
'mensagem' => 'Pedido finalizado com sucesso.',
]);
} catch (DomainException $exception) {
/*
* Uma violação das regras do domínio é transformada
* em uma resposta compreensível para o consumidor da API.
*/
return response()->json([
'mensagem' => $exception->getMessage(),
], 422);
}
}
}
O controller fica responsável por questões relacionadas à camada HTTP.
Ele recebe a requisição e devolve uma resposta.
O serviço coordena o caso de uso.
A entidade continua responsável pelas regras do negócio.
Essa separação ajuda a deixar o código mais organizado.
Comparando com uma abordagem CRUD tradicional
Sem uma preocupação maior com o domínio, poderíamos encontrar um código parecido com este:
<?php
// O Model representa diretamente a tabela do banco.
$pedido = PedidoModel::findOrFail(
$pedidoId
);
/*
* Para compreender essa condição, o desenvolvedor precisa saber:
*
* - o significado do status 2;
* - o significado do status 3;
* - por que o pedido precisa possuir itens;
* - em quais outros locais essa regra foi implementada.
*/
if (
$pedido->status !== 2
&& $pedido->itens()->count() > 0
) {
// O status é alterado diretamente.
$pedido->status = 3;
// A alteração é persistida no banco.
$pedido->save();
}
Embora esse código possa funcionar, ele apresenta alguns problemas.
Não sabemos imediatamente o que os números 2 e 3 representam.
Também não fica evidente qual operação de negócio está sendo executada.
Além disso, essa mesma validação pode acabar sendo repetida em vários lugares.
Com um modelo mais expressivo, temos:
<?php
// Localizamos a entidade pelo repositório.
$pedido = $repositorio->buscarPorId(
$pedidoId
);
// Executamos uma operação com significado para o negócio.
$pedido->finalizar();
// Persistimos o novo estado da entidade.
$repositorio->salvar($pedido);
Essa versão deixa a intenção mais clara.
Estamos finalizando um pedido.
Não estamos apenas alterando um campo de status.
O banco de dados não deve definir o domínio
Durante muito tempo, eu também pensei em sistemas começando pelas tabelas do banco de dados.
Primeiro criamos a tabela pedidos.
Depois criamos a tabela itens_pedido.
Em seguida, criamos Models que representam essas tabelas.
Essa abordagem é comum e pode funcionar bem em sistemas mais simples.
Entretanto, o Domain-Driven Design propõe começar por outra pergunta:
Quais são os conceitos, comportamentos e regras mais importantes do negócio?
O modelo de domínio não deve existir apenas para espelhar a estrutura do banco.
Ele deve representar a realidade do problema.
O banco de dados é uma tecnologia utilizada para persistir o estado desse modelo.
Onde está a verdadeira complexidade?
Esse exemplo de pedido é relativamente simples.
Em um sistema real, poderiam existir várias outras regras:
- limite de crédito do cliente;
- pedido mínimo;
- aprovação comercial;
- desconto máximo;
- estoque reservado;
- diferentes tabelas de preço;
- condições de pagamento;
- prazo de entrega;
- bloqueio de clientes inadimplentes;
- regras específicas para cada canal de venda.
É nesse conjunto de regras que normalmente encontramos a verdadeira complexidade do software.
A parte técnica continua sendo importante, mas ela precisa estar organizada para servir ao domínio.
DDD não significa criar muitas camadas
Um cuidado importante é não interpretar DDD apenas como uma forma de criar pastas, interfaces e classes.
Criar diretórios chamados Domain, Application e Infrastructure não significa automaticamente que um projeto utiliza Domain-Driven Design.
Também não é necessário transformar todo sistema em uma arquitetura complexa.
O ponto principal é compreender o negócio e criar um modelo capaz de expressar suas regras.
Em alguns casos, um CRUD simples pode ser suficiente.
Em outros, quando as regras são complexas e mudam frequentemente, um modelo de domínio mais rico pode trazer benefícios.
DDD deve ajudar a controlar a complexidade, e não criar complexidade desnecessária.
O que aprendi ao transformar os conceitos em código
Ao escrever esses exemplos, comecei a compreender melhor alguns pontos apresentados por Eric Evans.
O primeiro é que nomes importam.
Métodos como adicionarItem(), calcularTotal() e finalizar() explicam melhor o comportamento do sistema do que métodos genéricos.
O segundo é que as regras devem ficar próximas dos conceitos aos quais pertencem.
A regra que impede a finalização de um pedido vazio pertence ao pedido.
Ela não deveria existir apenas em um controller.
O terceiro é que o domínio não deveria depender diretamente do framework.
Laravel, Symfony, bancos de dados e filas são ferramentas importantes, mas as principais regras do negócio precisam continuar compreensíveis mesmo fora dessas tecnologias.
Os comentários adicionados aos exemplos não fazem parte obrigatória da aplicação prática de DDD. Eles foram incluídos para explicar as responsabilidades e tornar o conteúdo acessível para leitores com diferentes níveis de experiência.
Em um projeto real, é importante evitar comentários que apenas repetem o que o código já deixa evidente.
Comentários são mais úteis quando explicam:
- por que determinada decisão foi tomada;
- qual regra do negócio está sendo protegida;
- por que uma operação não pode ser realizada;
- qual responsabilidade pertence a determinada camada.
Conclusão
Este artigo é uma continuação do meu post “Minhas primeiras impressões sobre Domain-Driven Design, de Eric Evans”.
No primeiro conteúdo, compartilhei minhas interpretações iniciais sobre o capítulo 1 do livro.
Neste artigo, procurei transformar parte desses conceitos em exemplos práticos utilizando PHP.
Por meio de um pequeno domínio de pedidos, foi possível visualizar alguns pontos importantes:
- a utilização da Linguagem Ubíqua;
- entidades com identidade e comportamento;
- proteção das regras de negócio;
- utilização de Objetos de Valor;
- separação entre domínio e infraestrutura;
- utilização de repositórios;
- coordenação de casos de uso com serviços de aplicação.
Ainda estou no início da leitura e sei que Domain-Driven Design envolve muitos outros conceitos importantes.
Entidades, Objetos de Valor, Agregados, Repositórios, Serviços de Domínio e Bounded Contexts merecem estudos e artigos específicos.
Mesmo assim, esse exercício já me ajudou a entender uma das principais ideias do livro:
O código deve representar o negócio, e não apenas a tecnologia utilizada para construir o sistema.
Pretendo continuar documentando minha evolução nos próximos capítulos e trazer novos exemplos em PHP e Laravel.
Acredito que transformar o aprendizado em conteúdo e código é uma excelente maneira de consolidar o conhecimento e compartilhar experiências com outros desenvolvedores.