DEV Community

Cover image for White-Label em Flutter: duas filosofias, três técnicas
Wesley Oliveira
Wesley Oliveira

Posted on

White-Label em Flutter: duas filosofias, três técnicas

Se você já precisou publicar o "mesmo app" para 5, 10 ou 20 marcas diferentes, já bateu de frente com a pergunta: onde mora a diferença entre um tenant e outro?

TL;DR

  • 🏗️ Flavors: build por tenant, isolamento total (applicationId, ícone), CI cresce com cada marca.
  • 📁 Pasta + script: mais simples que flavors, boa quando a diferença é só tema/assets.
  • 🌐 Config por endpoint: 1 build para todos, muda sem rebuild, mas depende de rede e de isolamento no backend.
  • Na prática, a maioria combina as três — veja o guia de decisão no fim.

Existem duas filosofias possíveis:

  • Build-time: cada tenant vira um build separado. A diferença é resolvida antes do app existir, em tempo de compilação.
  • Runtime: existe um único build. A diferença é resolvida depois que o app já está rodando, buscando configuração de fora.

Dentro da filosofia build-time cabem duas técnicas bem diferentes na prática — flavors e pasta por tenant com script de troca. E a filosofia runtime geralmente se resolve com configuração buscada de um endpoint. Este artigo cobre as três, com código, trade-offs reais e um guia de quando usar cada uma.

Não existe "a certa". Existe a certa para o seu número de tenants, sua frequência de mudança de marca e sua tolerância a builds manuais.


Visão geral rápida

Flavors Pasta por tenant + script Endpoint de config
Filosofia Build-time Build-time Runtime
Quantos builds/artefatos 1 por tenant 1 por tenant 1 (todos os tenants)
Precisa rebuild pra mudar cor/logo Sim Sim Não
Funciona offline Sim Sim Só com cache local
Novo tenant sem tocar em código Não Parcialmente Sim, se só muda tema/feature flag
Risco de configuração vazar entre tenants Baixo (isolado no bundle) Baixo (isolado no bundle) Precisa de cuidado no backend
Esforço de CI/CD Alto (N pipelines) Médio (1 pipeline + parâmetro) Baixo (1 pipeline)
Bom para Poucos tenants, diferenças profundas (ícone, nome do app, permissões nativas) Poucos a médios tenants, times pequenos, sem infra de backend dedicada Muitos tenants, mudanças de marca frequentes, mesma regra de negócio

Na prática, a maioria dos projetos maduros acaba combinando build-time para o que é fixo (application ID, nome do app, ícone, permissões nativas) com runtime para o que muda com frequência (cores, textos, feature flags). Mas vamos por partes.


1. Build-time: Flavors

Flavors (ou product flavors, no Android; schemes + configurations, no iOS) fazem o Flutter gerar builds distintos a partir da mesma base de código, cada um com seu próprio applicationId/bundle identifier, ícone, nome e ponto de entrada.

Estrutura

lib/
  main_tenant_a.dart
  main_tenant_b.dart
  main_common.dart        # bootstrap compartilhado
android/
  app/
    build.gradle           # flavorDimensions + productFlavors
ios/
  Runner.xcodeproj         # schemes + xcconfig por tenant
Enter fullscreen mode Exit fullscreen mode

Android — android/app/build.gradle

android {
    flavorDimensions "tenant"
    productFlavors {
        tenantA {
            dimension "tenant"
            applicationId "com.example.tenanta"
            resValue "string", "app_name", "Tenant A"
        }
        tenantB {
            dimension "tenant"
            applicationId "com.example.tenantb"
            resValue "string", "app_name", "Tenant B"
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

iOS

No iOS não existe "flavor" nativo — a prática comum é criar uma Configuration (Debug-TenantA, Release-TenantA, etc.) e um Scheme por tenant, cada um apontando para um .xcconfig próprio com PRODUCT_BUNDLE_IDENTIFIER, PRODUCT_NAME e o AppIcon correspondente.

Entry point compartilhado

// lib/main_common.dart
Future<void> bootstrap(TenantConfig config) async {
  runApp(MyApp(config: config));
}

// lib/main_tenant_a.dart
void main() {
  bootstrap(TenantConfig.tenantA);
}

// lib/main_tenant_b.dart
void main() {
  bootstrap(TenantConfig.tenantB);
}
Enter fullscreen mode Exit fullscreen mode

Rodando e buildando

flutter run --flavor tenantA -t lib/main_tenant_a.dart
flutter build apk --flavor tenantA -t lib/main_tenant_a.dart
flutter build ipa --flavor tenantA -t lib/main_tenant_a.dart
Enter fullscreen mode Exit fullscreen mode

Trade-offs

A favor:

  • Isolamento real. applicationId, ícone, nome e permissões nativas (AndroidManifest.xml, Info.plist) podem divergir de verdade entre tenants — coisa que uma configuração remota não alcança.
  • Cada tenant é um artefato de loja independente, o que é exigido quando cada marca precisa aparecer com seu próprio nome na App Store/Play Store.
  • Funciona 100% offline, porque tudo já está compilado no binário.

Contra:

  • CI/CD cresce linearmente com o número de tenants. Com 15 tenants, são 15 pipelines de build (ou 1 pipeline com matriz de 15 combinações).
  • Mudar uma cor ou um texto exige rebuild, novo upload e nova aprovação de loja.
  • Acima de ~10-15 tenants o build.gradle e os schemes do Xcode viram um arquivo difícil de manter.

Use quando: o número de tenants é baixo (tipicamente até 10-15), as diferenças vão além de tema (ícone, nome do app, application ID, permissões nativas) e builds separados por marca são um requisito de negócio, não um detalhe técnico.


2. Build-time: pasta por tenant + script de troca

Uma variação mais simples de manter que flavors quando a diferença entre tenants é majoritariamente assets e configuração, não applicationId nem permissões nativas.

Estrutura

tenants/
  tenant_a/
    assets/
      logo.png
      splash.png
    theme.json
  tenant_b/
    assets/
      logo.png
      splash.png
    theme.json
assets/
  active/            # gerado pelo script, git-ignored
    logo.png
    splash.png
    theme.json
Enter fullscreen mode Exit fullscreen mode

Script de troca

#!/usr/bin/env bash
# scripts/select_tenant.sh
set -euo pipefail

TENANT="${1:?Uso: select_tenant.sh <nome-do-tenant>}"
SRC="tenants/$TENANT"
DEST="assets/active"

if [ ! -d "$SRC" ]; then
  echo "Tenant '$TENANT' não encontrado em tenants/"
  exit 1
fi

rm -rf "$DEST"
mkdir -p "$DEST"
cp -R "$SRC/assets/." "$DEST/"
cp "$SRC/theme.json" "$DEST/theme.json"

echo "Tenant ativo: $TENANT"
Enter fullscreen mode Exit fullscreen mode
chmod +x scripts/select_tenant.sh
./scripts/select_tenant.sh tenant_a
flutter build apk
Enter fullscreen mode Exit fullscreen mode

pubspec.yaml

flutter:
  assets:
    - assets/active/
Enter fullscreen mode Exit fullscreen mode

Lendo o tema em runtime (mas ainda dentro do bundle)

class ThemeLoader {
  static Future<ThemeData> load() async {
    final raw = await rootBundle.loadString('assets/active/theme.json');
    final json = jsonDecode(raw) as Map<String, dynamic>;
    return ThemeData(
      primaryColor: Color(int.parse(json['primaryColor'] as String)),
      // ...
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Trade-offs

A favor:

  • Muito mais simples de configurar que flavors — sem tocar em Gradle nem em Xcode.
  • Um único pipeline de CI, parametrizado pelo nome do tenant.
  • Onboarding de tenant novo é criar uma pasta e preencher theme.json, sem escrever código.

Contra:

  • Ainda gera um binário por tenant — não resolve o problema de "1 app, N marcas" em runtime.
  • Se applicationId e permissões nativas também precisam mudar por tenant, o script sozinho não cobre; você acaba combinando com flavors de qualquer forma.
  • Fácil esquecer de rodar o script antes do build errado e publicar o tenant errado — vale um passo de CI que falha se assets/active estiver vazio ou não bater com o parâmetro do build.

Use quando: os tenants diferem principalmente em tema, logo e textos, o time é pequeno, e configurar Gradle flavors + Xcode schemes para cada marca nova é mais trabalho do que o problema justifica.


3. Runtime: configuração por endpoint

Aqui a filosofia muda: existe um único build, publicado uma vez, e o tenant é resolvido depois que o app já está instalado no dispositivo.

Como o app descobre "quem ele é"

Algumas estratégias comuns, sozinhas ou combinadas:

  • Domínio de login: o usuário digita empresa.suaplataforma.com ou seleciona a empresa numa tela inicial.
  • Deep link / QR code de convite: o link de instalação já carrega o tenant_id.
  • Login primeiro, tenant depois: o backend de autenticação retorna o tenant_id junto do token.

Buscando e aplicando a configuração

class TenantConfig {
  final String tenantId;
  final Color primaryColor;
  final String logoUrl;
  final Map<String, bool> features;

  TenantConfig({
    required this.tenantId,
    required this.primaryColor,
    required this.logoUrl,
    required this.features,
  });

  factory TenantConfig.fromJson(Map<String, dynamic> json) {
    return TenantConfig(
      tenantId: json['tenantId'] as String,
      primaryColor: Color(int.parse(json['primaryColor'] as String)),
      logoUrl: json['logoUrl'] as String,
      features: Map<String, bool>.from(json['features'] as Map),
    );
  }
}

class TenantRepository {
  final Dio _dio;
  final Box _cache; // Hive, por exemplo

  TenantRepository(this._dio, this._cache);

  Future<TenantConfig> resolve(String tenantId) async {
    try {
      final response = await _dio
          .get('/tenants/$tenantId/config')
          .timeout(const Duration(seconds: 5));
      final config = TenantConfig.fromJson(response.data);
      await _cache.put('tenant_config_$tenantId', response.data);
      return config;
    } catch (_) {
      // Sem rede ou timeout: cai para o último config salvo.
      final cached = _cache.get('tenant_config_$tenantId');
      if (cached != null) {
        return TenantConfig.fromJson(Map<String, dynamic>.from(cached));
      }
      // Sem cache e sem rede: fallback para um tema neutro,
      // nunca para o tema de outro tenant.
      return TenantConfig.fallback();
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
// Bootstrap
Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final tenantId = await TenantResolver.detect(); // domínio, deep link, etc.
  final config = await TenantRepository(dio, cacheBox).resolve(tenantId);
  runApp(MyApp(theme: buildTheme(config), features: config.features));
}
Enter fullscreen mode Exit fullscreen mode

Feature flags junto do tema

Já que a config vem de um endpoint, é natural embutir feature flags no mesmo payload:

{
  "tenantId": "tenant_a",
  "primaryColor": "0xFF2DD4BF",
  "logoUrl": "https://cdn.exemplo.com/tenant_a/logo.png",
  "features": {
    "pixEnabled": true,
    "cardVirtualEnabled": false,
    "biometricLogin": true
  }
}
Enter fullscreen mode Exit fullscreen mode
if (tenantConfig.features['pixEnabled'] == true) {
  return const PixButton();
}
Enter fullscreen mode Exit fullscreen mode

Trade-offs

A favor:

  • Um único build, uma única submissão de loja (quando o nome do app e o ícone não variam por marca).
  • Mudar cor, texto ou ligar/desligar uma feature não exige rebuild nem nova aprovação de loja — é uma alteração no backend.
  • Escala bem para dezenas de tenants sem custo de CI/CD adicional por tenant.

Contra:

  • Depende de rede na primeira execução (ou de um cache/fallback bem pensado para funcionar offline depois).
  • applicationId, nome do app e ícone na loja continuam sendo os mesmos para todo mundo — se cada marca precisa de um app com nome próprio na loja, essa técnica sozinha não resolve.
  • Superfície de erro nova: um bug no backend de config pode quebrar o tema de todos os tenants ao mesmo tempo, ou pior, vazar a configuração de um tenant para outro se o endpoint não isolar bem por tenant_id.
  • Exige uma peça de infraestrutura (o serviço de config) que as duas técnicas anteriores não exigem.

Use quando: o número de tenants é alto, a marca muda com frequência (rebranding, campanhas sazonais, testes A/B de tema), e o applicationId/nome na loja pode ser compartilhado entre tenants (por exemplo, um único app "guarda-chuva" com seleção de empresa no login).


Combinando as três (o caminho mais comum na prática)

Projetos com white label maduro raramente escolhem só uma técnica. Um padrão comum:

  1. Flavors resolvem o que é impossível de mudar em runtime: applicationId, nome do app na loja, ícone, permissões nativas — geralmente para um número pequeno de tenants "grandes" que exigem app próprio na loja.
  2. Dentro de cada flavor, configuração por endpoint resolve tema, textos e feature flags — porque mesmo o tenant que tem app próprio na loja quer poder trocar a cor do botão sem esperar aprovação da Apple.
  3. Pasta por tenant + script aparece como uma alternativa mais leve a flavors quando o requisito de applicationId próprio não existe, mas ainda se quer builds separados (por exemplo, para times menores que não querem manter uma infra de config remota).

Ou seja: build-time para o que é estrutural e raramente muda, runtime para o que é visual/comportamental e muda o tempo todo.


Guia de decisão rápido

  • Cada marca precisa aparecer com nome e ícone próprios na loja? → precisa de flavors (ou pasta + script), não tem como fugir disso só com endpoint.
  • Menos de ~10 tenants, sem infraestrutura de backend dedicada, diferenças são só visuais? → pasta por tenant + script.
  • Mais de ~15-20 tenants, marca muda com frequência, mesmo applicationId para todos é aceitável? → configuração por endpoint.
  • Tudo isso ao mesmo tempo? → flavors (ou pasta) para o estrutural, endpoint para o resto. É mais trabalho de arquitetura, mas é o que escala.

Fechando

Nenhuma das três técnicas é "melhor" isoladamente — cada uma resolve um tipo de variação diferente entre tenants. O erro mais comum é tentar forçar o endpoint de config a fazer o trabalho dos flavors (mudar applicationId em runtime é impossível) ou o contrário, usar flavors para algo que muda toda semana e devia estar num backend.

Se você já implementou white label em Flutter, queria saber: qual dessas técnicas você usou, e o que te fez trocar de uma para outra?


Trabalho como Flutter Sênior remoto, com Go como segunda stack. Se quiser trocar ideia sobre isso ou sobre outras arquiteturas de white label, me encontra no GitHub ou no LinkedIn.

Top comments (0)