Modularização

A partir da release de 2026, o boilerplate blazorpost deixou de ser um projeto único e passou a ter uma arquitetura modular, na qual a fronteira entre a plataforma (genérica, mantida pela GTI/SDSS) e o domínio de negócio (específico de cada solução) é garantida pelo compilador, e não por convenção de pasta.

Na prática: se o código de um módulo tentar enxergar um tipo interno do host, o resultado é um erro de compilação (CS0246), e não uma revisão de código esquecida.

Índice
  1. Modularização
    1. Por que modularizar
    2. Arquitetura
      1. Direção de dependência
      2. O único ponto do host que conhece um módulo
    3. Decisões arquiteturais (ADs)
  2. Guia do desenvolvedor
    1. 1. Como crio um módulo novo?
    2. 2. Quais artefatos compõem um módulo?
    3. 3. Como configuro as dependências do módulo?
    4. 4. Como registro os serviços (DI)?
    5. 5. Como configuro os itens de menu?
    6. 6. Como configuro perfis e permissões?
    7. 7. Como crio o banco individualizado do módulo?
    8. 8. Como entrego a configuração do módulo?
    9. 9. Como faço i18n dentro do módulo?
    10. 10. Como defino rotas e redirects?
    11. 11. Como me comunico com outro módulo?
    12. 12. Como consumo dado da plataforma ou dado corporativo?
    13. 13. Como contribuo notificações ao sino?
    14. 14. O que um módulo não pode fazer?
    15. 15. Como valido antes de commitar?
    16. Defesas em runtime
    17. Sincronizando o derivado com a base
    18. Lições da implantação

Por que modularizar

O blazorpost é o boilerplate canônico da Embrapa: as soluções institucionais nascem a partir dele e, ao longo do tempo, precisam receber as correções e melhorias feitas na base. Quando o domínio de negócio e a plataforma vivem no mesmo assembly, não existe merge limpo entre a base e o derivado — só cherry-pick manual, arriscado e caro.

A modularização resolve exatamente isso:

  • Para a base: tudo que é genérico (Identity, autenticação, menu, notificações, auditoria, gateways corporativos) fica isolado e pode ser evoluído sem carregar código de negócio junto.
  • Para o derivado: o domínio inteiro vive numa biblioteca própria, com uma lista taxativa de pontos de edição sobre os arquivos da base — o que torna a sincronização com o upstream um merge previsível.

A iniciativa que originou esta estrutura foi executada no projeto Portal Gestão Jurídica e depois portada para o blazorpost. Por isso, comentários no código citam identificadores como AD-n (decisão de arquitetura) e story n.m — são apenas rastreabilidade; a justificativa relevante está sempre no próprio comentário.


Arquitetura

A solution (Boilerplate4Dev.slnx) tem três projetos na base, mais duas pastas reservadas para o derivado:

Boilerplate4Dev.csproj                          # host (Blazor Server): páginas, Identity,
                                                # admin transversal, ModuleRegistry
Core/Boilerplate4Dev.Core.csproj                # helpers e models institucionais, auditoria
                                                # centralizada, contratos e pontos de extensão
Integracoes/Boilerplate4Dev.Integracoes.csproj  # gateways institucionais (MongoDB corporativo,
                                                # foto de empregado) — único dono de IMongoClient

Modulos/<Modulo>/                               # (derivado) RCL de cada módulo de negócio
Contracts/                                      # (derivado) contrato módulo ↔ módulo

Direção de dependência

A regra fundamental é que as setas só apontam para dentro:

Direção de dependência entre os projetos Fig. 1 - Arquitetura alvo: o host referencia todos; o Core não referencia ninguém.

Em forma de tabela:

Projeto Referencia
Boilerplate4Dev (host) Core, Integracoes e as RCLs dos módulos
Integracoes só Core
Modulos/<Modulo> (RCL) só Core (e Contracts, se houver contrato entre módulos)
Core nada — nenhum ProjectReference
Contracts nada — nem ProjectReference, nem PackageReference

Consequência prática: a RCL de um módulo não tem como enxergar ApplicationDbContext, Data/Security/ ou Data/Services/ do host. Uma violação vira erro de compilação.

O Contracts não referencia nem o Core de propósito: o Core expõe Microsoft.EntityFrameworkCore na sua API pública (por causa do AuditedDbContextBase), e referenciá-lo tornaria o EF alcançável por transitividade dentro dos contratos. Sem referência nenhuma, uma entidade EF num contrato vira CS0246 em vez de uma transitividade despercebida.

O único ponto do host que conhece um módulo

// ModuleRegistry.cs
public static IReadOnlyList<IModuleDefinition> Modulos { get; } = [ new MeuModuloModule() ];

A partir daí, o host descobre tudo genericamente: DI, seed de papéis e permissões, policies, health checks, itens de menu, notificações, rotas, redirects, configuração e i18n. A ordem da lista é não-significativa: todo mecanismo que a itera produz o mesmo resultado sob qualquer permutação.


Decisões arquiteturais (ADs)

# Decisão
AD-1 Direção de dependência única — as setas só apontam para dentro
AD-2 Módulo pluga por IModuleDefinition; rota prefixada
AD-3 Menu por contribuição (IMenuContributor)
AD-4 Notificação por contribuição (o sino agrega os providers)
AD-5 Módulo ↔ módulo só por contrato em Contracts/
AD-6 Eventos in-process pós-commit, at-most-once
AD-7 Schema, conexão e usuário de banco próprios por módulo
AD-8 Sem query cross-schema, sem transação cruzada
AD-9 Dado corporativo só via gateway do Core
AD-10 Auditoria central obrigatória, mapeamento único
AD-11 Papéis e permissões namespaced, catálogo no módulo
AD-12 Naming: Boilerplate4Dev.* = base · namespace do derivado = domínio
AD-13 i18n embarcada na RCL, paridade nos 3 idiomas
AD-14 A aplicação nunca executa DDL
AD-15 Config nomeada e entregue pelo próprio módulo
AD-16 Versões de pacote centralizadas (Directory.Packages.props)
AD-17 Um único deployable — modularidade é fronteira de build, não de deploy
AD-18 O contrato carrega a autorização (recebe ClaimsPrincipal)
AD-19 Dado de plataforma só por contrato do Core

Duas valem destaque por serem contraintuitivas:

  • AD-17 — um deployable. Modularizar não fragmenta o deploy: a solution continua publicando um aplicativo Blazor Server. A fronteira é de build.
  • AD-14 — a aplicação nunca executa DDL. Não há EF Migrations. O schema é versionado em scripts/postgres/*.sql, com a ordem de execução fixada no docker/Dockerfile.postgres.

Guia do desenvolvedor

As respostas a seguir refletem o código do blazorpost como ele está hoje. Onde o exemplo usa MeuModulo / MeuPortal, substitua pelo nome do seu módulo e pelo namespace raiz do seu projeto.

1. Como crio um módulo novo?

A ordem abaixo é a real: cada passo depende de o anterior compilar.

  1. RCL em Modulos/<Modulo>/MeuPortal.<Modulo>.csproj (Microsoft.NET.Sdk.Razor), com RootNamespace e AssemblyName fixados. Referencia só o Core (e Contracts, se precisar).
  2. Catálogos: Papeis<Modulo>.cs e Permissoes<Modulo>.cs, com constantes prefixadas (<Modulo>.<Recurso>.<Acao>).
  3. <Modulo>Module : IModuleDefinition — nome, assembly, ConfigureServices, Papeis, MatrizPermissoes, SeedAsync.
  4. Banco: Data/<Modulo>DbContext : AuditedDbContextBase, com schema próprio e DDL em scripts/postgres/<modulo>.sql.
  5. Config: appsettings.<Modulo>.json + placeholders.env como EmbeddedResource.
  6. i18n: Locales/{pt-BR,en-US,es}.json como EmbeddedResource.
  7. Páginas em Pages/, com rota /<modulo>/*; menu via IMenuContributor.

Só então, no host — três toques, nenhum de lógica:

  • ModuleRegistry.Modulos → adicionar new <Modulo>Module();
  • Boilerplate4Dev.csproj → ProjectReference para a RCL (Modulos\** já está no DefaultItemExcludes);
  • Boilerplate4Dev.slnx → entrada do projeto.

E fora do código: um COPY do novo .csproj no docker/Dockerfile antes do dotnet restore; o DDL do schema com prefixo numérico no docker/Dockerfile.postgres; e os placeholders novos no .env.example e no .embrapa/settings.json.

Rotas, menu, sino, seed, policies, health check, config e i18n são descobertos pelo host a partir do ModuleRegistry — nada mais é editado.

2. Quais artefatos compõem um módulo?

Modulos/<Modulo>/
├─ MeuPortal.<Modulo>.csproj           RCL (Microsoft.NET.Sdk.Razor)
├─ <Modulo>Module.cs                   IModuleDefinition — o plug
├─ Papeis<Modulo>.cs                   papéis, prefixados "<Modulo>.<Papel>"
├─ Permissoes<Modulo>.cs               permissões + ObterTodas()
├─ <Modulo>MenuContributor.cs          IMenuContributor
├─ <Modulo>NotificacaoProvider.cs      INotificacaoProvider
├─ ChavesI18nDinamicas<Modulo>.cs      IChavesI18nDinamicas
├─ appsettings.<Modulo>.json           template de config (EmbeddedResource)
├─ placeholders.env                    nomes dos placeholders (EmbeddedResource)
├─ _Imports.razor                      @using do módulo + Core
├─ Locales/{pt-BR,en-US,es}.json       i18n (EmbeddedResource)
├─ Data/<Modulo>DbContext.cs           : AuditedDbContextBase
├─ Models/  Dtos/  Services/           entidades, DTOs, services (Scoped)
├─ Components/                         componentes compartilhados do módulo
└─ Pages/                              @page "/<modulo>/..."

Obrigatórios para o host aceitar o módulo: o IModuleDefinition; os dois arquivos de configuração (o startup falha se faltar placeholders.env); e os três Locales/*.json embarcados.

Opcionais, por contribuição: IMenuContributor, INotificacaoProvider, IChavesI18nDinamicas, RedirecionamentosLegados e IModuleEventHandler<T>. Um módulo sem menu ou sem sino é perfeitamente válido.

Fora da pasta do módulo, mas pertencentes a ele: scripts/postgres/<modulo>.sql (DDL) e as linhas correspondentes no .env.example.

3. Como configuro as dependências do módulo?

<Project Sdk="Microsoft.NET.Sdk.Razor">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <!-- Fixados: renomear o csproj não pode mudar o namespace gerado
         dos .razor nem o assembly que resolve os Locales -->
    <RootNamespace>MeuPortal.MeuModulo</RootNamespace>
    <AssemblyName>MeuPortal.MeuModulo</AssemblyName>
  </PropertyGroup>

  <ItemGroup>
    <!-- IHttpContextAccessor/IConfiguration não fluem por ProjectReference -->
    <FrameworkReference Include="Microsoft.AspNetCore.App" />

    <Content Remove="Locales\*.json" />
    <EmbeddedResource Include="Locales\*.json" />
    <EmbeddedResource Include="appsettings.MeuModulo.json" />
    <EmbeddedResource Include="placeholders.env" />

    <!-- Sem Version: as versões vivem em Directory.Packages.props (AD-16) -->
    <PackageReference Include="BootstrapBlazor" />
    <PackageReference Include="Microsoft.EntityFrameworkCore" />
    <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />

    <!-- Só estes. Nunca o host, nunca Integracoes -->
    <ProjectReference Include="..\..\Core\Boilerplate4Dev.Core.csproj" />
  </ItemGroup>
</Project>

Regra de ouro (AD-1): o módulo referencia o Core e, opcionalmente, o Contracts. Nada mais. Precisa de MongoDB corporativo ou de outra integração institucional? Injete a interface de Core/Contracts/Institucional/ — a implementação chega pelo host.

Pacote novo: a versão entra em Directory.Packages.props (uma linha PackageVersion) e o PackageReference no csproj vai sem Version. Declarar Version no csproj falha o restore sob CPM.

Referenciar o host é impossível: seria dependência circular, já que o host referencia a RCL. Se o módulo “precisa” de algo do host, esse algo é candidato a contrato em Core/Contracts/Plataforma/ (AD-19).

4. Como registro os serviços (DI)?

public sealed class MeuModuloModule : IModuleDefinition
{
    public const string NomeDoModulo = "MeuModulo";
    public string Nome => NomeDoModulo;
    public Assembly Assembly => typeof(MeuModuloModule).Assembly;

    public void ConfigureServices(IServiceCollection services, IConfiguration configuration)
    {
        var cs = configuration.GetConnectionString("MeuModuloConnection")
            ?? throw new InvalidOperationException("Connection string do módulo ausente.");
        services.AddDbContextFactory<MeuModuloDbContext>(o => o.UseNpgsql(cs));

        services.AddScoped<ItemService>();
        // ... todos os services do módulo, Scoped

        services.AddHealthChecks().AddDbContextCheck<MeuModuloDbContext>();

        services.AddSingleton<IMenuContributor, MeuModuloMenuContributor>();
        services.AddScoped<INotificacaoProvider, MeuModuloNotificacaoProvider>();
    }

    public Task SeedAsync(IServiceProvider services, CancellationToken ct) => Task.CompletedTask;
}

O host apenas itera: foreach (var modulo in ModuleRegistry.Modulos) modulo.ConfigureServices(builder.Services, builder.Configuration);. Cada módulo enxerga somente a coleção de serviços e a configuração, nunca o estado de outro módulo.

Lifetimes: service de módulo → Scoped; contributor de menu → Singleton (lista estática); provider de notificação → Scoped; AddHealthChecks() é idempotente entre assemblies.

Anti-sequestro: o módulo registra apenas tipos do próprio assembly e implementações das interfaces de contribuição do Core. Registrar ou substituir um tipo do Core/host (trocar IHistoricoAuditoriaQuery, por exemplo) é proibido — a ordem do ModuleRegistry é não-significativa, e um override dependeria dela.

5. Como configuro os itens de menu?

// MenuItemDescriptor(ChaveI18n, Url, Icone, Permissao, Ordem, Grupo)
public sealed class MeuModuloMenuContributor : IMenuContributor
{
    private const string Grupo = "MeuModulo";
    private const string GrupoItens = "MeuModulo.Itens";

    public string NomeModulo => MeuModuloModule.NomeDoModulo;

    private static readonly IReadOnlyList<MenuItemDescriptor> Itens =
    [
        // nó de grupo: Url null, Ordem 100 (após Home 0 e Administração 10)
        new("MeuModulo", null, "fa-solid fa-cubes", null, 100, Grupo),

        // folha, protegida pela permissão da página de destino
        new("Cadastros", "/meumodulo/admin/cadastros", "fa-solid fa-sitemap",
            PermissoesMeuModulo.CadastrosGerenciar, 10, Grupo),

        // subgrupo com permissão própria: filho de grupo invisível é descartado
        new("Itens", null, "fa-solid fa-file-lines",
            PermissoesMeuModulo.ItensVisualizar, 80, GrupoItens),
        new("NovoItem", "/meumodulo/itens/novo", "fa-solid fa-plus",
            PermissoesMeuModulo.ItensRegistrar, 20, GrupoItens),
    ];

    public IReadOnlyList<MenuItemDescriptor> ObterItens() => Itens;
}
  • Grupo: string constante; o nó de grupo tem Url = null. Os grupos raiz da plataforma vêm de MenuGrupos (Home, Administracao) no Core.
  • Ordem: inteiro por nível; o desempate é determinístico no MenuService.
  • Permissao: null significa visível a qualquer usuário autenticado. O pipeline filtra por permissão, descarta filho de grupo invisível e poda grupo vazio.
  • Ícone: classe do Font Awesome.

Exceção à regra de i18n. A ChaveI18n é resolvida por IStringLocalizer<MenuService>, que é do host. Portanto, a tradução dos rótulos de menu do módulo fica em Locales/*.json do host (grupo Boilerplate4Dev.Data.Services.MenuService), nos 3 idiomas, e a chave entra em scripts/i18n/allowlist.txt. Só os rótulos de menu: todo o resto da i18n do módulo fica na RCL.

Validado no startup (em Development): permissão inexistente é pega por ValidarPermissoesDosContribuidoresDeMenu; chave sem tradução em algum idioma, por ValidarLocalizacaoDaSolution.

6. Como configuro perfis e permissões?

public static class PapeisMeuModulo
{
    public const string Gestor = "MeuModulo.Gestor";   // prefixo obrigatório: <Modulo>.<Papel>
}

public static class PermissoesMeuModulo
{
    public const string CadastrosGerenciar = "MeuModulo.Cadastros.Gerenciar";
    public const string ItensVisualizar    = "MeuModulo.Itens.Visualizar";
    // <Modulo>.<Recurso>.<Acao> — ObterTodas() lê as const por reflexão
}
// no IModuleDefinition
public IReadOnlyList<string> Papeis => [ PapeisMeuModulo.Gestor ];

public IReadOnlyDictionary<string, IReadOnlyList<string>> MatrizPermissoes =>
    new Dictionary<string, IReadOnlyList<string>>
    {
        [PapeisMeuModulo.Gestor] = PermissoesMeuModulo.ObterTodas(),
    };
@* na página — a policy É a string da permissão *@
@attribute [Authorize(Policy = PermissoesMeuModulo.CadastrosGerenciar)]

<AuthorizeView Policy="@PermissoesMeuModulo.CadastrosGerenciar">...</AuthorizeView>

Não existe “cadastrar policy”. O PermissaoPolicyProvider do host trata qualquer nome de policy desconhecido como exigência da permissão de mesmo nome, checada contra os claims de perfil (AspNetRoleClaims) pelo PermissaoHandler. Declarou a constante e usou no [Authorize] — pronto.

Seed no startup, em todo ambiente: o host cria os papéis de Papeis, grava a MatrizPermissoes como claims e concede ao Administrador a união das matrizes de todos os módulos. É idempotente. Papel ou permissão declarado por dois módulos derruba o startup, nomeando os donos.

Cuidados: nunca use string literal de papel ou permissão fora dos catálogos; ApplicationRoleNames guarda somente os papéis institucionais da plataforma; renomear um papel já existente é script SQL em scripts/postgres/, nunca só a constante.

7. Como crio o banco individualizado do módulo?

Quatro peças, nesta ordem (AD-7, AD-8, AD-10, AD-14):

  1. DDL em scripts/postgres/<modulo>.sql: CREATE SCHEMA <modulo> e as tabelas. Sem EF Migrations — nunca dotnet ef migrations add.
  2. Usuário e grants em scripts/postgres/init.sql: role de login própria, GRANT USAGE ON SCHEMA, SELECT/INSERT/UPDATE/DELETE e ALTER DEFAULT PRIVILEGES. Sem grant no schema de outro módulo.
  3. Ordem no container: docker/Dockerfile.postgres copia os scripts com prefixo numérico (10- base → 20-<modulo>.sql → 90-init.sql). Um módulo novo entra como 30-.
  4. Connection string própria em appsettings.<Modulo>.json, com SearchPath=<schema> e usuário/senha do módulo via placeholders <MODULO>_DB_USER / <MODULO>_DB_PASS.
// Data/MeuModuloDbContext.cs — herda a auditoria central (AD-10)
public class MeuModuloDbContext : AuditedDbContextBase
{
    public DbSet<Item> Itens { get; set; }
    // OnModelCreating: HasDefaultSchema("meumodulo")
}
// em página ou service — sempre factory + await using (Blazor Server)
await using var db = await _factory.CreateDbContextAsync(ct);

Auditoria de graça: por herdar AuditedDbContextBase, todo SaveChanges grava na tabela de log de auditoria central pela DefaultConnection — o módulo não referencia AuditoriaDbContext nem LogAuditoria. Para ler o histórico, use IHistoricoAuditoriaQuery (pergunta 12).

Proibido (AD-8): query cross-schema, transação que atravesse dois módulos e DbSet de entidade de outro módulo. Precisa do dado do vizinho? Contrato (pergunta 11) ou evento.

8. Como entrego a configuração do módulo?

As variáveis de um módulo não moram no appsettings.json do host (AD-15). O módulo entrega a própria configuração pela RCL:

// appsettings.MeuModulo.json — template, EmbeddedResource
{
  "ConnectionStrings": {
    "MeuModuloConnection": "Host=DB_HOST; Database=DB_NAME; Username=MEUMODULO_DB_USER; Password=MEUMODULO_DB_PASS; SearchPath=meumodulo"
  },
  "MeuModulo": {
    "LimiarDias": "MEUMODULO_LIMIAR_DIAS"
  }
}
# placeholders.env — fonte única dos nomes, EmbeddedResource
# default vazio = obrigatório (o gerador aborta se a variável não existir)
DB_HOST=
DB_NAME=
MEUMODULO_DB_USER=
MEUMODULO_DB_PASS=
MEUMODULO_LIMIAR_DIAS=3

A leitura no módulo é por seção nomeada: configuration["MeuModulo:LimiarDias"] ou GetConnectionString("MeuModuloConnection"). Os placeholders levam o prefixo <MODULO>_; os compartilhados com a base (DB_HOST, DB_NAME) não são renomeados.

  • Em desenvolvimento: variáveis no .env (molde: .env.example) → scripts/generate-appsettings.sh ou .ps1 gera appsettings.<Modulo>.Development.json na raiz (gitignored) → o host o carrega como overlay de menor precedência. O gerador descobre os módulos por convenção (Modulos/*/placeholders.env), sem conhecer nome nenhum.
  • No Docker: não há overlay — o Dockerfile substitui os placeholders no próprio template antes do dotnet build, e o valor entra compilado no assembly.

Duas regras que evitam incidente. Nunca coloque valor real no template embarcado — é assim que segredo vai parar no Git. E ValidarConfigDosModulos derruba o startup, em todo ambiente, se sobrar placeholder cru, sem jamais logar o valor.

9. Como faço i18n dentro do módulo?

// Pages/Admin/Cadastros.razor
@inject IStringLocalizer<Cadastros> Localizer
<h1>@Localizer["Title"]</h1>
// Locales/pt-BR.json (idem en-US e es) — o grupo é o FQCN de T
{
  "MeuPortal.MeuModulo.Pages.Admin.Cadastros": {
    "Title": "Cadastros",
    "NovoItem": "Novo item"
  }
}
  • O grupo é o nome totalmente qualificado do T de IStringLocalizer<T>, resolvido pelo assembly de T — por isso os JSONs do módulo ficam na RCL e o RootNamespace é fixado no csproj.
  • Paridade nos 3 idiomas, sempre no mesmo conjunto (RCL ou host, nunca os dois).
  • Precisa de um grupo compartilhado entre classes não relacionadas? Crie uma classe-marcador vazia no módulo; não reaproveite o T de outra tela.
  • Renomear ou mover chaves em massa: scripts/i18n/renomear-chaves-modulo.sh --prefixo-antigo … --prefixo-novo … --destino Modulos/<Modulo> (atômico entre idiomas).

Chave dinâmica = allowlist + contraparte. Localizer[variavel] não é visível ao gate estático. Ela precisa de duas coisas: uma entrada em scripts/i18n/allowlist.txt (3 campos separados por TAB — cuidado para o editor não trocar por espaços) e uma família declarada via IChavesI18nDinamicas, que ValidarLocalizacaoDaSolution confere contra o JSON do assembly dono, nos dois sentidos. Uma sem a outra é buraco: o gate passa e a aplicação renderiza a chave crua em silêncio.

new FamiliaChaveI18nDinamica(
    Grupo: typeof(Pages.Relatorios.Indicadores).FullName!,
    PrefixosDeCobertura: ["StatusEmAndamento", "StatusConcluido"],
    Chaves: [ /* ... */ ],
    Descricao: "status dinâmico do relatório de indicadores")

Antes de commitar: bash scripts/i18n/gate-i18n.sh deve sair com código 0.

10. Como defino rotas e redirects?

@* Pages/Admin/Cadastros.razor — prefixo do módulo é obrigatório *@
@page "/meumodulo/admin/cadastros"
// URL mudou e alguém tem favorito? declare no IModuleDefinition
public IReadOnlyDictionary<string, string> RedirecionamentosLegados =>
    new Dictionary<string, string>
    {
        ["/cadastros"] = "/meumodulo/admin/cadastros",
        ["/itens/{id:int}"] = "/meumodulo/itens/{id:int}",
        // o placeholder do template tem de casar nos dois lados
    };
// o host publica cada par como MapGet anônimo → 302, em todo ambiente
  • /<modulo>/* é obrigatório. /admin/* na raiz é espaço de rota do host.
  • A descoberta de páginas é automática: os três pontos (Components/Routes.razor, Program.cs e Components/Shared/MainLayout.razor) leem ModuleRegistry.AssembliesDosModulos. Registrou o módulo, as páginas aparecem.
  • Navegação interna do módulo (NavigateTo, BreadcrumbItem, href, Url do menu, Url da notificação) sempre já com o prefixo.
  • RedirecionamentosLegados tem implementação default vazia — um módulo novo, sem URL legada, não declara nada.
  • Usa-se 302, não 301: o 301 é cacheado pelo navegador de forma difícil de reverter.

Teste de rota nova: sempre dê F5 nela. Abrir pela sidebar não basta. O ponto de descoberta do MainLayout (Layout.AdditionalAssemblies do BootstrapBlazor) falha em silêncio — a página perde header e sidebar, mas o @Body continua aparecendo, e só em carga completa de página. Trocar de idioma também força carga completa e reproduz o problema.

11. Como me comunico com outro módulo?

Por um assembly de contrato próprio do derivado (AD-5), que não referencia nada:

// Contracts/MeuModulo/IItemQuery.cs
using System.Security.Claims;
namespace MeuPortal.Contracts.MeuModulo;   // <Dono> = pasta em Modulos/

public interface IItemQuery
{
    // "não encontrado" e "não autorizado" devolvem a MESMA coisa
    Task<ItemResumoDto?> ObterPorIdAsync(ClaimsPrincipal usuario, int itemId, CancellationToken ct);
}

public sealed record ItemResumoDto(int Id, string Nome, string Sigla);
// módulo DONO — adapter interno, registrado no próprio ConfigureServices
internal sealed class ItemQuery : IItemQuery { /* usa IDbContextFactory, aplica escopo */ }
services.AddScoped<IItemQuery, ItemQuery>();

// módulo CONSUMIDOR — injeta só a interface
@inject IItemQuery Itens

Regras:

  1. Interface + DTO record em Contracts/<Dono>/. Nunca entidade EF (o compilador impede, já que o assembly não referencia nada).
  2. *Query separado de *Command; nunca *Service. O primeiro parâmetro é sempre o ClaimsPrincipal, e o dono aplica permissão e escopo antes de devolver qualquer linha (AD-18).
  3. Quem implementa e registra (Scoped) é o módulo dono — nunca o host, nunca o consumidor.
  4. Dependência mútua síncrona é proibida; um dos sentidos vira evento.

Alternativa assíncrona (AD-6) — fato consumado, sem resposta esperada: o dono chama IModuleEventBus.PublicarAsync(evento) após o commit e o consumidor registra IModuleEventHandler<TEvento>. O handler deve ser idempotente; não há garantia de ordem, a entrega é at-most-once e é proibido publicar de dentro de um handler.

O projeto Contracts/ e o seu gate de verificação pertencem ao derivado, não à base — o blazorpost não os traz prontos. Crie-os no seu projeto quando houver o segundo módulo.

12. Como consumo dado da plataforma ou dado corporativo?

Dado de plataforma (auditoria, usuários) — Core/Contracts/Plataforma/ (AD-19):

public ItemService(IHistoricoAuditoriaQuery historico, ...) { ... }

var registros = await _historico.ListarPorTabelaAsync(usuario, tabela: "item", limite: 50, ct);
// devolve RegistroAuditoriaDto — nunca LogAuditoria, nunca AuditoriaDbContext

A implementação fica no host e é registrada no Program.cs. Precisa de um dado de plataforma que ainda não tem contrato? Ele nasce como I<Algo>Query + DTO no Core, com implementação no host — nunca IDbContextFactory<ApplicationDbContext> no módulo (e o compilador nem deixaria).

Dado corporativo (MongoDB do ETL, foto de empregado) — Core/Contracts/Institucional/ (AD-9):

Interface Uso
IEmpregadosGateway empregados (MongoDB corporativo, somente leitura)
IUnidadesGateway unidades da Embrapa
IPessoasGateway · ILocalidadesGateway pessoas físicas/jurídicas e localidades
IFotoEmpregadoGateway foto via API do Portal Embrapa

O host chama builder.Services.AddIntegracoesInstitucionais(config), que registra as implementações internal sealed de Boilerplate4Dev.Integracoes. O módulo não referencia Integracoes — recebe tudo pela injeção de dependência.

Onde o dado corporativo é buscado depende do ambiente, e o módulo não precisa saber. Na Sede, a implementação padrão lê do MongoDB do ETL; fora dela, o MongoDB não é acessível e as implementações em Integracoes são substituídas por versões que consomem as APIs corporativas (ver o item 8 do FAQ). Como o módulo depende apenas das interfaces, essa troca não altera uma linha de código de domínio.

Projeção local. O MongoDB corporativo é somente leitura e a cópia local é cache derivado: o módulo persiste apenas chave/código (matrícula, id de unidade), nunca a entidade corporativa inteira.

Integrações específicas de um derivado (não genéricas) não entram em Core/Contracts/Institucional/ nem em Integracoes/ — vivem em Modulos/<Modulo>/Integracoes/, dentro da própria RCL, registradas no ConfigureServices do módulo.

13. Como contribuo notificações ao sino?

// NotificacaoDescriptor(Titulo, Descricao, Url, DataLimite, Vencida)
public sealed class MeuModuloNotificacaoProvider : INotificacaoProvider
{
    private readonly ItemService _service;
    private readonly IStringLocalizer<MeuModuloNotificacaoProvider> _l;

    public async Task<IReadOnlyList<NotificacaoDescriptor>> ObterNotificacoesAsync(
        ClaimsPrincipal usuario, CancellationToken ct)
    {
        if (usuario.Identity?.IsAuthenticated != true) return [];

        var itens = await _service.ListarPendentesAsync(ct);
        return itens.Select(i => new NotificacaoDescriptor(
            _l["NotifItem", i.Id],
            $"{i.UnidadeNome} — {i.Descricao}",
            $"/meumodulo/itens/{i.Id}",
            i.DataLimite,
            i.Vencida)).ToList();
    }
}
  • O widget do sino, no host, enumera todos os providers com try/catch por provider — uma exceção sua não derruba o sino dos demais.
  • A Url já vai com o prefixo do módulo; Vencida muda o destaque visual; DataLimite ordena.
  • O texto vem por IStringLocalizer<Provider>, com as chaves no Locales/ da RCL.
  • AD-18: derive matrícula e escopo do ClaimsPrincipal recebido, não do AuthenticationStateProvider do circuito.
  • O sino usa a Url como chave de render: garanta unicidade de Url por item.

14. O que um módulo não pode fazer?

Não faça Quem impede Faça em vez disso
Referenciar o host ou Integracoes compilador (ciclo / CS0246) contratos do Core (pergunta 12)
Injetar IDbContextFactory<ApplicationDbContext>, AuditoriaDbContext, LogAuditoria compilador IHistoricoAuditoriaQuery e demais contratos de plataforma
Usar IMongoClient / MongoDB.Driver ausência do pacote + verificação I*Gateway de Core/Contracts/Institucional/
Query cross-schema, transação cruzando módulos, DbSet alheio usuário de banco sem grant no outro schema contrato em Contracts/ ou evento
Componente Razor de um módulo dentro de página de outro convenção — revisão o componente compartilhado sobe ao Core/host como genérico
Rota sem prefixo; /admin/* na raiz ValidarRotasDosModulos (dev) /<modulo>/admin/*
String literal de papel/permissão; papel em ApplicationRoleNames revisão; o seed acusa colisão Papeis<Modulo> / Permissoes<Modulo>
Registrar ou substituir tipo do Core/host no ConfigureServices convenção — revisão só tipos do próprio assembly + interfaces de contribuição
Chave i18n no Locales/ do host; grupo fora do RootNamespace gate de i18n (DOMINIO/COLISAO) Locales/ da RCL (exceção: rótulos de menu)
Editar Program.cs, MenuService, o widget do sino, appsettings.json revisão contribuição pelas interfaces + appsettings.<Modulo>.json
dotnet ef migrations add; valor real no template de config nenhum gate — disciplina DDL em scripts/postgres/; valores no .env

15. Como valido antes de commitar?

# 1. compila? (nunca dois builds em paralelo — gera erro espectral)
dotnet build --no-incremental

# 2. i18n — sempre que tocar em Locales/ ou em Localizer[...]
bash scripts/i18n/gate-i18n.sh            # exit 0

# 3. fronteira do MongoDB (deve voltar vazio)
grep -rn "IMongoClient\|MongoDB.Driver" --include=*.cs --include=*.csproj . \
  | grep -v "bin/\|obj/\|^./Integracoes/"

# 4. sobe em Development — as validações dev-only rodam aqui
dotnet watch run                          # http://localhost:5205

Códigos de saída dos gates: 0 limpo · 1 violação nomeada · 2 problema de tooling ou configuração (jq ausente, JSON inválido). Exit 2 nunca significa “passou” — um gate que não sabe dizer “não consegui rodar” é pior que gate nenhum.

Roteiro manual mínimo (o projeto não tem testes automatizados): login com um perfil do módulo; a sidebar mostra só o que a matriz permite; abrir uma tela nova e dar F5; trocar de idioma na tela (força carga completa e prova os 3 JSONs); o sino carrega; a tela de permissões por perfil lista as permissões do módulo.

Suba com banco vazio ao menos uma vez. Vários defeitos só aparecem em banco novo (seed do Administrador, colisão de papel) — subir o container do PostgreSQL do zero exercita o seed genérico de verdade.


Defesas em runtime

Como não há projeto de testes automatizados, o boilerplate valida as invariantes no startup. Vale saber qual delas cala em produção:

Validação Ambiente O que pega
Seed genérico de módulos todos papel ou permissão declarado por dois módulos (nomeia os donos)
ValidarConfigDosModulos todos placeholder cru em config de módulo — sem logar o valor
ValidarPermissoesDosContribuidoresDeMenu dev item de menu apontando para permissão inexistente
ValidarLocalizacaoDaSolution dev colisão de grupo entre assemblies; chave de menu ou família dinâmica ausente em algum dos 3 idiomas
ValidarRotasDosModulos dev colisão de template, rota legada ainda viva, destino sem rota, placeholder não casado

As três validações dev-only agregam todas as violações numa única exceção — ninguém corrige uma, sobe a aplicação e descobre a próxima.

Fora do alcance de tudo isso: string crua hardcoded no markup, que nunca passou pelo Localizer.


Sincronizando o derivado com a base

Boilerplate4Dev.* (Core, host genérico, Integracoes) pertence ao blazorpost; o namespace do derivado (módulos e Contracts/) é domínio do projeto e nunca sobe. Sobre os arquivos da base, a lista de pontos de edição legítimos do derivado é taxativa:

  • ModuleRegistry.cs;
  • o bloco de ProjectReference de módulo em Boilerplate4Dev.csproj;
  • as entradas de módulo em Boilerplate4Dev.slnx;
  • as linhas COPY de csproj de módulo e ARG de variável de módulo em docker/Dockerfile;
  • args de módulo em docker-compose.yaml;
  • variáveis de módulo em .env.example e .embrapa/settings.json;
  • Modulos/<Modulo>/appsettings.<Modulo>.json + placeholders.env;
  • scripts/postgres/<modulo>.sql e a linha COPY correspondente em docker/Dockerfile.postgres;
  • role e grants do usuário do módulo em scripts/postgres/init.sql.

Qualquer outra mudança genérica (Core, host genérico, Integracoes) vai por Merge Request upstream para o blazorpost — nunca como correção local no derivado. O derivado sincroniza com a base por merge manual de upstream/main.


Lições da implantação

Quatro achados do processo que valem mais que o refactor em si:

  1. Gate que falha aberto. Sem uma ferramenta no PATH, um gate chegou a aprovar um arquivo violado com exit 0. Todo gate precisa de preflight de tooling e de um código de saída que distinga “não achei defeito” de “não consegui procurar”.
  2. Allowlist sem contraparte. Colocar uma chave dinâmica na allowlist calava o gate e deixava a aplicação renderizando chave crua. Exceção que não é verificada em outro lugar não é exceção — é buraco.
  3. O terceiro ponto de assembly. Dois dos três pontos de descoberta de página quebram alto; o terceiro (Layout.AdditionalAssemblies) apaga header e sidebar sem exceção nenhuma, e só em carga completa. Fonte única + “sempre dar F5” foi a resposta.
  4. Seed em banco novo. Vários defeitos só aparecem em banco vazio, que ninguém exercita no dia a dia.

Gerência-Adjunta de Tecnologia da Informação - GTI

Supervisão de Desenvolvimento e Sustentação de Sistemas - SDSS

gti.sdss@embrapa.br

© 2026 embrapa4dev. Todos os direitos reservados.