Ao longo da minha carreira, passei por vários cenários em que a arquitetura baseada em plugins se encaixou muito bem. Em quase todos eles o motivador era o mesmo: a necessidade de não “sujar” o core do sistema com detalhes de implementação de um cliente específico, com regras de negócio que precisavam ser substituídas ou com integrações que só faziam sentido para um contexto muito particular.
Mesmo sendo um estilo arquitetural antigo e presente em ferramentas que usamos todos os dias, existe pouco conteúdo aprofundado sobre o assunto. E o pouco que existe costuma parar no ponto em que o plugin é descoberto, carregado e sai funcionando. O problema é que, em sistemas reais, é justamente depois disso que as coisas começam a quebrar.
Neste artigo, quero trazer tanto a visão conceitual do Plugin Architecture quanto os detalhes de implementação no .NET, com foco especial em um tema que quase ninguém aborda: o isolamento de dependências entre plugins. Tudo o que está aqui foi materializado em uma demo com duas solutions (.NET Framework e .NET moderno), disponível no GitHub:
https://github.com/andreluizsecco/PluginArchitecture.DotNet.Demo
O que é Plugin Architecture e qual problema ela resolve
Em algumas referências você vai encontrar o nome Microkernel Architecture. Existem nuances entre os dois termos, então eles não são exatamente a mesma coisa.
Em poucas palavras, você tem um core (que vou chamar também de host), que é o núcleo do seu software. É ali que moram as funcionalidades principais e onde os fluxos centrais acontecem. Em algum momento surge a necessidade de estender esse sistema com novas funcionalidades, novas integrações ou variações de comportamento, e você não quer fazer isso alterando o próprio host.
Para isso, o host oferece um contrato (uma interface), e os plugins se baseiam nesse contrato para atender a uma necessidade que foi deliberadamente aberta para extensão. As duas palavras que melhor resumem essa arquitetura são extensibilidade e isolamento.
E qual é o problema que motiva esse tipo de decisão? Na minha experiência, os sintomas costumam ser estes:
- Todo comportamento novo exige alterar o núcleo do sistema.
- Cada cliente pede uma variação, uma customização própria.
- O núcleo vira gargalo: todos os times dependem dele e todo deploy é um deploy de tudo.
Percebe que esses sintomas conversam diretamente com os níveis de maturidade de isolamento de um sistema? Você começa com um monolito, evolui para um monolito modular, depois talvez para serviços, microsserviços e assim por diante. Plugin Architecture é mais uma dessas formas de isolamento, não a única. Não existe bala de prata. A frase “preciso de mais isolamento, então tem que ser plugin” simplesmente não é verdadeira. O que diferencia esse estilo é a possibilidade de rodar as extensões dentro do mesmo processo, carregá-las em tempo de execução e, em um nível de maturidade mais alto, permitir que terceiros desenvolvam seus próprios plugins.
A pergunta que resume a motivação é: e se o sistema pudesse ganhar um comportamento novo sem ser recompilado? Você deixa uma porta aberta (o contrato), alguém desenvolve algo que respeita esse contrato e apenas “pluga” no sistema, sem um novo deploy do core.
Fundamentação: SOLID, Clean Architecture e Package by Component
Não se trata de uma ideia isolada. Plugin Architecture é a aplicação, em escala de sistema, de princípios que já conhecemos no nível de código.
Open-Closed Principle em escala de sistema: Quando falamos do OCP no SOLID, normalmente pensamos em classes: aberta para extensão, fechada para modificação. Aqui a mesma ideia é aplicada ao sistema inteiro. O host é fechado para modificação e aberto para extensão por meio de plugins.
Dependency Inversion: O host conhece o contrato, nunca a implementação. Os plugins dependem da abstração oferecida pelo host, e não o contrário. Um exemplo clássico é o Visual Studio: a IDE não sabe nada sobre o ReSharper em tempo de compilação, mas oferece pontos de extensão para que ele seja instalado e funcione.
Package by Component: Quem leu o livro Clean Architecture, do Robert C. Martin, deve lembrar do capítulo 34, “O Capítulo Perdido”, escrito pelo Simon Brown. Ele apresenta diferentes formas de organizar o código (por camada, por feature, ports and adapters) e chega ao Package by Component, em que toda a implementação fica encapsulada dentro de um componente e a única coisa exposta é uma interface.
Agora extrapole esse conceito. Se o componente já está tão isolado que só se comunica por um contrato, o que falta para ele virar uma unidade implantável independente? Basicamente duas coisas: compilá-lo separadamente e fazer com que o host seja capaz de descobri-lo e carregá-lo dinamicamente. Isso é um plugin.
Existe ainda um nível de isolamento acima, que é transformar o componente em um serviço à parte, comunicando-se com o core por rede ou por algum mecanismo de comunicação entre processos. Neste artigo, porém, quando eu falar de plugins no .NET, estou falando do mesmo processo: o plugin é carregado dentro do processo do host e executa ali dentro.
Como funciona?
O ciclo de vida de um plugin pode ser dividido em quatro etapas:
- Descoberta. O host varre um diretório, um manifesto ou um registro para saber onde estão os plugins e quais deles implementam o contrato.
- Carregamento. O binário é carregado dinamicamente dentro do processo. Isso pode acontecer na inicialização do sistema ou sob demanda, em alguma rotina específica.
- Registro. Os tipos que implementam o contrato são identificados e instanciados, e essa associação entre contrato e implementação é registrada.
- Execução. O host chama o plugin sempre através da abstração, ou seja, do próprio contrato.
Um ponto de atenção desde já: a etapa 2 é onde mora o problema. Quando os plugins crescem e passam a ter dependências próprias, surgem dependências que se cruzam, bibliotecas iguais em versões diferentes e comportamentos difíceis de explicar. Vou voltar a esse ponto com calma mais adiante, porque ele é o coração deste artigo.
Um exemplo real: WordPress
Talvez o exemplo mais conhecido de Plugin Architecture seja o WordPress, presente em uma fatia enorme da web (levantamentos como o do W3Techs costumam apontar algo acima de 40% dos sites).
O que chama atenção no WordPress é a relação entre o tamanho do núcleo e o tamanho do ecossistema. O núcleo é pequeno e o ecossistema é gigante, justamente porque ele é extensível. O WordPress expõe hooks (as actions e os filters), que são os pontos de extensão, e a comunidade cria plugins que se conectam a esses eventos. A partir de um núcleo enxuto, você transforma o WordPress em blog, e-commerce, CRM e muitas outras coisas.
É um bom exemplo de onde esse estilo pode chegar quando o contrato é bem definido e mantido com disciplina ao longo do tempo.
Vantagens
- Extensibilidade: comportamento novo sem recompilar o núcleo.
- Desacoplamento: o host não conhece as implementações, nem mesmo em tempo de compilação.
- Deploy independente: cada plugin tem o seu próprio ciclo de vida e, eventualmente, o seu próprio time. Esse time nem precisa ser da sua empresa.
- Paralelização de times: times trabalhando em plugins diferentes raramente disputam o mesmo código.
- Customização por cliente: você (ou o próprio cliente, dependendo da maturidade) implementa variações sem afetar o core.
- Ecossistema: terceiros estendem o produto, e o SDK passa a funcionar como parte do próprio produto.
Aplicabilidade
Alguns casos típicos em que esse estilo faz sentido:
- Muitas integrações com sistemas externos. Se a integração é com um sistema relevante e reaproveitável entre vários clientes (um CRM ou ERP conhecido), talvez faça mais sentido tê-la como parte do produto. Agora, quando você precisa integrar com sistemas pequenos, muitas vezes desenvolvidos internamente por cada cliente, um plugin pode ser bem mais interessante do que carregar essas particularidades para dentro do core.
- Drivers e protocolos de dispositivos. No mundo IoT, por exemplo, uma aplicação que precisa falar vários protocolos pode oferecer o suporte a cada um deles por meio de plugins.
- Regras de negócio que variam por cliente ou por regulação. Já trabalhei em cenários em que o cliente precisava de uma regra que mudava o fluxo padrão do sistema. E quando falo de regra de negócio, falo de coisas complexas, que não se resolviam com configuração.
- Pipelines com etapas plugáveis, IDEs, editores e ferramentas de build.
Também gosto de pensar em níveis de maturidade:
- Extensível internamente. Você mesmo cria o core e os plugins, com total controle e curadoria.
- Extensível pelo cliente. Você customiza para clientes específicos ou oferece a ferramenta para que poucos clientes estendam o sistema.
- Plataforma com SDK. Terceiros desenvolvem plugins para o seu sistema. Esse nível exige preocupações muito bem pensadas com segurança.
E existe um preço claro ao subir de nível: o contrato vira API pública. Versionamento e compatibilidade deixam de ser uma boa prática e passam a ser uma obrigação. A partir do momento em que outras pessoas constroem plugins sobre o seu contrato, ele não pode mais ser alterado a qualquer momento sem quebrar todo o ecossistema.
Plugin Architecture no .NET
Vamos para a parte específica de .NET. A primeira pergunta é: qual é a menor unidade compilada que pode funcionar como um plugin?
A resposta é a DLL, que é a materialização de um assembly. Todo plugin em .NET é, no fundo, um assembly carregado em tempo de execução.
A partir daí, existem duas decisões diferentes (e independentes) a tomar:
- Descoberta e composição: como encontrar e identificar os plugins. Aqui entram o Reflection e o MEF.
- Carregamento: como colocar esse assembly dentro do processo em execução, e em qual contexto de carregamento.
A maioria dos conteúdos que você encontra por aí trata apenas da primeira decisão, geralmente com plugins muito simples, sem dependências próprias. A segunda decisão é onde os sistemas quebram, e ela depende de um conceito que precisa ficar bem claro: por padrão, tudo é carregado no contexto padrão do processo, que no .NET Framework é o AppDomain default e no .NET moderno é o AssemblyLoadContext (ALC) default. Esse contexto é compartilhado pelo host e por todos os plugins.
No exemplo da demo, o host é um hub de integração. Ele possui um documento canônico de pedido (OrderDocument) e precisa entregá-lo a sistemas parceiros com layouts diferentes. Cada plugin é um adaptador de saída, e o contrato é este:
public interface IDocumentConverter
{
string Key { get; }
string DisplayName { get; }
string TargetFormatName { get; }
ConversionResult Convert(OrderDocument document);
}
Esse contrato fica em um assembly próprio (PluginArchitecture.Shared), que é referenciado pelo host e pelos plugins. O host não referencia nenhum plugin.
Carregamento de assemblies no .NET
O mecanismo básico é simples: varrer o diretório, carregar o assembly, encontrar os tipos que implementam o contrato e instanciá-los.
var plugins = new List<IDocumentConverter>();
foreach (var dll in Directory.GetFiles(pluginsPath, "PluginArchitecture.Plugins.*.dll"))
{
var assembly = Assembly.LoadFrom(dll);
var types = assembly.GetTypes()
.Where(t => typeof(IDocumentConverter).IsAssignableFrom(t)
&& !t.IsAbstract
&& !t.IsInterface);
foreach (var type in types)
plugins.Add((IDocumentConverter)Activator.CreateInstance(type));
}
var result = plugins.First(p => p.Key == "A").Convert(document);
Esse código funciona nas duas plataformas, mas os bastidores são diferentes:
- No .NET Framework, o
Assembly.LoadFrom(ouAssembly.Load) passa pela resolução do Fusion, e o eventoAppDomain.AssemblyResolvefunciona como fallback quando uma dependência não é encontrada. Tudo cai no AppDomain default. - No .NET moderno, o
Assembly.LoadFromcai noAssemblyLoadContext.Default, e o evento equivalente de fallback é oAssemblyLoadContext.Default.Resolving.
Ou seja, esse exemplo carrega todos os plugins no mesmo contexto. Guarde essa informação.
Um detalhe prático que vale incluir desde o início: quando uma dependência de um plugin não resolve, o GetTypes() lança ReflectionTypeLoadException, e o erro real fica dentro de LoaderExceptions. Sem tratar isso, a mensagem que chega para quem está diagnosticando é praticamente inútil.
MEF (Managed Extensibility Framework)
O MEF é um framework da própria Microsoft que facilita a descoberta e a composição de plugins por meio de atributos. Do lado do plugin, você declara o que é exportado ([Export]). Do lado do host, você declara o que é importado ([Import] ou [ImportMany]).
Existem dois MEFs, e é importante entender que o MEF 2 não é uma atualização do MEF 1, e sim um subconjunto dele:
- MEF 1 (
System.ComponentModel.Composition): nasceu no .NET Framework, trabalha com catálogos como oDirectoryCatalog. - MEF 2 (
System.Composition): mais leve, pensado para o .NET moderno.
Os dois rodam no .NET moderno via pacote NuGet. MEF não é uma tecnologia exclusiva do .NET Framework.
No MEF 1, o [InheritedExport] pode ficar na própria interface, e os plugins nem precisam de atributos do MEF. O DirectoryCatalog varre a pasta sozinho:
[InheritedExport(typeof(IDocumentConverter))]
public interface IDocumentConverter { /* ... */ }
internal sealed class PluginCatalog
{
[ImportMany]
public IEnumerable<Lazy<IDocumentConverter, IPluginKeyMetadata>> Converters { get; set; }
}
var directory = new DirectoryCatalog(pluginsPath, "PluginArchitecture.Plugins.*.dll");
var container = new CompositionContainer(directory);
var catalog = new PluginCatalog();
container.ComposeParts(catalog);
No MEF 2, cada plugin precisa declarar o [Export], e quem carrega os assemblies é o próprio host:
[PluginMetadata("A", "Converter System A")]
[Export(typeof(IDocumentConverter))]
public class LegacyErpConverter : IDocumentConverter { /* ... */ }
var assemblies = Directory.GetFiles(pluginsPath, "PluginArchitecture.Plugins.*.dll")
.Select(Assembly.LoadFrom)
.ToArray();
var configuration = new ContainerConfiguration().WithAssemblies(assemblies);
using var container = configuration.CreateContainer();
var catalog = new PluginCatalog();
container.SatisfyImports(catalog);
O lado do Import é igual nos dois, e é nele que está um dos recursos mais úteis do MEF: o Lazy<T, TMetadata>. Ele permite ler os metadados de todos os plugins (chave, nome de exibição) e escolher qual ativar antes de instanciar qualquer um deles.
| MEF 1 | MEF 2 | |
|---|---|---|
[InheritedExport] na interface | Existe | Não existe, o [Export] vai no plugin |
DirectoryCatalog | Existe | Não existe, o host carrega e passa os assemblies |
Metadata view (TMetadata) | Pode ser interface | Precisa ser classe concreta |
| Recomposição | Existe | Não existe |
[ImportMany] e Lazy<T, TMetadata> | Igual | Igual |
Agora, o ponto mais importante desta seção: o MEF resolve descoberta e composição, não resolve isolamento. Ele compõe dentro do contexto vigente. Todos os plugins continuam caindo no mesmo AppDomain ou no mesmo ALC default.
Na demo com .NET Framework, isso ficou ainda mais evidente. Quando um único plugin tem uma dependência que não resolve, a enumeração do DirectoryCatalog lança uma exceção que derruba o catálogo inteiro, levando junto o plugin saudável. Com o loader baseado em reflection, que trata cada assembly separadamente, a falha fica contida no plugin problemático. Escolher o mecanismo de composição também é escolher o raio de alcance de uma falha de carga.
Descobrir plugins é fácil. Carregá-los sem que eles se destruam é o problema real.
Isolamento de dependências no .NET
Para entender o problema de isolamento, precisamos entender como o runtime identifica um assembly. E aqui vai o primeiro ponto que costuma surpreender: o runtime não identifica um assembly pelo nome do arquivo nem pelo caminho. Ele identifica pela identidade do assembly.
No .NET Framework existem dois tipos de identidade:
- Strong name (nome forte): nome + versão + cultura + public key token. Provavelmente você já viu esse public key token em alguma exceção de “não foi possível carregar o assembly”.
- A versão participa do binding? Sim, e precisa bater exatamente.
- Duas versões podem coexistir (side by side)? Sim.
- Binding redirect funciona? Sim, mas vale para o AppDomain inteiro.
- Weak name (nome fraco): apenas o nome.
- A versão participa do binding? Não, é ignorada.
- Side by side? Não, o primeiro a carregar vence.
- Binding redirect? Não se aplica.
Você provavelmente já se deparou com o mecanismo de binding redirect no arquivo de configuração da aplicação, seja o web.config em aplicações web ou o app.config (que no build vira NomeDoExecutavel.exe.config) em aplicações desktop, console ou serviços:
<dependentAssembly>
<assemblyIdentity name="Newtonsoft.Json" publicKeyToken="30ad4fe6b2a6aeed" culture="neutral" />
<bindingRedirect oldVersion="0.0.0.0-13.0.0.0" newVersion="13.0.0.0" />
</dependentAssembly>
Esse trecho diz: “qualquer assembly que referencie qualquer versão entre 0.0.0.0 e 13.0.0.0 do Newtonsoft.Json deve receber a 13.0.0.0”. Funciona desde que a versão nova seja retrocompatível com tudo o que as outras referências usam.
No .NET moderno, nada disso se aplica da mesma forma. A própria Microsoft afirma que strong naming não traz benefícios no .NET Core e no .NET 5+. Dentro de um contexto de carregamento, existe uma única versão por nome simples. Não há binding redirect nem o carregamento estrito do .NET Framework, que exige a versão exata. A regra passa a ser outra: se um assembly com aquele nome já está carregado no contexto, a resolução só funciona quando a versão carregada é igual ou maior que a pedida. Uma versão maior é aceita silenciosamente; uma menor gera erro. Quem isola agora é o contexto de carregamento: se você precisa da mesma dependência em versões diferentes, sem que uma interfira na outra, elas precisam estar em ALCs diferentes.
Único contexto de carregamento: o problema
Chegamos ao cenário que motivou toda a demo. Quando tudo é carregado no contexto padrão, host e plugins compartilham o mesmo espaço. Se cada plugin foi desenvolvido por um time diferente, é natural que eles não atualizem suas dependências ao mesmo tempo.
Na demo, os dois plugins dependem das mesmas duas bibliotecas, em versões diferentes. Nenhuma delas é acessório: um conversor para JSON é um uso de biblioteca de serialização, e converter unidade de medida é conversão de dados.
| Plugin A (ERP legado) | Plugin B (API moderna) | |
|---|---|---|
Newtonsoft.Json | 9.0.1 | 13.0.3 |
UnitsNet | 4.152.0 | 5.75.1 |
A narrativa é bem comum no mundo real: o parceiro legado homologou a integração contra as versões antigas e não vai revalidar, enquanto o parceiro novo exige as versões novas. Os dois adaptadores precisam conviver no mesmo processo.
Qual versão será carregada? No contexto padrão, apenas uma. E aí começam os sintomas que você talvez já tenha visto:
MissingMethodException: o plugin foi compilado contra uma versão que tinha um método que a versão carregada não tem.TypeLoadExceptioneFileLoadException: tipos ou assemblies que não batem com o que o plugin esperava.- E o pior de todos: mudança silenciosa de comportamento. Nada estoura, mas o resultado muda.
Esse último ponto merece atenção. Bibliotecas populares têm muito cuidado com retrocompatibilidade, então nem sempre vai quebrar com uma exceção. Às vezes a forma de serializar alguma coisa muda entre versões, e o plugin passa a entregar outro resultado sem que ninguém perceba. Um erro estourando na sua cara é, sinceramente, o cenário menos pior.
E a demo mostrou exatamente isso, com comportamentos opostos entre as duas plataformas:
No .NET Framework, sem isolamento, o Plugin A simplesmente não carrega (Could not load file or assembly 'UnitsNet, Version=4.0.0.0'). A falha é ruidosa, desde que nada entregue ao plugin uma versão diferente da que ele pediu: nenhum binding redirect cobrindo a versão solicitada e nenhum handler de AssemblyResolve permissivo. Na demo, o redirect do web.config cobre apenas a faixa que o próprio Web API precisa, deixando a 9.0.0.0 do Newtonsoft de fora de propósito. Com um redirect de faixa ampla, o .NET Framework passa a se comportar igual ao .NET moderno, e a falha vira silenciosa (detalho isso mais abaixo).
No .NET moderno, o mesmo cenário devolve 200 OK. O Plugin A roda contra o Newtonsoft 13 e o UnitsNet 5, versões que nunca foram homologadas para ele. E o número entregue ao parceiro muda: a 5.x do UnitsNet corrigiu o fator de conversão do galão americano (de 3.78541, truncado, para 3.785411784, o valor exato). Em uma linha de 317 galões, o ERP legado, que foi homologado recebendo 1199975 mL, passa a receber 1199976 mL.
Repare que a versão nova está mais correta. O problema não é qual versão é melhor, e sim quem escolheu. Sem isolamento, quem decide a versão que executa é a ordem em que os arquivos foram copiados para uma pasta. Com isolamento, quem decide é o .csproj de cada plugin. Se o ERP legado passar a receber outro número sem que ninguém tenha tomado essa decisão, a conciliação quebra.
E pode ficar pior. No cenário sem isolamento, o host da demo registra um handler no evento AssemblyLoadContext.Default.Resolving (o equivalente ao AssemblyResolve do .NET Framework) que procura a DLL pedida na pasta compartilhada dos plugins. É o workaround que quase todo mundo escreve. Invertendo apenas a ordem de cópia dessa pasta (sem alterar nenhuma linha de código), sobra nela somente a UnitsNet 4.0.0.0, e os papéis se invertem: agora é o Plugin B, que estava com tudo em dia, quem pede a 5.0.0.0. Como ela ainda não foi carregada, o handler devolve a 4.0.0.0 da pasta, o runtime aceita, e o plugin executa com o fator truncado, devolvendo 1199.97 em vez de 1199.98. Também com 200 OK.
Repare que quem abre essa porta é o handler. Se a 4.0.0.0 já estivesse carregada no contexto, o pedido pela 5.0.0.0 falharia pela regra de versão que comentei antes. O .NET Framework tem exatamente a mesma armadilha, como você vai ver logo abaixo.
.NET Framework quebra alto. .NET moderno quebra baixo. E o segundo é mais perigoso, porque passa no teste de fumaça.
Com o Newtonsoft, o cenário é ainda mais traiçoeiro: o payload sai byte a byte idêntico nas duas versões. Nenhum teste pega a diferença. O que muda é qual código está executando, e a versão 9.0.1 tem vulnerabilidade conhecida (o build avisa com NU1903). Ninguém sabe qual versão está rodando de fato.
Existe ainda um detalhe do .NET Framework que eu considero uma das descobertas mais úteis dessa implementação. O workaround mais comum para esse problema é registrar um handler no AssemblyResolve que procura a dependência na pasta dos plugins:
AppDomain.CurrentDomain.AssemblyResolve += (sender, args) =>
{
var requested = new AssemblyName(args.Name);
var candidate = Path.Combine(pluginsPath, requested.Name + ".dll");
if (!File.Exists(candidate)) return null;
// Sem esta conferência, o CLR aceita o assembly devolvido sem validar a versão
var found = AssemblyName.GetAssemblyName(candidate);
if (requested.Version != null && !requested.Version.Equals(found.Version))
return null;
return Assembly.LoadFrom(candidate);
};
Quando um handler de AssemblyResolve devolve um assembly, o CLR o aceita sem validar a versão. A versão ingênua desse handler, que apenas devolve o arquivo que existir na pasta, transforma uma falha ruidosa em uma falha silenciosa. Na prática, é provavelmente assim que muitos times “resolvem” esse problema sem perceber o que fizeram. O mesmo acontece se o binding redirect do web.config (ou do app.config) cobrir uma faixa ampla (0.0.0.0-13.0.0.0), que é exatamente a faixa que as ferramentas geram automaticamente.
A ordem de carregamento pode variar de máquina para máquina e entre deploys. É o tipo de bug que vira pesadelo para diagnosticar.
Solução de isolamento no .NET Framework: um AppDomain por plugin
No .NET Framework, a solução é deixar de usar o AppDomain default para os plugins e criar um AppDomain por plugin. Cada AppDomain tem o seu próprio conjunto de assemblies, a sua própria ApplicationBase e o seu próprio arquivo de configuração.
Isso traz algumas consequências importantes:
- Se a
ApplicationBaseé a pasta do plugin, dois plugins não disputam a mesma dependência. Cada domínio sonda apenas a sua pasta. - Cada plugin pode ter o seu próprio
.config, com binding redirects específicos. Oweb.configouapp.configdo host (e os redirects dele) não influenciam a resolução de assemblies dentro do domínio do plugin. Isso é fundamental: se o config geral tiver um redirect de “9 e 11 para 13”, ele quebraria justamente o isolamento que você está tentando criar. - O isolamento funciona inclusive para assemblies com weak name, que nenhum truque de binding resolveria.
AppDomain.Unloaddescarrega um plugin sem derrubar o processo.
A limitação: AppDomain não isola código nativo. DLLs em C ou C++ são carregadas pelo loader do sistema operacional e vivem no nível do processo.
Criando o AppDomain do plugin
private PluginDomain GetOrCreateDomain(string key, string folder)
{
return Domains.GetOrAdd(key, k =>
{
var setup = new AppDomainSetup
{
// O domínio filho sonda as dependências na PASTA DO PLUGIN
ApplicationBase = folder,
ApplicationName = "PluginDomain_" + k,
// Config próprio do plugin: os bindingRedirects do host não se aplicam aqui
ConfigurationFile = Path.Combine(folder, "plugin.config")
};
var domain = AppDomain.CreateDomain("PluginDomain_" + k, null, setup);
// O tipo criado do outro lado vem do assembly de CONTRATO, nunca do plugin
var host = (RemotePluginHost)domain.CreateInstanceAndUnwrap(
typeof(RemotePluginHost).Assembly.FullName,
typeof(RemotePluginHost).FullName);
return new PluginDomain { Domain = domain, Host = host, Folder = folder };
});
}
O plugin.config de cada plugin pode ser mínimo. Na demo, ele existe justamente para garantir que nenhum redirect do host vaze para dentro do domínio:
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<runtime>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1" />
</runtime>
</configuration>
Atravessando a fronteira: MarshalByRefObject e Serializable
Aqui entra a parte mais específica (e mais trabalhosa) do .NET Framework. Cada AppDomain é um espaço de memória separado, e objetos comuns não atravessam essa fronteira. A comunicação entre o domínio do host e o domínio do plugin acontece via .NET Remoting, e existem duas formas de um objeto cruzar a fronteira:
- Por valor (
[Serializable]): o objeto é serializado e uma cópia é criada do outro lado. O assembly do tipo precisa estar carregado nos dois domínios. - Por referência (
MarshalByRefObject): o objeto fica no domínio do plugin, e o host recebe um proxy transparente. Cada chamada nesse proxy é marshalada para o outro lado.
Existe um detalhe que faz toda a diferença. Se o host criasse o objeto remoto a partir do tipo concreto do plugin, o assembly do plugin teria que ser carregado também no domínio do host, anulando o isolamento. Por isso, na demo, a peça central é uma fachada que vive no assembly de contrato:
Host (AppDomain padrão) AppDomain do Plugin A
+----------------------------+ +---------------------------------+
| Shared.dll |<----- mesma ----->| Shared.dll |
| IDocumentConverter | identidade | RemotePluginHost : MBRO |
| RemotePluginHost | | +- carrega ConverterSystemA |
| | | + Newtonsoft 9.0.0.0 |
| proxy transparente --------------------------->| |
+----------------------------+ +---------------------------------+
só o proxy cruza código e dependências ficam aqui
public sealed class RemotePluginHost : MarshalByRefObject
{
// Lease infinito: sem isso, o proxy morre após 5 minutos ocioso
public override object InitializeLifetimeService()
{
return null;
}
// Executa DENTRO do domínio do plugin
public ConversionResult ConvertComplex(string pluginDirectory, string key, OrderDocument document)
{
var converter = Resolve<IDocumentConverter>(pluginDirectory, key);
return converter.Convert(document);
}
private T Resolve<T>(string pluginDirectory, string key) where T : class
{
foreach (var dll in Directory.GetFiles(pluginDirectory, "PluginArchitecture.Plugins.*.dll"))
{
// Carregado aqui dentro: o plugin e as dependências dele existem só neste domínio
var assembly = Assembly.LoadFrom(dll);
foreach (var type in assembly.GetTypes())
{
if (type.IsAbstract || type.IsInterface) continue;
if (!typeof(T).IsAssignableFrom(type)) continue;
var metadata = (PluginMetadataAttribute)Attribute.GetCustomAttribute(
type, typeof(PluginMetadataAttribute));
if (metadata != null && string.Equals(metadata.Key, key, StringComparison.OrdinalIgnoreCase))
return (T)Activator.CreateInstance(type);
}
}
throw new InvalidOperationException("Plugin não encontrado: " + key);
}
}
Os tipos que trafegam pelo contrato precisam ser serializáveis, porque atravessam por valor:
[Serializable]
public sealed class OrderDocument
{
public string DocumentId { get; set; }
public DateTime IssuedAtUtc { get; set; }
public List<OrderLine> Lines { get; set; }
// ...
}
[Serializable]
public sealed class ConversionResult
{
public string PayloadJson { get; set; }
// ...
}
E, se a própria instância do plugin precisar ser devolvida ao host, a classe do plugin precisa herdar de MarshalByRefObject:
[PluginMetadata("A", "Converter System A")]
public class LegacyErpConverter : MarshalByRefObject, IDocumentConverter
{
public override object InitializeLifetimeService()
{
return null;
}
public ConversionResult Convert(OrderDocument document)
{
// Executa no domínio do plugin, com a Newtonsoft 9 e o UnitsNet 4
}
}
Na demo existe um endpoint que prova esse ponto: ele tenta trazer ao host a instância de um conversor que não herda de MarshalByRefObject nem é [Serializable], e o resultado é uma SerializationException. Ao mesmo tempo, esse mesmo conversor funciona perfeitamente quando é executado pela fachada, porque ele é construído e executado dentro do domínio filho, e só a string de retorno cruza a fronteira. Ou seja, MarshalByRefObject só é necessário quando o objeto precisa atravessar.
Com o isolamento aplicado, o diagnóstico da demo mostra as duas versões vivendo no mesmo processo:
HostDomain (IIS) | Newtonsoft.Json 13.0.0.0
PluginDomain_A | Newtonsoft.Json 9.0.0.0 | UnitsNet 4.0.0.0
PluginDomain_B | Newtonsoft.Json 13.0.0.0 | UnitsNet 5.0.0.0
Os cuidados com a fronteira
- Tudo o que aparece na assinatura precisa ser
[Serializable]ouMarshalByRefObject. Task<T>eCancellationTokennão atravessam a fronteira. A fachada precisa ser síncrona.- O
.Shared.dllprecisa estar dentro da pasta de cada plugin, com nome forte, para que a identidade do contrato seja a mesma nos dois domínios. - O lease do Remoting. Objetos remotos não seguem o GC normal. O padrão do .NET Framework é um
InitialLeaseTimede 5 minutos: se o plugin ficar ocioso por mais tempo, a próxima chamada lançaRemotingException(“Object has been disconnected or does not exist at the server”). SobrescreverInitializeLifetimeServiceretornandonulltorna o lease infinito, e o objeto só morre com oAppDomain.Unload. É um dos bugs mais comuns desse padrão. - Chamadas custam. Cada travessia é marshalada, então a fronteira precisa ser coarse-grained: uma chamada por operação, nada de acessar propriedades do proxy dentro de um laço.
“Mas André, e se eu quiser cancelar uma operação que está rodando dentro do plugin?“
Como o CancellationToken não atravessa, você precisa de uma estrutura própria. Uma alternativa (não implementada na demo, apenas um esboço) é criar um objeto de cancelamento dentro do domínio do plugin e entregar ao host apenas o proxy dele:
// Vive no Shared e é instanciado DENTRO do domínio do plugin
public sealed class RemoteCancellation : MarshalByRefObject
{
private readonly CancellationTokenSource _source = new CancellationTokenSource();
public override object InitializeLifetimeService()
{
return null;
}
// O host chama pelo proxy
public void Cancel()
{
_source.Cancel();
}
// Usado apenas do lado do plugin, o token nunca cruza a fronteira
internal CancellationToken Token
{
get { return _source.Token; }
}
}
O host obtém o proxy por um método da fachada (CreateCancellation()), repassa esse proxy na chamada da operação e, quando necessário, chama Cancel() a partir de outra thread. Do lado do plugin, a fachada converte isso em um CancellationToken comum.
Solução de isolamento no .NET moderno: um AssemblyLoadContext por plugin
No .NET moderno, não existe mais AppDomain.CreateDomain para isolamento. A ideia é a mesma, mas com outro mecanismo: um AssemblyLoadContext por plugin.
- Cada ALC é um universo de identidade próprio. Newtonsoft 9 e 13 coexistem no mesmo processo.
- O
AssemblyDependencyResolverresolve as dependências privadas de cada plugin lendo o arquivo.deps.jsongerado na pasta do plugin. - Todos os ALCs vivem no mesmo heap. Não existe proxy, não existe marshaling, não existe serialização e não existe fachada.
Task<T>eCancellationTokenatravessam normalmente, e o custo por chamada é zero.
Todo aquele trabalho com Remoting, MarshalByRefObject e lease simplesmente deixa de existir. O que passa a exigir atenção é outra coisa: a identidade de tipo do contrato.
O PluginLoadContext
internal sealed class PluginLoadContext : AssemblyLoadContext
{
// Assemblies que compõem a fronteira compartilhada.
// Sempre resolvidos pelo Default ALC, nunca pela pasta do plugin.
private static readonly string[] SharedIdentity =
{
"PluginArchitecture.Shared",
"System.Composition.AttributedModel"
};
private readonly AssemblyDependencyResolver _resolver;
public PluginLoadContext(string pluginAssemblyPath, string name)
: base(name, isCollectible: false)
{
// Lê o .deps.json do plugin para localizar as dependências na pasta dele
_resolver = new AssemblyDependencyResolver(pluginAssemblyPath);
}
protected override Assembly Load(AssemblyName assemblyName)
{
// Retornar null delega a resolução ao Default ALC:
// o contrato mantém uma única identidade de tipo
if (SharedIdentity.Contains(assemblyName.Name))
return null;
// Todo o resto vem da pasta do plugin: dependência privada e isolada
var path = _resolver.ResolveAssemblyToPath(assemblyName);
return path is null ? null : LoadFromAssemblyPath(path);
}
protected override nint LoadUnmanagedDll(string unmanagedDllName)
{
// Lembrete: isolamento gerenciado NÃO isola dependência nativa
var path = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
return path is null ? nint.Zero : LoadUnmanagedDllFromPath(path);
}
}
O método Load é, ao mesmo tempo, a linha do isolamento e a linha do compartilhamento. Quando ele retorna LoadFromAssemblyPath(path), a dependência é privada e vive só naquele ALC. Quando ele retorna null, a resolução é delegada ao Default ALC, e aquele assembly passa a ser compartilhado com o host.
Carregando cada plugin no seu próprio contexto
private static readonly ConcurrentDictionary<string, PluginLoadContext> Contexts =
new(StringComparer.OrdinalIgnoreCase);
private static Assembly LoadInIsolation(string folder, string dllPath)
{
var contextName = $"Plugin_{Path.GetFileName(folder)}";
var context = Contexts.GetOrAdd(contextName, name => new PluginLoadContext(dllPath, name));
return context.LoadFromAssemblyPath(dllPath);
}
private static T Resolve<T>(string folder, string key) where T : class
{
foreach (var dll in Directory.GetFiles(folder, "PluginArchitecture.Plugins.*.dll"))
{
var assembly = LoadInIsolation(folder, dll);
foreach (var type in assembly.GetTypes())
{
if (!typeof(T).IsAssignableFrom(type) || type.IsAbstract || type.IsInterface)
continue;
var metadata = type.GetCustomAttribute<PluginMetadataAttribute>();
if (metadata is null || !string.Equals(metadata.Key, key, StringComparison.OrdinalIgnoreCase))
continue;
// O cast funciona porque o contrato foi resolvido pelo Default ALC
return (T)Activator.CreateInstance(type);
}
}
throw new PluginNotFoundException(key, typeof(T).Name, "isolated");
}
A configuração do projeto do plugin
Duas configurações no .csproj do plugin fazem esse modelo funcionar:
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<!-- Copia as dependências NuGet para a pasta de saída do plugin -->
<EnableDynamicLoading>true</EnableDynamicLoading>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="9.0.1" />
<PackageReference Include="UnitsNet" Version="4.152.0" />
</ItemGroup>
<ItemGroup>
<!-- O contrato NÃO pode ser copiado para a pasta do plugin -->
<ProjectReference Include="..\PluginArchitecture.Shared\PluginArchitecture.Shared.csproj">
<Private>false</Private>
<ExcludeAssets>runtime</ExcludeAssets>
</ProjectReference>
</ItemGroup>
EnableDynamicLoadingprepara o projeto para ser carregado como plugin. Entre outras coisas, ele faz com que as dependências NuGet sejam copiadas para a pasta de saída (em uma class library, isso não acontece por padrão), gera o.runtimeconfig.jsone define oRollForwardcomoLatestMinor. O.deps.jsonjá é gerado normalmente pelo build. O ponto é que, sem as DLLs copiadas, oAssemblyDependencyResolveraté sabe quais são as dependências, mas não encontra os arquivos na pasta do plugin.Private=falseimpede que oPluginArchitecture.Shared.dllseja copiado para a pasta do plugin.ExcludeAssets=runtimetem o mesmo efeito sobre os pacotes NuGet que o projeto de contrato referencia (as dependências transitivas), para que eles também não sejam copiados e continuem vindo do host.
O resultado na demo:
Default | Newtonsoft.Json 13.0.0.0
Plugin_A | Newtonsoft.Json 9.0.0.0 | UnitsNet 4.0.0.0
Plugin_B | Newtonsoft.Json 13.0.0.0 | UnitsNet 5.0.0.0
E o Plugin A volta a entregar 1199975 mL, exatamente como foi homologado. A versão que executa passa a ser uma decisão, e não um efeito colateral do layout de uma pasta.
Sobre descarregamento: o ALC suporta Unload() quando criado com isCollectible: true, mas de forma cooperativa. O contexto só é de fato coletado quando não existem mais referências a nenhum tipo ou instância carregados nele. Na demo, os ALCs não são colecionáveis, porque o foco era o conflito de dependências.
O que não deve ser isolado
Isolar tudo não funciona. Host e plugin precisam falar a mesma língua, que é a língua do contrato.
O que obrigatoriamente precisa ser compartilhado é o assembly de contratos, ou seja, o projeto onde ficam as interfaces e os DTOs que aparecem na assinatura do contrato. No .NET moderno, esse assembly é carregado uma única vez, no Default ALC. Todo o resto (a DLL do plugin e as dependências dele) é privado e carregado no ALC do plugin.
No .NET Framework, a regra é diferente. Cada AppDomain é um espaço de memória separado, e o código de um assembly só pode ser executado depois de carregado no domínio que vai usá-lo. Além disso, a própria Microsoft documenta que, em chamadas entre domínios, os metadados do tipo precisam estar disponíveis nos dois lados. Na prática, o assembly de contrato é carregado uma vez em cada AppDomain: no domínio do host e em cada domínio de plugin. O que garante que host e plugin falem a mesma língua ali não é uma instância única do assembly, e sim a mesma identidade (por isso o nome forte no Shared.dll da demo), que permite ao Remoting reconstruir o tipo correto do outro lado da fronteira.
Já no .NET moderno, imagine um PluginLoadContext que apenas consulta o AssemblyDependencyResolver, sem nenhuma regra para o contrato (é assim, por exemplo, o do tutorial oficial da Microsoft). Se o Shared.dll estiver na pasta do plugin, o resolver vai encontrá-lo e carregar uma segunda cópia dentro do ALC do plugin. A partir daí, o mesmo tipo passa a ter duas identidades: o IDocumentConverter que o plugin implementa não é o mesmo IDocumentConverter que o host conhece, porque cada um veio de uma cópia diferente do assembly.
O sintoma depende de como o host procura os plugins. No código deste artigo, que filtra os tipos com IsAssignableFrom antes de instanciar, a falha é silenciosa: o IsAssignableFrom retorna false e o plugin simplesmente não é encontrado. Se o cast for feito diretamente, a exceção é um InvalidCastException com uma das mensagens mais confusas que você vai encontrar, afirmando que uma classe que claramente implementa a interface não pode ser convertida para ela:
Unable to cast object of type 'PluginArchitecture.Plugins.ConverterSystemA.LegacyErpConverter'
to type 'PluginArchitecture.Shared.IDocumentConverter'.
Quando o tipo de origem e o de destino têm o mesmo nome, a mensagem fica ainda mais estranha, no formato “Unable to cast object of type ‘X’ to type ‘X'”.
Existem duas formas de evitar isso, e elas se complementam:
- No projeto do plugin (empacotamento): o
Private=falsee oExcludeAssets=runtimeque mostrei antes. Com eles, oShared.dllnem chega à pasta do plugin. O resolver não o encontra, oLoadretornanulle a resolução cai naturalmente no Default ALC. - No host (a lista
SharedIdentity): oPluginLoadContextretornanullpara os assemblies do contrato antes mesmo de consultar o resolver, independentemente do que exista na pasta.
Se o empacotamento estiver correto, a lista é tecnicamente redundante para o Shared.dll. Mesmo assim, eu recomendo manter as duas, por dois motivos.
O primeiro é quem controla cada uma. O empacotamento depende da disciplina de quem escreve cada plugin. Basta um plugin de terceiro, ou um dotnet publish mal configurado, colocar o Shared.dll na pasta para o problema voltar. A lista vive no host, e o host é a única parte que você controla de verdade. É defesa em profundidade.
O segundo motivo é uma lição que a demo ensinou: a fronteira compartilhada raramente é um único assembly, e nem todo assembly compartilhado fica fora da pasta do plugin. Repare que a lista SharedIdentity tem dois nomes. O atributo PluginMetadataAttribute, que está no contrato, é marcado com [MetadataAttribute], que vem do System.Composition.AttributedModel. Só que o plugin também referencia esse pacote diretamente (para usar o [Export] do MEF 2), então a DLL é copiada para a pasta dele e aparece no .deps.json. Sem a lista, o resolver a carregaria no ALC do plugin, e o problema de identidade apareceria de novo. O mesmo raciocínio vale para qualquer tipo que apareça na assinatura do contrato. Se o seu contrato recebe um ILogger, por exemplo, o assembly que define o ILogger também precisa ser resolvido pelo contexto default: ou ele entra na lista do host, ou cada plugin precisa marcar esse pacote com ExcludeAssets=runtime, voltando a depender da disciplina de cada time.
Outro cuidado de design: na demo, os modelos internos de cada plugin (LegacyOrderDto, PartnerOrderModel) são internal, e o plugin devolve texto (o payload serializado), não o tipo. Se o ConversionResult carregasse um object com a instância do modelo do parceiro, esse tipo viveria no ALC do plugin e o host só conseguiria acessá-lo por reflection, ou tomaria um InvalidCastException. A fronteira passa a ser garantida pelo compilador, e não pela disciplina do time.
Uma curiosidade que vale registrar é a assimetria entre as duas plataformas:
| .NET Framework (AppDomain) | .NET moderno (ALC) | |
|---|---|---|
Shared.dll na pasta do plugin | Obrigatória, o domínio filho sonda a própria base | Proibida, duplicaria a identidade do tipo |
| Objeto atravessa a fronteira | Proxy (MarshalByRefObject) ou cópia ([Serializable]) | Direto, mesma referência, mesmo heap |
[Serializable] nos modelos do contrato | Obrigatório | Desnecessário |
Task<T> / CancellationToken | Não atravessam | Atravessam normalmente |
| Custo por chamada | Marshaling em toda travessia | Zero |
| Configuração por plugin | plugin.config via AppDomainSetup | .deps.json via AssemblyDependencyResolver |
| Descarregar | AppDomain.Unload, síncrono | Unload() cooperativo, com isCollectible: true |
Mesma intenção de isolamento, exigências opostas.
Cuidados e desvantagens
Isolar dependências não é isolar falhas nem garantir segurança
Tudo o que mostrei até aqui resolve conflito de dependências. Isolar falhas e isolar em termos de segurança são outros problemas. Os plugins continuam rodando dentro do mesmo processo do sistema operacional. Um plugin que vaza memória ou que derruba o processo derruba o host junto.
E aqui está o ponto crítico: um plugin roda no seu processo, com todas as permissões dele. Disco, rede, memória e segredos. Quando você permite que alguém suba um plugin e o conecte ao seu host, está dando acesso a tudo isso. Nem AppDomain nem ALC são fronteiras de segurança. O antigo sandbox por AppDomain dependia do CAS (Code Access Security), que a Microsoft desaconselha como fronteira de segurança. A fronteira real precisa vir do sistema operacional ou da virtualização.
Algumas mitigações possíveis:
- Processo separado, com comunicação via IPC (pipes, gRPC ou até a entrada e saída padrão do processo).
- Container ou VM, com regras de segurança próprias.
- Assinatura de código (code signing), validando se o plugin foi assinado com um certificado confiável.
- Allow list de plugins aprovados.
- Menor privilégio, criando fronteiras de acesso em vez de liberar tudo.
Abrir o sistema para extensibilidade não é algo trivial do ponto de vista de segurança. Segurança de plugins é um tema que merece um artigo próprio.
Outros custos
- Complexidade operacional: versionar e diagnosticar N artefatos, cada um com seu ciclo de vida.
- Contrato como dívida: uma mudança de contrato pode quebrar todo o ecossistema.
- Debug e observabilidade ficam mais difíceis.
- Performance: o Reflection na descoberta tem custo, mas, se a carga acontece na inicialização, ele é mínimo. Já o marshaling do AppDomain no .NET Framework acontece a cada chamada e precisa ser medido.
Existe ainda um custo que não aparece à primeira vista: o formato do contrato. A demo tem dois contratos que carregam exatamente a mesma informação. Um recebe um objeto (OrderDocument), o outro recebe 17 parâmetros primitivos e arrays paralelos. O payload produzido é idêntico. A versão primitiva ganha portabilidade (funciona sobre IPC, pipe, gRPC ou processo separado), mas paga caro em ergonomia e segurança de tipos. Na demo, acrescentar a unidade de medida ao modelo canônico custou uma propriedade no objeto do contrato rico e um parâmetro novo em toda a assinatura do contrato primitivo, quebrando os dois plugins de uma vez.
Seis regras para levar
- Contrato fino e estável. Interfaces e tipos simples, expostos para os plugins implementarem.
- Uma pasta por plugin, com todas as suas dependências privadas dentro.
- Um contexto por plugin: ALC no .NET moderno, AppDomain no .NET Framework.
- O contrato é carregado uma única vez, no contexto compartilhado. No .NET moderno, nunca copiado para a pasta do plugin.
- AppDomain cobra marshaling (fronteira síncrona e coarse-grained). ALC não cobra.
- Isolamento não é segurança. Para código não confiável: processo, container ou VM.
Conclusão
Plugin Architecture troca complexidade interna por complexidade de fronteira, e o contrato é onde essa troca é paga. Você tira do core a responsabilidade de conhecer cada variação, cada cliente e cada integração, mas assume o compromisso de manter um contrato estável e de lidar com isolamento, versionamento e diagnóstico de vários artefatos independentes. São complexidades que simplesmente não existem quando tudo vive em uma base de código única.
Na minha opinião, o ponto mais negligenciado desse tema é o isolamento de dependências. Descobrir e carregar plugins é a parte fácil, e é onde a maioria dos conteúdos para. O problema aparece meses depois, quando dois times atualizam a mesma biblioteca em momentos diferentes e o sistema passa a se comportar de forma diferente dependendo da ordem em que os arquivos foram copiados. E, no .NET moderno, esse problema tende a ser silencioso.
Se você quiser ver tudo isso funcionando na prática, com os cenários de falha ruidosa, falha silenciosa e isolamento correto lado a lado, o código completo está no repositório abaixo. Cada solution e cada projeto tem o seu próprio README, explicando o papel de cada peça e o roteiro da demonstração:
https://github.com/andreluizsecco/PluginArchitecture.DotNet.Demo
Mesmo em uma época em que delegamos cada vez mais a escrita de código para a inteligência artificial, entender como as coisas funcionam por baixo continua sendo o que diferencia uma decisão arquitetural consciente de um bug que ninguém consegue explicar. Espero que este artigo te ajude a tomar essas decisões com mais clareza.
Se quiser dar uma olhada em 2 vídeos sobre o assunto, um mais teórico e outro mostrando e comentando a demonstração, fique a vontade:






