Casos de uso orientados a operações (Use Cases)
Em uma arquitetura limpa, um caso de uso representa uma operação de negócio executável e testável, como CreateUser, ListUsers e UpdateUser. Ele recebe uma entrada explícita (um DTO de input), executa regras de negócio e retorna uma saída explícita (um DTO de output). Assim, a camada HTTP (Slim) fica responsável apenas por: ler o request, mapear para DTOs, chamar o caso de uso e mapear a saída para response.
O objetivo é evitar que regras de domínio “vazem” para a camada de transporte (rotas/actions), mantendo o domínio independente de JSON, headers, status codes e detalhes do Slim.
Estrutura típica
- Action/Controller (HTTP): mapeia payload HTTP → DTO de input; chama o caso de uso; mapeia DTO de output → JSON/HTTP.
- Use Case: orquestra regras, validações de negócio, conflitos e persistência via portas (interfaces).
- Domínio: entidades/VOs e regras invariantes.
- Infra: repositórios concretos, gateways, etc.
Comandos vs Consultas (Commands vs Queries)
Separar operações em comandos e consultas ajuda a manter responsabilidades claras:
- Command: altera estado (cria/atualiza/remove). Ex.:
CreateUser,UpdateUser. Normalmente envolve validações de negócio e pode falhar por conflito. - Query: apenas lê dados, sem efeitos colaterais. Ex.:
ListUsers. Pode ter paginação/filtros e tende a ser mais simples.
Na prática, ambos podem ser implementados como casos de uso, mas com contratos e retornos adequados. O importante é: não misturar escrita e leitura na mesma operação e não depender de detalhes HTTP.
DTOs de entrada e saída: contratos explícitos
DTOs (Data Transfer Objects) são objetos simples que carregam dados entre camadas. Aqui, eles servem para:
- Ouça o áudio com a tela desligada
- Ganhe Certificado após a conclusão
- + de 5000 cursos para você explorar!
Baixar o aplicativo
- Definir claramente o que o caso de uso precisa para executar.
- Evitar acoplamento do caso de uso ao formato do request (JSON, form-data etc.).
- Padronizar o que sai do caso de uso, sem “vazar” entidades internas.
Exemplo de DTOs (CreateUser)
final class CreateUserInputDTO { public function __construct( public readonly string $name, public readonly string $email, public readonly string $passwordPlain ) {} } final class CreateUserOutputDTO { public function __construct( public readonly string $id, public readonly string $name, public readonly string $email, public readonly string $createdAt ) {} }Note que o output não expõe passwordHash nem detalhes internos. O caso de uso decide o que é seguro e útil retornar.
Mapeadores: payload HTTP ↔ DTOs ↔ modelos internos
Para manter a camada HTTP fina, crie mapeadores (ou assemblers) responsáveis por converter:
- Request JSON → InputDTO
- OutputDTO → Response JSON
Isso evita que Actions fiquem cheias de $body['campo'] e regras de transformação.
Mapper de input (CreateUser)
final class CreateUserHttpMapper { /** @param array<string,mixed> $payload */ public function toInputDTO(array $payload): CreateUserInputDTO { return new CreateUserInputDTO( name: (string)($payload['name'] ?? ''), email: (string)($payload['email'] ?? ''), passwordPlain: (string)($payload['password'] ?? '') ); } }Repare que o mapper não valida regra de negócio; ele apenas converte. Validações de formato/sanitização podem ocorrer antes (camada HTTP), mas regras de domínio ficam no caso de uso.
Mapper de output (CreateUser)
final class UserOutputHttpMapper { /** @return array<string,mixed> */ public function toJson(CreateUserOutputDTO $dto): array { return [ 'id' => $dto->id, 'name' => $dto->name, 'email' => $dto->email, 'createdAt' => $dto->createdAt, ]; } }Passo a passo prático: CreateUser (Command)
1) Defina portas (interfaces) necessárias ao caso de uso
O caso de uso não deve conhecer PDO/ORM. Ele depende de interfaces.
interface UserRepository { public function existsByEmail(string $email): bool; public function save(User $user): void; } interface PasswordHasher { public function hash(string $plain): string; }2) Modele o domínio mínimo (entidade/VO) sem HTTP
final class User { public function __construct( public readonly string $id, public string $name, public string $email, public string $passwordHash, public readonly
DateTimeImmutable $createdAt ) {} }3) Crie exceções de conflito/regra de negócio
Conflitos como “e-mail já cadastrado” são regras de negócio e devem ser sinalizados pelo caso de uso (não pela Action).
final class EmailAlreadyRegistered extends
DomainException { public function __construct(string $email) { parent::__construct("E-mail j1 cadastrado: {$email}"); } }4) Implemente o caso de uso CreateUser
final class CreateUser { public function __construct( private UserRepository $users, private PasswordHasher $hasher, private IdGenerator $ids, private Clock $clock ) {} public function execute(CreateUserInputDTO $input): CreateUserOutputDTO { // Regra de negcio: e-mail deve ser anico if ($this->users->existsByEmail($input->email)) { throw new EmailAlreadyRegistered($input->email); } // Regra de negcio: nome n3o pode ser vazio (exemplo) if (trim($input->name) === '') { throw new
DomainException('Nome 9 obrigat3rio.'); } $id = $this->ids->generate(); $now = $this->clock->now(); $hash = $this->hasher->hash($input->passwordPlain); $user = new User( id: $id, name: $input->name, email: $input->email, passwordHash: $hash, createdAt: $now ); $this->users->save($user); return new CreateUserOutputDTO( id: $user->id, name: $user->name, email: $user->email, createdAt: $user->createdAt->format(DATE_ATOM) ); } }Observe que o caso de uso não retorna User (entidade). Ele retorna um DTO de saída apropriado para consumo externo.
5) Conecte na Action do Slim usando mapeadores
A Action fica responsável por: ler JSON, mapear para DTO, executar e mapear saída. O tratamento de exceções e padronização de erros deve estar centralizado (por exemplo, via middleware/handler global), então aqui mostramos apenas o fluxo.
final class CreateUserAction { public function __construct( private CreateUser $useCase, private CreateUserHttpMapper $inputMapper, private UserOutputHttpMapper $outputMapper ) {} public function __invoke(
Psr\Http\Message\ServerRequestInterface $request,
Psr\Http\Message\ResponseInterface $response):
Psr\Http\Message\ResponseInterface { /** @var array<string,mixed> $payload */ $payload = (array)($request->getParsedBody() ?? []); $input = $this->inputMapper->toInputDTO($payload); $output = $this->useCase->execute($input); $json = $this->outputMapper->toJson($output); $response->getBody()->write(json_encode($json)); return $response->withHeader('Content-Type', 'application/json')->withStatus(201); } }Passo a passo prático: ListUsers (Query)
Consultas podem usar um repositório específico de leitura (porta de query) e retornar DTOs prontos para serialização, sem carregar entidades completas.
1) DTOs de query
final class ListUsersInputDTO { public function __construct( public readonly int $page, public readonly int $perPage, public readonly ?string $search ) {} } final class UserListItemDTO { public function __construct( public readonly string $id, public readonly string $name, public readonly string $email ) {} } final class ListUsersOutputDTO { /** @param list<UserListItemDTO> $items */ public function __construct( public readonly array $items, public readonly int $page, public readonly int $perPage, public readonly int $total ) {} }2) Porta de leitura
interface UserQuery { public function list(int $page, int $perPage, ?string $search): ListUsersOutputDTO; }3) Caso de uso (query) como orquestrador simples
final class ListUsers { public function __construct(private UserQuery $query) {} public function execute(ListUsersInputDTO $input): ListUsersOutputDTO { // Regras de negcio de consulta (ex.: limites) $perPage = max(1, min(100, $input->perPage)); $page = max(1, $input->page); return $this->query->list($page, $perPage, $input->search); } }4) Mapper de querystring → InputDTO
final class ListUsersHttpMapper { /** @param array<string,string> $queryParams */ public function toInputDTO(array $queryParams): ListUsersInputDTO { return new ListUsersInputDTO( page: isset($queryParams['page']) ? (int)$queryParams['page'] : 1, perPage: isset($queryParams['perPage']) ? (int)$queryParams['perPage'] : 20, search: isset($queryParams['search']) ? (string)$queryParams['search'] : null ); } }Passo a passo prático: UpdateUser (Command) com conflitos e regras
Atualizações costumam exigir: carregar o agregado, aplicar mudanças, validar invariantes e persistir. Conflitos comuns: e-mail já usado por outro usuário.
1) DTOs de Update
final class UpdateUserInputDTO { public function __construct( public readonly string $id, public readonly ?string $name, public readonly ?string $email ) {} } final class UpdateUserOutputDTO { public function __construct( public readonly string $id, public readonly string $name, public readonly string $email, public readonly string $updatedAt ) {} }2) Portas necessárias
interface UserRepository { public function getById(string $id): ?User; public function existsByEmail(string $email): bool; public function save(User $user): void; } final class UserNotFound extends
DomainException { public function __construct(string $id) { parent::__construct("Usu1rio n3o encontrado: {$id}"); } }3) Caso de uso UpdateUser
final class UpdateUser { public function __construct( private UserRepository $users, private Clock $clock ) {} public function execute(UpdateUserInputDTO $input): UpdateUserOutputDTO { $user = $this->users->getById($input->id); if (!$user) { throw new UserNotFound($input->id); } if ($input->name !== null) { if (trim($input->name) === '') { throw new
DomainException('Nome n3o pode ser vazio.'); } $user->name = $input->name; } if ($input->email !== null) { // Conflito: e-mail j1 cadastrado (idealmente: existsByEmailExcludingId) if ($input->email !== $user->email && $this->users->existsByEmail($input->email)) { throw new EmailAlreadyRegistered($input->email); } $user->email = $input->email; } $this->users->save($user); $updatedAt = $this->clock->now(); return new UpdateUserOutputDTO( id: $user->id, name: $user->name, email: $user->email, updatedAt: $updatedAt->format(DATE_ATOM) ); } }Se seu repositório suportar, prefira uma checagem mais precisa:
interface UserRepository { public function existsByEmailExcludingId(string $email, string $excludeId): bool; }Onde colocar validações: transporte vs negcio
| Tipo de valida73o | Exemplo | Onde deve ficar |
|---|---|---|
| Formato/sintaxe | email com formato inv1lido, campo ausente, tipo errado | Camada de transporte (HTTP) ou mapeadores/validador de entrada |
| Regras de negcio | e-mail anico, limite de perPage, nome obrigat3rio para criar | Casos de uso e/ou domdnio |
| Invariantes do domdnio | entidade n3o pode existir em estado inv1lido | Domdnio (entidade/VO) e refor7adas pelo caso de uso |
Mesmo que a camada HTTP valide campos obrigat3rios, o caso de uso deve proteger as regras essenciais (defesa em profundidade), pois ele pode ser chamado por outros meios (fila, CLI, testes, integra75es).
Tratamento de conflitos (ex.: e-mail j1 cadastrado) sem acoplar ao HTTP
O caso de uso sinaliza conflitos com exce75es de domdnio (ex.: EmailAlreadyRegistered). A camada HTTP n3o decide regra; ela apenas traduz o erro para uma resposta adequada (por exemplo, 409 Conflict) por meio do mecanismo centralizado de erros.
Uma pr1tica atil e9 manter um mapeamento entre exce75es de domdnio e c3digos HTTP em um ponto anico (handler/middleware), para que Actions n3o precisem conhecer detalhes de status code.
Checklist de implementa73o (para manter Actions enxutas)
- Crie um caso de uso por opera73o:
CreateUser,ListUsers,UpdateUser. - Defina InputDTO e OutputDTO para cada opera73o.
- Use mapeadores para converter HTTP → DTO e DTO → JSON.
- Coloque conflitos e regras de negcio no caso de uso (ex.: e-mail anico).
- N3o retorne entidades diretamente para fora do caso de uso; retorne DTOs.
- Para queries, prefira portas de leitura que retornem DTOs de listagem (mais leves).