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
- Modularização
- Guia do desenvolvedor
- 1. Como crio um módulo novo?
- 2. Quais artefatos compõem um módulo?
- 3. Como configuro as dependências do módulo?
- 4. Como registro os serviços (DI)?
- 5. Como configuro os itens de menu?
- 6. Como configuro perfis e permissões?
- 7. Como crio o banco individualizado do módulo?
- 8. Como entrego a configuração do módulo?
- 9. Como faço i18n dentro do módulo?
- 10. Como defino rotas e redirects?
- 11. Como me comunico com outro módulo?
- 12. Como consumo dado da plataforma ou dado corporativo?
- 13. Como contribuo notificações ao sino?
- 14. O que um módulo não pode fazer?
- 15. Como valido antes de commitar?
- Defesas em runtime
- Sincronizando o derivado com a base
- 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
upstreamum 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 comoAD-n(decisão de arquitetura) estory 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:
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 nodocker/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.
- RCL em
Modulos/<Modulo>/MeuPortal.<Modulo>.csproj(Microsoft.NET.Sdk.Razor), comRootNamespaceeAssemblyNamefixados. Referencia só oCore(eContracts, se precisar). - Catálogos:
Papeis<Modulo>.csePermissoes<Modulo>.cs, com constantes prefixadas (<Modulo>.<Recurso>.<Acao>). <Modulo>Module : IModuleDefinition— nome, assembly,ConfigureServices,Papeis,MatrizPermissoes,SeedAsync.- Banco:
Data/<Modulo>DbContext : AuditedDbContextBase, com schema próprio e DDL emscripts/postgres/<modulo>.sql. - Config:
appsettings.<Modulo>.json+placeholders.envcomoEmbeddedResource. - i18n:
Locales/{pt-BR,en-US,es}.jsoncomoEmbeddedResource. - Páginas em
Pages/, com rota/<modulo>/*; menu viaIMenuContributor.
Só então, no host — três toques, nenhum de lógica:
ModuleRegistry.Modulos→ adicionarnew <Modulo>Module();Boilerplate4Dev.csproj→ProjectReferencepara a RCL (Modulos\**já está noDefaultItemExcludes);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 deMenuGrupos(Home,Administracao) noCore. - Ordem: inteiro por nível; o desempate é determinístico no
MenuService. - Permissao:
nullsignifica 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 porIStringLocalizer<MenuService>, que é do host. Portanto, a tradução dos rótulos de menu do módulo fica emLocales/*.jsondo host (grupoBoilerplate4Dev.Data.Services.MenuService), nos 3 idiomas, e a chave entra emscripts/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):
- DDL em
scripts/postgres/<modulo>.sql:CREATE SCHEMA <modulo>e as tabelas. Sem EF Migrations — nuncadotnet ef migrations add. - Usuário e grants em
scripts/postgres/init.sql: role de login própria,GRANT USAGE ON SCHEMA,SELECT/INSERT/UPDATE/DELETEeALTER DEFAULT PRIVILEGES. Sem grant no schema de outro módulo. - Ordem no container:
docker/Dockerfile.postgrescopia os scripts com prefixo numérico (10-base →20-<modulo>.sql→90-init.sql). Um módulo novo entra como30-. - Connection string própria em
appsettings.<Modulo>.json, comSearchPath=<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.shou.ps1geraappsettings.<Modulo>.Development.jsonna 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
Dockerfilesubstitui os placeholders no próprio template antes dodotnet 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
ValidarConfigDosModulosderruba 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
TdeIStringLocalizer<T>, resolvido pelo assembly deT— por isso os JSONs do módulo ficam na RCL e oRootNamespaceé 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
Tde 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.cseComponents/Shared/MainLayout.razor) leemModuleRegistry.AssembliesDosModulos. Registrou o módulo, as páginas aparecem. - Navegação interna do módulo (
NavigateTo,BreadcrumbItem,href,Urldo menu,Urlda notificação) sempre já com o prefixo. RedirecionamentosLegadostem 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.AdditionalAssembliesdo BootstrapBlazor) falha em silêncio — a página perde header e sidebar, mas o@Bodycontinua 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:
- Interface + DTO
recordemContracts/<Dono>/. Nunca entidade EF (o compilador impede, já que o assembly não referencia nada). *Queryseparado de*Command; nunca*Service. O primeiro parâmetro é sempre oClaimsPrincipal, e o dono aplica permissão e escopo antes de devolver qualquer linha (AD-18).- Quem implementa e registra (
Scoped) é o módulo dono — nunca o host, nunca o consumidor. - 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 — oblazorpostnã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
Integracoessã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/catchpor provider — uma exceção sua não derruba o sino dos demais. - A
Urljá vai com o prefixo do módulo;Vencidamuda o destaque visual;DataLimiteordena. - O texto vem por
IStringLocalizer<Provider>, com as chaves noLocales/da RCL. - AD-18: derive matrícula e escopo do
ClaimsPrincipalrecebido, não doAuthenticationStateProviderdo circuito. - O sino usa a
Urlcomo chave de render: garanta unicidade deUrlpor 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
ProjectReferencede módulo emBoilerplate4Dev.csproj; - as entradas de módulo em
Boilerplate4Dev.slnx; - as linhas
COPYde csproj de módulo eARGde variável de módulo emdocker/Dockerfile; argsde módulo emdocker-compose.yaml;- variáveis de módulo em
.env.examplee.embrapa/settings.json; Modulos/<Modulo>/appsettings.<Modulo>.json+placeholders.env;scripts/postgres/<modulo>.sqle a linhaCOPYcorrespondente emdocker/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:
- Gate que falha aberto. Sem uma ferramenta no
PATH, um gate chegou a aprovar um arquivo violado com exit0. 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”. - 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.
- 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. - Seed em banco novo. Vários defeitos só aparecem em banco vazio, que ninguém exercita no dia a dia.