Design do Projeto — Arquitetura da Plataforma SaaS ShopMetrics
Programar sem design é como construir uma casa sem planta — você só percebe que a fundação não é profunda o suficiente quando já chegou ao terceiro andar, momento em que não tem outra opção senão demolir e recomeçar.
1. O Que Você Vai Aprender
- Análise de Requisitos e User Stories: Alice (Administradora de Tenant)/Bob (Operações da Plataforma)/Charlie (Analista de Dados)
- Selecionando uma Arquitetura Multi-Tenant: Estratégias de Banco Compartilhado vs. Banco Dedicado
- Design ER de Banco de Dados: tenants/users/plans/subscriptions/shops/orders/analytics
- Design de Documentação API: Especificação OpenAPI 3.0 e Coleções Postman
- Seleção de Tecnologia: Laravel 11 / MySQL / Redis / S3 / WebSocket
2. Uma História Real de Construção de um Negócio SaaS do Zero
(1) Dor: Bob quer construir uma plataforma de analytics de e-commerce mas não sabe por onde começar
Bob dirige uma empresa de consultoria de dados de e-commerce com mais de 50 clientes. Os dados de cada cliente estão espalhados por oito plataformas de e-commerce, e Bob os agrega manualmente no Excel — gastando quatro horas por dia criando relatórios, que frequentemente estão cheios de erros. Alice, a gerente de operações de um desses clientes, diz: "Seria ótimo ter um dashboard que mostrasse todos os dados das lojas em tempo real." Charlie, o analista de dados de Bob, diz: "Precisamos de uma plataforma SaaS multi-tenant, mas como devemos projetar a arquitetura?"
(2) Abordagem Sistemática de Design
Comece com análise de requisitos → user stories → seleção de arquitetura → modelagem de dados → design de API → seleção de tecnologia. Cada passo tem entregáveis claros, e você segue a planta ao programar.
Análise de Requisitos → User Stories → Seleção de Arquitetura → Design ER → Design de API → Seleção de Tecnologia → Começar a Programar
(3) Resultado
Depois que Bob gastou duas semanas completando o design, sua eficiência de programação triplicou — porque cada requisito tinha uma interface clara e design de banco de dados, eliminando a necessidade de fazer alterações ao longo do caminho.
3. Análise de Requisitos e User Stories
(1) Três Tipos de Perfis de Usuário
| Papel | Nome | Necessidade Principal | Ações Típicas |
|---|---|---|---|
| Administradora de Tenant | Alice | Gerenciar sua própria loja e equipe | Criar lojas, convidar membros, ver dashboard |
| Operações da Plataforma | Bob | Gerenciar todos os tenants e cobranças | Aprovar tenants, gerenciar planos, ver estatísticas da plataforma |
| Analista de Dados | Charlie | Analisar dados de e-commerce e gerar relatórios | Criar relatórios, configurar alertas e exportar dados |
(2) User Stories
Como Alice (Admin de Tenant), eu quero:
- US-01: Adicionar uma nova loja de e-commerce para acompanhar suas métricas
- US-02: Convidar membros da equipe para que Charlie possa acessar analytics
- US-03: Ver um dashboard mostrando receita/pedidos em todas as minhas lojas
- US-04: Assinar um plano que se adapte ao meu número de lojas
- US-05: Exportar relatórios em formato CSV/Excel
- US-06: Configurar alertas quando a receita cair abaixo de um limite
Como Bob (Operador da Plataforma), eu quero:
- US-07: Gerenciar contas de tenants (criar/suspender/excluir)
- US-08: Definir planos de assinatura com diferentes limites de funcionalidades
- US-09: Ver métricas de toda a plataforma (total de tenants/MRR/ativações)
- US-10: Processar pagamentos de assinatura via Stripe
- US-11: Enviar notificações aos tenants sobre assinaturas expirando
Como Charlie (Analista de Dados), eu quero:
- US-12: Construir relatórios de analytics personalizados com intervalo de datas e filtros
- US-13: Comparar performance de lojas lado a lado
- US-14: Agendar geração automática de relatórios (diário/semanal/mensal)
- US-15: Receber notificações em tempo real quando eventos significativos ocorrerem
(1) ▶ Exemplo: Decompondo User Stories em Módulos Funcionais
// User stories → Mapeamento de módulos funcionais
return [
'Gerenciamento de Tenant' => [
'US-07: Gerenciar contas de tenants',
'US-01: Adicionar lojas de e-commerce',
'US-02: Convidar membros da equipe',
],
'Assinatura e Cobrança' => [
'US-04: Assinar um plano',
'US-08: Definir planos de assinatura',
'US-10: Processar pagamentos Stripe',
'US-11: Notificações de expiração',
],
'Analytics e Relatórios' => [
'US-03: Ver dashboard de receita',
'US-05: Exportar relatórios',
'US-12: Construir relatórios personalizados',
'US-13: Comparar performance de lojas',
'US-14: Agendar geração de relatórios',
],
'Alertas e Notificações' => [
'US-06: Alertas de queda de receita',
'US-15: Notificações de eventos em tempo real',
],
'Administração da Plataforma' => [
'US-09: Métricas de toda a plataforma',
],
];
Saída:
// Execução bem-sucedida
4. Selecionando uma Arquitetura Multitenant
(1) Três Estratégias de Multitenancy
| Estratégia | Nível de Isolamento | Custo | Complexidade | Casos de Uso |
|---|---|---|---|---|
| Banco de Dados Independente | Mais alto | Alto | Médio | Requisitos de Conformidade Financeira/Saúde |
| Banco Compartilhado + Schema Independente | Médio | Médio | Médio | Médio porte, com alguns requisitos de isolamento |
| Banco Compartilhado + Schema Compartilhado | Mais baixo | Baixo | Baixo | Maioria dos SaaS, isolamento por tenant_id |
(2) Decisão de Seleção do ShopMetrics
flowchart TD
A[Estratégia Multi-tenant] --> B{Requisito de Isolamento de Dados?}
B -->|Conformidade rigorosa| C[BD Separado por Tenant]
B -->|SaaS padrão| D{Quantidade de Tenants?}
D -->|< 100| E[BD Compartilhado + Schema Separado]
D -->|> 100| F[BD Compartilhado + Schema Compartilhado]
F --> G[tenant_id em cada linha]
G --> H[Global Scope com auto-filtro]
H --> I[Escolha do ShopMetrics ✅]
O ShopMetrics escolheu a estratégia de banco compartilhado + schema compartilhado:
- Expectativa de 500+ tenants; sensível a custos
- Não há requisitos de conformidade financeira; isolamento por tenant_id é suficiente
- Implementando Filtragem Automática no Global Scope do Laravel
(1) ▶ Exemplo: Implementação de Global Scope Multi-tenant
// app/Models/Traits/BelongsToTenant.php
trait BelongsToTenant
{
protected static function bootBelongsToTenant(): void
{
static::addGlobalScope('tenant', function (Builder $builder) {
$tenantId = Tenant::current()?->id;
if ($tenantId) {
$builder->where('tenant_id', $tenantId);
}
});
static::creating(function (Model $model) {
$tenantId = Tenant::current()?->id;
if ($tenantId && ! $model->isDirty('tenant_id')) {
$model->tenant_id = $tenantId;
}
});
}
}
// app/Models/Tenant.php
class Tenant extends Model
{
protected static Tenant $currentTenant;
public static function setCurrent(self $tenant): void
{
static::$currentTenant = $tenant;
}
public static function current(): ?self
{
return static::$currentTenant ?? null;
}
}
// app/Http/Middleware/SetTenantContext.php
class SetTenantContext
{
public function handle(Request $request, Closure $next): Response
{
if ($user = $request->user()) {
Tenant::setCurrent($user->tenant);
}
return $next($request);
}
}
Saída:
// Execução bem-sucedida
5. Design ER de Banco de Dados
(1) Relacionamentos de Entidades Principais
erDiagram
TENANT ||--o{ USER : "tem muitos"
TENANT ||--o{ SHOP : "tem muitos"
TENANT ||--|| SUBSCRIPTION : "tem um"
PLAN ||--o{ SUBSCRIPTION : "assinado por"
SHOP ||--o{ ORDER : "tem muitos"
SHOP ||--o{ PRODUCT : "tem muitos"
ORDER ||--|{ ORDER_ITEM : "contém"
ORDER_ITEM }o--|| PRODUCT : "referencia"
USER ||--o{ REPORT : "cria"
TENANT ||--o{ ALERT : "configura"
TENANT {
bigint id PK
string name
string slug UK
string domain
string status
timestamp created_at
}
USER {
bigint id PK
bigint tenant_id FK
string name
string email UK
string role
timestamp created_at
}
PLAN {
bigint id PK
string name
string slug UK
int shop_limit
int user_limit
int price_cents
string stripe_price_id
}
SUBSCRIPTION {
bigint id PK
bigint tenant_id FK
bigint plan_id FK
string stripe_id
string status
timestamp trial_ends_at
timestamp ends_at
}
SHOP {
bigint id PK
bigint tenant_id FK
string name
string platform
string external_id
string status
}
ORDER {
bigint id PK
bigint tenant_id FK
bigint shop_id FK
string external_id
string customer_email
int total_cents
string status
timestamp ordered_at
}
PRODUCT {
bigint id PK
bigint tenant_id FK
bigint shop_id FK
string name
string sku
int price_cents
}
ORDER_ITEM {
bigint id PK
bigint order_id FK
bigint product_id FK
int quantity
int unit_price_cents
}
REPORT {
bigint id PK
bigint tenant_id FK
bigint user_id FK
string type
string format
string status
string storage_path
timestamp generated_at
}
ALERT {
bigint id PK
bigint tenant_id FK
string type
string condition
string channel
boolean is_active
}
(2) Decisões Chave de Design
| Decisão | Escolha | Motivo |
|---|---|---|
| Armazenamento de Valores | int price_cents |
Evitar problemas de precisão de ponto flutuante |
| Isolamento de Tenant | tenant_id em cada tabela |
Estratégia de Schema Compartilhado |
| Status da Assinatura | Sincronização Stripe Webhook | Fonte Única de Dados (Stripe) |
| ID Externo | external_id UK por tenant |
Diferentes formatos de ID de plataformas de e-commerce |
| Soft Delete | Apenas User/Tenant | Não excluir pedidos/produtos; apenas alterar status |
(1) ▶ Exemplo: Definição dos Models Principais do ShopMetrics
// app/Models/Tenant.php
class Tenant extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = ['name', 'slug', 'domain', 'status'];
protected static function booted(): void
{
static::creating(function (self $tenant) {
$tenant->slug ??= Str::slug($tenant->name);
$tenant->domain ??= "{$tenant->slug}.shopmetrics.io";
});
}
public function users(): HasMany
{
return $this->hasMany(User::class);
}
public function shops(): HasMany
{
return $this->hasMany(Shop::class);
}
public function subscription(): HasOne
{
return $this->hasOne(Subscription::class)->ofMany([], fn ($q) => $q->orderByDesc('created_at'));
}
public function alerts(): HasMany
{
return $this->hasMany(Alert::class);
}
public function isActive(): bool
{
return $this->status === 'active' &&
$this->subscription?->stripe_status === 'active';
}
public function canAddShop(): bool
{
$limit = $this->subscription?->plan->shop_limit ?? 0;
return $this->shops()->count() < $limit;
}
}
// app/Models/Order.php
class Order extends Model
{
use BelongsToTenant, HasFactory;
protected $fillable = [
'tenant_id', 'shop_id', 'external_id',
'customer_email', 'total_cents', 'status', 'ordered_at',
];
protected $casts = [
'total_cents' => 'integer',
'ordered_at' => 'datetime',
];
public function shop(): BelongsTo
{
return $this->belongsTo(Shop::class);
}
public function items(): HasMany
{
return $this->hasMany(OrderItem::class);
}
public function getTotalDollarsAttribute(): float
{
return $this->total_cents / 100;
}
}
Saída:
// Execução bem-sucedida
6. Design de API
(1) Versionamento e Planejamento de Recursos API
| Recurso | Prefixo | Método | Descrição |
|---|---|---|---|
| Auth | /api/v1/auth |
POST login/logout/refresh | Autenticação |
| Tenants | /api/v1/tenants |
GET/PATCH current | Informações do Tenant |
| Shops | /api/v1/shops |
CRUD | Gerenciamento de Lojas |
| Orders | /api/v1/shops/{id}/orders |
GET/POST | Consulta de Pedidos |
| Products | /api/v1/shops/{id}/products |
GET/POST | Gerenciamento de Produtos |
| Dashboard | /api/v1/dashboard |
GET overview/top/revenue | Dashboard |
| Reports | /api/v1/reports |
POST generate/GET status | Relatórios |
| Alerts | /api/v1/alerts |
CRUD | Configuração de Alertas |
| Plans | /api/v1/plans |
GET list | Lista de Planos |
| Subscriptions | /api/v1/subscriptions |
POST/DELETE | Gerenciamento de Assinaturas |
(2) Especificação de Resposta API
{
"data": {
"id": 1,
"type": "shop",
"attributes": {
"name": "Loja Amazon da Alice",
"platform": "amazon",
"status": "active",
"created_at": "2024-01-15T10:00:00Z"
},
"relationships": {
"tenant": { "data": { "id": 1, "type": "tenant" } }
}
},
"meta": {
"request_id": "req_abc123",
"timestamp": "2024-03-15T14:30:00Z"
}
}
(1) ▶ Exemplo: Design de Rotas API do ShopMetrics
// routes/api.php
Route::prefix('v1')->group(function () {
// Público: Autenticação
Route::post('auth/login', [AuthController::class, 'login']);
Route::post('auth/register', [AuthController::class, 'register']);
// Rotas autenticadas
Route::middleware(['auth:sanctum', 'set-tenant-context'])->group(function () {
// Auth
Route::post('auth/logout', [AuthController::class, 'logout']);
Route::get('auth/me', [AuthController::class, 'me']);
// Tenant (tenant do usuário atual)
Route::get('tenant', [TenantController::class, 'show']);
Route::patch('tenant', [TenantController::class, 'update']);
// Lojas
Route::apiResource('shops', ShopController::class);
// Recursos aninhados sob lojas
Route::prefix('shops/{shop}')->group(function () {
Route::apiResource('orders', OrderController::class)->only(['index', 'show']);
Route::apiResource('products', ProductController::class);
});
// Dashboard
Route::prefix('dashboard')->group(function () {
Route::get('overview', [DashboardController::class, 'overview']);
Route::get('top-products', [DashboardController::class, 'topProducts']);
Route::get('revenue', [DashboardController::class, 'revenueChart']);
});
// Relatórios
Route::apiResource('reports', ReportController::class)->only(['index', 'store', 'show']);
Route::post('reports/{report}/download', [ReportController::class, 'download']);
// Alertas
Route::apiResource('alerts', AlertController::class);
// Assinatura
Route::get('plans', [PlanController::class, 'index']);
Route::post('subscriptions', [SubscriptionController::class, 'store']);
Route::get('subscription', [SubscriptionController::class, 'show']);
Route::delete('subscription', [SubscriptionController::class, 'cancel']);
// Gerenciamento de equipe (apenas tenant_owner)
Route::middleware('role:tenant_owner')->prefix('team')->group(function () {
Route::get('members', [TeamController::class, 'index']);
Route::post('invite', [TeamController::class, 'invite']);
Route::delete('members/{user}', [TeamController::class, 'remove']);
});
});
// Stripe Webhooks (sem auth)
Route::post('webhooks/stripe', [WebhookController::class, 'handleStripe']);
});
Saída:
// Execução bem-sucedida
7. Decisões de Seleção de Tecnologia
(1) Comparação e Seleção do Stack de Tecnologia
| Camada | Opção | Seleção | Motivo |
|---|---|---|---|
| Framework | Laravel/Symfony/Lumen | Laravel 11 | Completo, ecossistema rico, desenvolvimento SaaS rápido |
| Banco de Dados | MySQL/PostgreSQL | MySQL 8.0 | Familiar para a equipe, padrão do Laravel, suporta JSON |
| Cache | Redis/Memcached | Redis 7 | Cache, Sessions, Filas e Broadcasting unificados |
| Armazenamento | S3/MinIO/Local | S3 | Escalável, integração CDN, MinIO para desenvolvimento |
| Fila | Redis/Database/SQS | Redis | Baixa latência, ambientes de desenvolvimento e produção unificados |
| Autenticação | Sanctum/Passport | Sanctum | Autenticação Token SPA + Mobile é suficiente |
| Tempo real | Pusher/Soketi | Soketi | Compatível com protocolo Pusher; auto-hospedado sem custos |
| Front-end | Blade/Inertia/Livewire | Inertia + Vue | Experiência SPA + Roteamento Server-Side |
(2) Visão Geral da Arquitetura
flowchart TB
subgraph Client["Camada de Cliente"]
WEB[Web SPA - Inertia/Vue]
MOBILE[App Mobile]
API_CLIENT[Consumidores API]
end
subgraph LB["Load Balancer - Nginx"]
direction LR
direction TB
end
subgraph App["Camada de Aplicação"]
API[Servidor API - Laravel]
WS[WebSocket - Soketi]
end
subgraph Worker["Processamento em Segundo Plano"]
QUEUE[Workers de Fila]
SCHED[Agendador]
end
subgraph Data["Camada de Dados"]
DB[(MySQL 8.0)]
CACHE[(Redis 7)]
S3[S3/MinIO]
end
subgraph External["Serviços Externos"]
STRIPE[Stripe API]
SHOPS[APIs de E-commerce]
MAIL[Serviço de Email]
end
WEB --> LB
MOBILE --> LB
API_CLIENT --> LB
LB --> API
WEB --> WS
API --> DB
API --> CACHE
API --> S3
API --> STRIPE
API --> SHOPS
API --> QUEUE
API --> WS
QUEUE --> DB
QUEUE --> CACHE
QUEUE --> S3
QUEUE --> MAIL
QUEUE --> STRIPE
SCHED --> QUEUE
(1) ▶ Exemplo: Perfil de Configuração de Seleção de Tecnologia do ShopMetrics
// config/shopmetrics.php
return [
'tenant' => [
'strategy' => env('TENANT_STRATEGY', 'shared_schema'),
'default_plan' => env('DEFAULT_PLAN_SLUG', 'starter'),
'trial_days' => env('TRIAL_DAYS', 14),
],
'limits' => [
'starter' => ['shops' => 3, 'users' => 5, 'reports_per_month' => 10],
'pro' => ['shops' => 25, 'users' => 25, 'reports_per_month' => 100],
'enterprise' => ['shops' => -1, 'users' => -1, 'reports_per_month' => -1],
],
'reports' => [
'max_date_range_days' => 365,
'formats' => ['csv', 'xlsx', 'json'],
'storage_disk' => env('REPORT_DISK', 's3'),
'retention_days' => env('REPORT_RETENTION_DAYS', 90),
],
'sync' => [
'platforms' => ['amazon', 'shopify', 'ebay', 'etsy'],
'sync_interval_minutes' => env('SYNC_INTERVAL', 60),
'batch_size' => env('SYNC_BATCH_SIZE', 500),
],
'alerts' => [
'channels' => ['email', 'slack', 'webhook'],
'check_interval_minutes' => env('ALERT_CHECK_INTERVAL', 15),
],
];
Saída:
// Execução bem-sucedida
8. Exemplo Compreensivo: Inicializando o Projeto ShopMetrics
// ============================================
// Compreensivo: Scaffold do projeto e configuração inicial
// Migrations do banco de dados, models e config
// ============================================
// database/migrations/2024_01_01_000001_create_tenants_table.php
Schema::create('tenants', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->string('domain')->unique();
$table->string('status')->default('active'); // active/suspended/cancelled
$table->softDeletes();
$table->timestamps();
$table->index('status');
});
// database/migrations/2024_01_01_000002_create_plans_table.php
Schema::create('plans', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->integer('shop_limit')->default(3);
$table->integer('user_limit')->default(5);
$table->integer('price_cents')->default(0);
$table->string('stripe_price_id')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
});
// database/migrations/2024_01_01_000003_create_subscriptions_table.php
Schema::create('subscriptions', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->foreignId('plan_id')->constrained();
$table->string('stripe_id')->unique();
$table->string('stripe_status');
$table->timestamp('trial_ends_at')->nullable();
$table->timestamp('ends_at')->nullable();
$table->timestamps();
$table->index(['tenant_id', 'stripe_status']);
});
// database/migrations/2024_01_01_000004_create_shops_table.php
Schema::create('shops', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->string('platform'); // amazon/shopify/ebay/etsy
$table->string('external_id');
$table->string('status')->default('active');
$table->json('metadata')->nullable();
$table->timestamps();
$table->unique(['tenant_id', 'platform', 'external_id']);
$table->index(['tenant_id', 'status']);
});
// database/migrations/2024_01_01_000005_create_orders_table.php
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->foreignId('shop_id')->constrained()->cascadeOnDelete();
$table->string('external_id');
$table->string('customer_email')->nullable();
$table->unsignedBigInteger('total_cents')->default(0);
$table->string('status')->default('pending');
$table->timestamp('ordered_at');
$table->timestamps();
$table->unique(['tenant_id', 'external_id']);
$table->index(['tenant_id', 'shop_id', 'status', 'ordered_at']);
$table->index(['tenant_id', 'ordered_at']);
});
❓ Perguntas Frequentes
tenant_id; a camada API valida o tenant ao qual o usuário atual pertence; e SQL bruto é impedido de bypassar o Scope. Para isolamento superior (ex.: financeiro ou saúde), use a estratégia de banco de dados separado.Alert pode pertencer a um Shop ou a um Tenant, então associações polimórficas podem ser consideradas. No entanto, se o tipo de associação é fixo, usar uma chave estrangeira proporciona maior clareza./api/v1/). Quando v1 e v2 coexistem, as rotas v2 são tratadas em um arquivo separado, enquanto as camadas Model e Service são compartilhadas. Um novo número de versão é atribuído apenas para upgrades de versão principal; mudanças menores são implementadas de forma retrocompatível.tenant_id e criar um script de migração de dados. Ao migrar de single-tenant para multi-tenant, use um comando Artisan para atribuir valores tenant_id em lote; após o processamento, adicione uma restrição NOT NULL.📖 Resumo
- User stories são decompostas em módulos funcionais com base em três tipos de personagens (Alice, Bob e Charlie)
- Banco compartilhado e schema compartilhado são a melhor escolha para a maioria das aplicações SaaS
- Design ER principal: isolamento tenant_id, valores em cents, restrição única no ID externo
- Design de API segue a arquitetura RESTful, usa prefixos de versão e emprega formato de resposta unificado
- Na seleção de tecnologias, prioridade é dada às capacidades da equipe e à maturidade do ecossistema
- Inicialização do projeto começa com o arquivo de migration, estabelecendo a fundação do modelo de dados
📝 Exercícios
-
Exercício Básico (⭐): Com base no diagrama ER, escreva os arquivos de schema para as três tabelas restantes do ShopMetrics (products, order_items, alerts), incluindo os índices e chaves estrangeiras necessários.
-
Exercício Avançado (⭐⭐): Usando o formato OpenAPI 3.0 YAML, escreva a documentação da API de Orders do ShopMetrics (GET lista / GET detalhe / POST sync), incluindo parâmetros de requisição, formatos de resposta e códigos de erro.
-
Desafio (⭐⭐⭐): Design uma solução completa de middleware multi-tenant para o ShopMetrics — implemente o middleware
SetTenantContext, a traitBelongsToTenante use parâmetros de caminho (subdomínio) para identificar tenants. Escreva testes para verificar que o Tenant A não pode acessar dados do Tenant B.



