Artigo 50 — Projeto Final Parte 1: Planejamento e Arquitetura
Módulo 11 · Semana 50 · Nível: Avançado
Introdução
Chegamos ao projeto final. Quarenta e nove artigos de fundamentos, orientação a objetos, bancos de dados, web, segurança, frameworks, testes e deploy. Agora é hora de integrar tudo isso em um sistema real, coeso e profissional.
O projeto é uma plataforma de e-commerce chamada Mercatto — simples o suficiente para ser implementado em três semanas, complexo o suficiente para exercitar todos os conceitos aprendidos. Não vamos construir um sistema com todas as funcionalidades imagináveis. Vamos construir um sistema bem feito, com as funcionalidades certas, do jeito certo.
Escopo do sistema
O que o Mercatto faz:
- Catálogo de produtos com categorias, busca e filtros
- Carrinho de compras persistido em sessão
- Checkout com endereço de entrega
- Processamento de pedidos com status
- Painel do cliente: pedidos, histórico, perfil
- Painel administrativo: produtos, pedidos, relatórios
- API REST para integração com apps mobile
- Notificações por email em eventos importantes
O que deliberadamente não está no escopo: - Integração com gateway de pagamento real (simulamos) - Sistema de avaliações de produtos - Multi-tenant / múltiplos vendedores - Internacionalização
Escopo claro é tão importante quanto o código. Um projeto sem fronteiras definidas nunca termina.
Arquitetura — decisões e justificativas
Antes de escrever uma linha de código, tomamos decisões arquiteturais. Cada decisão tem uma justificativa — não escolhemos tecnologias por modismo, mas por adequação ao problema.
Arquitetura em camadas:
┌─────────────────────────────────────────────┐
│ HTTP Layer │
│ Routes → Middleware → Controllers │
├─────────────────────────────────────────────┤
│ Application Layer │
│ Form Requests → Services → Jobs │
├─────────────────────────────────────────────┤
│ Domain Layer │
│ Models → Value Objects → Domain Events │
├─────────────────────────────────────────────┤
│ Infrastructure Layer │
│ Eloquent Repositories → Cache → Queue │
└─────────────────────────────────────────────┘
Essa separação garante que a lógica de negócio (Domain) não depende de como os dados são persistidos (Infrastructure) nem de como chegam ao sistema (HTTP). Você troca o banco de dados sem tocar nas regras de negócio. Testa as regras sem subir o servidor HTTP.
Stack tecnológico:
| Camada | Tecnologia | Justificativa |
|---|---|---|
| Framework | Laravel 11 | Produtividade, ecossistema, convenções sólidas |
| Banco de dados | PostgreSQL 16 | JSONB para atributos de produto, confiabilidade |
| Cache | Redis 7 | Cache de queries, sessões, filas |
| Filas | Laravel Queue + Redis | Emails e processamento assíncrono |
| Testes | Pest + SQLite in-memory | Velocidade e isolamento |
| Deploy | Docker + GitHub Actions | Reprodutibilidade e automação |
Modelagem do banco de dados
-- Schema completo do Mercatto
CREATE TABLE categorias (
id SERIAL PRIMARY KEY,
nome VARCHAR(100) NOT NULL,
slug VARCHAR(120) NOT NULL UNIQUE,
descricao TEXT,
ativa BOOLEAN NOT NULL DEFAULT TRUE,
criada_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
atualizada_em TIMESTAMPTZ
);
CREATE TABLE produtos (
id SERIAL PRIMARY KEY,
categoria_id INTEGER NOT NULL REFERENCES categorias(id),
nome VARCHAR(200) NOT NULL,
slug VARCHAR(220) NOT NULL UNIQUE,
descricao TEXT,
preco NUMERIC(10,2) NOT NULL CHECK (preco >= 0),
preco_promocional NUMERIC(10,2) CHECK (preco_promocional >= 0),
estoque INTEGER NOT NULL DEFAULT 0 CHECK (estoque >= 0),
ativo BOOLEAN NOT NULL DEFAULT TRUE,
atributos JSONB, -- cor, tamanho, material, etc.
imagens JSONB, -- array de paths
criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
atualizado_em TIMESTAMPTZ,
deleted_at TIMESTAMPTZ -- soft delete
);
CREATE TABLE usuarios (
id SERIAL PRIMARY KEY,
nome VARCHAR(150) NOT NULL,
email VARCHAR(200) NOT NULL UNIQUE,
senha_hash VARCHAR(255) NOT NULL,
cpf_cript TEXT, -- AES-256-GCM
telefone_cript TEXT,
role VARCHAR(20) NOT NULL DEFAULT 'cliente',
ativo BOOLEAN NOT NULL DEFAULT TRUE,
criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE enderecos (
id SERIAL PRIMARY KEY,
usuario_id INTEGER NOT NULL REFERENCES usuarios(id),
rotulo VARCHAR(50) NOT NULL DEFAULT 'Casa',
cep VARCHAR(9) NOT NULL,
logradouro VARCHAR(200) NOT NULL,
numero VARCHAR(20) NOT NULL,
complemento VARCHAR(100),
bairro VARCHAR(100) NOT NULL,
cidade VARCHAR(100) NOT NULL,
estado CHAR(2) NOT NULL,
principal BOOLEAN NOT NULL DEFAULT FALSE,
criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE pedidos (
id SERIAL PRIMARY KEY,
usuario_id INTEGER NOT NULL REFERENCES usuarios(id),
endereco_id INTEGER NOT NULL REFERENCES enderecos(id),
status VARCHAR(30) NOT NULL DEFAULT 'pendente',
subtotal NUMERIC(10,2) NOT NULL,
desconto NUMERIC(10,2) NOT NULL DEFAULT 0,
frete NUMERIC(10,2) NOT NULL DEFAULT 0,
total NUMERIC(10,2) NOT NULL,
observacoes TEXT,
criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
atualizado_em TIMESTAMPTZ,
CONSTRAINT status_valido CHECK (
status IN ('pendente','confirmado','em_preparo','enviado','entregue','cancelado')
)
);
CREATE TABLE pedido_itens (
id SERIAL PRIMARY KEY,
pedido_id INTEGER NOT NULL REFERENCES pedidos(id) ON DELETE CASCADE,
produto_id INTEGER NOT NULL REFERENCES produtos(id),
nome_produto VARCHAR(200) NOT NULL, -- snapshot do nome no momento da compra
preco_unitario NUMERIC(10,2) NOT NULL, -- snapshot do preço
quantidade INTEGER NOT NULL CHECK (quantidade > 0),
subtotal NUMERIC(10,2) NOT NULL
);
CREATE TABLE personal_access_tokens (
id BIGSERIAL PRIMARY KEY,
tokenable_type VARCHAR(255) NOT NULL,
tokenable_id BIGINT NOT NULL,
name VARCHAR(255) NOT NULL,
token VARCHAR(64) NOT NULL UNIQUE,
abilities TEXT,
last_used_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ
);
-- Índices de performance
CREATE INDEX idx_produtos_categoria ON produtos(categoria_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_produtos_ativo ON produtos(ativo, estoque) WHERE deleted_at IS NULL;
CREATE INDEX idx_produtos_slug ON produtos(slug);
CREATE INDEX idx_pedidos_usuario ON pedidos(usuario_id);
CREATE INDEX idx_pedidos_status ON pedidos(status, criado_em DESC);
CREATE INDEX idx_produtos_atributos ON produtos USING gin(atributos);
Estrutura de diretórios do projeto
mercatto/
├── app/
│ ├── Domain/
│ │ ├── Carrinho/
│ │ │ ├── Carrinho.php
│ │ │ ├── ItemDoCarrinho.php
│ │ │ └── CarrinhoService.php
│ │ ├── Pedido/
│ │ │ ├── Pedido.php
│ │ │ ├── StatusDoPedido.php ← Enum
│ │ │ └── PedidoService.php
│ │ └── Produto/
│ │ ├── Produto.php
│ │ └── Dinheiro.php ← Value Object
│ ├── Http/
│ │ ├── Controllers/
│ │ │ ├── Web/
│ │ │ │ ├── CatalogoController.php
│ │ │ │ ├── CarrinhoController.php
│ │ │ │ └── CheckoutController.php
│ │ │ ├── Admin/
│ │ │ │ ├── ProdutoController.php
│ │ │ │ └── PedidoController.php
│ │ │ └── Api/
│ │ │ ├── AuthController.php
│ │ │ └── ProdutoController.php
│ │ ├── Middleware/
│ │ └── Requests/
│ ├── Jobs/
│ │ ├── ProcessarPedido.php
│ │ └── EnviarEmailDePedido.php
│ ├── Models/
│ ├── Providers/
│ └── Services/
├── database/migrations/
├── resources/views/
│ ├── layouts/
│ ├── catalogo/
│ ├── carrinho/
│ ├── checkout/
│ ├── pedidos/
│ └── admin/
├── routes/
│ ├── web.php
│ ├── admin.php
│ └── api.php
└── tests/
├── Unit/
│ ├── Domain/
│ └── Services/
└── Feature/
├── Web/
└── Api/
Value Objects e Enums do domínio
<?php
declare(strict_types=1);
namespace App\Domain\Produto;
final readonly class Dinheiro
{
public function __construct(private int $centavos)
{
if ($centavos < 0) {
throw new \InvalidArgumentException('Valor monetário não pode ser negativo.');
}
}
public static function dereais(float $reais): self
{
return new self((int) round($reais * 100));
}
public function centavos(): int { return $this->centavos; }
public function reais(): float { return $this->centavos / 100; }
public function somar(self $outro): self
{
return new self($this->centavos + $outro->centavos);
}
public function subtrair(self $outro): self
{
if ($outro->centavos > $this->centavos) {
throw new \DomainException('Subtração resultaria em valor negativo.');
}
return new self($this->centavos - $outro->centavos);
}
public function multiplicar(int $fator): self
{
return new self($this->centavos * $fator);
}
public function aplicarDesconto(float $percentual): self
{
if ($percentual < 0 || $percentual > 100) {
throw new \InvalidArgumentException("Percentual inválido: {$percentual}");
}
$desconto = (int) round($this->centavos * $percentual / 100);
return new self($this->centavos - $desconto);
}
public function formatar(): string
{
return 'R$ ' . number_format($this->reais(), 2, ',', '.');
}
public function equals(self $outro): bool
{
return $this->centavos === $outro->centavos;
}
public function maiorQue(self $outro): bool
{
return $this->centavos > $outro->centavos;
}
}
<?php
declare(strict_types=1);
namespace App\Domain\Pedido;
enum StatusDoPedido: string
{
case Pendente = 'pendente';
case Confirmado = 'confirmado';
case EmPreparo = 'em_preparo';
case Enviado = 'enviado';
case Entregue = 'entregue';
case Cancelado = 'cancelado';
public function label(): string
{
return match($this) {
self::Pendente => 'Aguardando confirmação',
self::Confirmado => 'Confirmado',
self::EmPreparo => 'Em preparação',
self::Enviado => 'Enviado',
self::Entregue => 'Entregue',
self::Cancelado => 'Cancelado',
};
}
public function cor(): string
{
return match($this) {
self::Pendente => 'amarelo',
self::Confirmado => 'azul',
self::EmPreparo => 'laranja',
self::Enviado => 'roxo',
self::Entregue => 'verde',
self::Cancelado => 'vermelho',
};
}
public function podeTransicionarPara(self $proximo): bool
{
return match($this) {
self::Pendente => in_array($proximo, [self::Confirmado, self::Cancelado]),
self::Confirmado => in_array($proximo, [self::EmPreparo, self::Cancelado]),
self::EmPreparo => in_array($proximo, [self::Enviado, self::Cancelado]),
self::Enviado => $proximo === self::Entregue,
self::Entregue,
self::Cancelado => false, // estados finais
};
}
}
Carrinho de compras — lógica de domínio
<?php
declare(strict_types=1);
namespace App\Domain\Carrinho;
use App\Domain\Produto\Dinheiro;
final class ItemDoCarrinho
{
public function __construct(
public readonly int $produtoId,
public readonly string $nomeProduto,
public readonly Dinheiro $precoUnitario,
private int $quantidade,
) {
if ($quantidade <= 0) {
throw new \InvalidArgumentException('Quantidade deve ser maior que zero.');
}
}
public function quantidade(): int { return $this->quantidade; }
public function subtotal(): Dinheiro
{
return $this->precoUnitario->multiplicar($this->quantidade);
}
public function incrementar(int $qtd = 1): self
{
$novo = clone $this;
$novo->quantidade += $qtd;
return $novo;
}
public function comQuantidade(int $quantidade): self
{
$novo = clone $this;
$novo->quantidade = $quantidade;
return $novo;
}
}
final class Carrinho
{
/** @var array<int, ItemDoCarrinho> */
private array $itens = [];
public function adicionar(ItemDoCarrinho $novoItem): void
{
if (isset($this->itens[$novoItem->produtoId])) {
$this->itens[$novoItem->produtoId] = $this->itens[$novoItem->produtoId]
->incrementar($novoItem->quantidade());
} else {
$this->itens[$novoItem->produtoId] = $novoItem;
}
}
public function atualizar(int $produtoId, int $quantidade): void
{
if (!isset($this->itens[$produtoId])) {
throw new \DomainException("Produto {$produtoId} não está no carrinho.");
}
if ($quantidade <= 0) {
$this->remover($produtoId);
return;
}
$this->itens[$produtoId] = $this->itens[$produtoId]->comQuantidade($quantidade);
}
public function remover(int $produtoId): void
{
unset($this->itens[$produtoId]);
}
public function limpar(): void
{
$this->itens = [];
}
public function total(): Dinheiro
{
return array_reduce(
$this->itens,
fn(Dinheiro $acc, ItemDoCarrinho $item) => $acc->somar($item->subtotal()),
new Dinheiro(0),
);
}
public function quantidadeDeItens(): int
{
return array_sum(array_map(fn($i) => $i->quantidade(), $this->itens));
}
public function estaVazio(): bool { return empty($this->itens); }
/** @return array<int, ItemDoCarrinho> */
public function itens(): array { return $this->itens; }
}
Exercício da semana
-
Configure o projeto Laravel com PostgreSQL, Redis e as migrations do schema acima. Execute
php artisan migratecom sucesso. -
Implemente
DinheiroeStatusDoPedidoexatamente como no artigo. Escreva testes unitários Pest cobrindo todos os métodos, incluindopodeTransicionarPara()com todas as combinações de estados. -
Implemente
CarrinhoeItemDoCarrinhocom testes: adicionar item novo, adicionar item existente (incrementa), atualizar quantidade, remover, calcular total com múltiplos itens, comportamento ao atualizar com quantidade zero. -
Escreva os Service Providers do projeto: bind de todos os repositórios, configuração do Redis, configuração dos canais de log.
-
Desafio: implemente
CarrinhoServiceque persiste oCarrinhona sessão do Laravel (serializa para JSON, desserializa ao carregar). Garanta que o carrinho sobrevive entre requisições e que itens com estoque zerado são removidos automaticamente ao carregar.
Referências
- Laravel Application Structure: https://laravel.com/docs/structure
- Domain-Driven Design (livro) — Eric Evans: https://www.amazon.com.br/Domain-Driven-Design-Eric-Evans/dp/0321125215
- PostgreSQL Schema Design: https://www.postgresql.org/docs/current/ddl.html