Como organizar uma solução .NET grande sem transformar o projeto em um caos

Como organizar uma solução .NET grande sem transformar o projeto em um caos
Publicado em 18/10/2026 por Admin

Organizar uma solução .NET pequena costuma ser relativamente simples. No início, criar uma API, adicionar algumas classes, configurar o Entity Framework Core e implementar as primeiras funcionalidades pode ser suficiente para colocar a aplicação em funcionamento.

O problema aparece quando o sistema começa a crescer.

Novas funcionalidades são adicionadas, as regras de negócio ficam mais complexas, diferentes desenvolvedores passam a trabalhar no mesmo código e aquilo que antes parecia organizado começa a se transformar em um conjunto de dependências difíceis de entender.

Classes acumulam responsabilidades, serviços passam a conhecer detalhes de outras funcionalidades e pequenas alterações provocam efeitos inesperados em diferentes partes da aplicação.

Nesse cenário, a organização da solução deixa de ser apenas uma questão estética. Ela passa a influenciar diretamente a velocidade de desenvolvimento, a facilidade de manutenção e a capacidade de evoluir o sistema com segurança.

Mas como organizar uma solução .NET grande sem criar uma arquitetura excessivamente complexa?

A resposta não está em multiplicar projetos, adotar todos os padrões disponíveis ou transformar cada classe em uma abstração. O objetivo é estabelecer limites claros, separar responsabilidades e permitir que o sistema cresça sem perder a capacidade de ser compreendido.

Neste artigo, vamos explorar como estruturar uma solução .NET de grande porte, quando separar projetos, como organizar funcionalidades, controlar dependências e evitar os erros arquiteturais mais comuns.

1. Por que soluções .NET ficam difíceis de manter?

Antes de discutir a estrutura ideal, precisamos entender como muitos projetos começam a perder organização.

Uma aplicação pode funcionar perfeitamente durante os primeiros meses e, ainda assim, acumular problemas estruturais que só se tornam evidentes quando o número de funcionalidades aumenta.

Entre os problemas mais comuns estão os seguintes.

1.1. Classes com responsabilidades demais

Imagine uma classe chamada OrderService responsável por:

  • Validar pedidos.

  • Calcular preços.

  • Aplicar descontos.

  • Consultar produtos no banco de dados.

  • Atualizar o estoque.

  • Enviar e-mails.

  • Publicar eventos.

  • Registrar informações de auditoria.

Inicialmente, concentrar essas operações em uma única classe pode parecer uma maneira rápida de desenvolver.

Entretanto, à medida que as regras de negócio aumentam, essa classe passa a ser modificada por diferentes motivos e acumula dependências de vários componentes.

O resultado é um serviço difícil de testar, compreender e modificar sem provocar efeitos colaterais.

Uma solução melhor é distribuir as responsabilidades entre componentes que representem conceitos distintos do sistema.

Isso não significa criar uma classe para cada pequena operação. Significa evitar que um único componente se transforme no responsável por praticamente tudo.

1.2. Dependências espalhadas pela aplicação

Outro problema frequente é permitir que qualquer parte da aplicação acesse diretamente qualquer outra.

Por exemplo, um controller pode consultar o banco de dados, manipular regras de negócio, enviar notificações e atualizar informações de outros módulos.

Quando esse padrão se repete, as dependências deixam de seguir uma estrutura previsível.

Uma alteração no módulo de estoque pode exigir modificações no módulo de pedidos, no módulo financeiro e em diferentes controllers.

Quanto maior o número de dependências cruzadas, maior o risco de uma mudança aparentemente simples afetar funcionalidades que deveriam ser independentes.

1.3. Estrutura baseada exclusivamente no tipo de arquivo

Considere uma estrutura como esta:

MyApplication/
├── Controllers/
├── Services/
├── Repositories/
├── DTOs/
├── Models/
├── Validators/
└── Helpers/

Essa organização não é necessariamente incorreta. Para aplicações pequenas, pode funcionar muito bem.

O problema surge quando o sistema possui dezenas de funcionalidades.

Imagine que seja necessário modificar o processo de criação de pedidos. O desenvolvedor precisa procurar informações em Controllers, Services, DTOs, Validators, Repositories e Models.

Se essas pastas tiverem centenas de arquivos, entender uma única funcionalidade pode exigir navegar por boa parte da solução.

A organização por tipo de arquivo facilita a localização de determinadas categorias de classes, mas não garante que as funcionalidades estejam separadas de maneira coerente.

Em aplicações maiores, organizar o código também por domínio ou funcionalidade pode tornar a navegação muito mais simples.

2. Comece pela organização da solução, não pela quantidade de projetos

Um dos equívocos mais comuns na arquitetura de aplicações .NET é acreditar que um sistema bem organizado precisa ter muitos projetos.

Isso não é verdade.

Uma solução com vinte projetos e dependências mal definidas pode ser muito mais difícil de manter do que outra com cinco projetos bem estruturados.

A quantidade de projetos deve resultar das necessidades arquiteturais da aplicação, e não de uma tentativa de tornar a estrutura visualmente mais sofisticada.

Uma solução pode começar com a seguinte organização:

MyApplication.sln
│
├── src/
│   ├── MyApplication.Api/
│   ├── MyApplication.Application/
│   ├── MyApplication.Domain/
│   └── MyApplication.Infrastructure/
│
├── tests/
│   ├── MyApplication.UnitTests/
│   └── MyApplication.IntegrationTests/
│
└── docs/

Essa estrutura representa uma possibilidade para uma aplicação organizada em camadas, com responsabilidades separadas.

Cada projeto possui um propósito específico.

MyApplication.Api

É o ponto de entrada HTTP da aplicação.

Normalmente, contém:

  • Controllers ou Minimal APIs.

  • Configuração do pipeline HTTP.

  • Autenticação e autorização.

  • Configuração de injeção de dependência.

  • Tratamento de exceções HTTP.

  • Configurações específicas da hospedagem.

A API deve ser responsável por receber requisições, encaminhá-las para os componentes apropriados e transformar os resultados em respostas HTTP.

Ela não deve concentrar todas as regras de negócio do sistema.

MyApplication.Application

Contém os casos de uso e a coordenação das operações da aplicação.

Por exemplo:

  • Criar um pedido.

  • Cancelar uma encomenda.

  • Consultar o histórico de compras.

  • Aprovar uma fatura.

  • Atualizar os dados de um cliente.

É nessa camada que podemos organizar handlers, serviços de aplicação, validadores e contratos necessários para executar os casos de uso.

A estrutura exata depende da abordagem adotada. Uma aplicação baseada em serviços de aplicação não precisa necessariamente utilizar MediatR ou outro mecanismo de mensageria interna.

O importante é manter os casos de uso organizados e evitar que a camada de aplicação dependa diretamente dos detalhes de infraestrutura.

MyApplication.Domain

Representa o núcleo das regras de negócio que justificam a existência do sistema.

Pode conter:

  • Entidades.

  • Objetos de valor.

  • Agregados.

  • Regras de domínio.

  • Eventos de domínio.

  • Interfaces de domínio, quando apropriado.

Essa camada não precisa conhecer Entity Framework Core, ASP.NET Core ou os detalhes de comunicação com serviços externos.

Entretanto, nem toda aplicação precisa de um domínio rico, cheio de entidades complexas e objetos de valor.

Se o sistema possui regras de negócio simples, uma modelagem mais enxuta pode ser suficiente.

O objetivo é representar corretamente o domínio, e não adicionar complexidade sem necessidade.

MyApplication.Infrastructure

Concentra os detalhes técnicos necessários para executar os casos de uso.

Entre as responsabilidades possíveis estão:

  • Persistência com Entity Framework Core ou Dapper.

  • Implementação de repositórios.

  • Integrações com APIs externas.

  • Publicação de mensagens.

  • Armazenamento de arquivos.

  • Implementações de notificações.

  • Acesso a serviços externos.

Essa camada implementa os contratos definidos pelas camadas internas quando isso faz parte do desenho arquitetural adotado.

A regra geral é evitar que o domínio e os casos de uso fiquem diretamente acoplados a tecnologias específicas de infraestrutura.

3. Defina uma direção clara para as dependências

Separar a aplicação em projetos é apenas o primeiro passo.

O verdadeiro benefício aparece quando estabelecemos regras sobre quem pode depender de quem.

Em uma arquitetura baseada em camadas e inspirada nos princípios da Clean Architecture, podemos estabelecer a seguinte direção:

API ────────────────> Application
Infrastructure ────> Application
Application ───────> Domain
Domain ────────────> Sem dependências de infraestrutura

Esse diagrama representa uma direção simplificada para as referências entre projetos.

A API também pode precisar de referências ao projeto de infraestrutura para registrar suas implementações durante a composição da aplicação. Essa dependência deve ficar restrita à configuração de inicialização, quando essa abordagem for adotada.

O ponto principal é que as regras de negócio não devem depender dos detalhes técnicos utilizados para executá-las.

Por exemplo, considere um contrato de persistência:

public interface IOrderRepository
{
    Task<Order?> GetByIdAsync(
        Guid id,
        CancellationToken cancellationToken);

    Task AddAsync(
        Order order,
        CancellationToken cancellationToken);
}

Esse contrato pode ser definido em uma camada interna apropriada, dependendo do modelo adotado.

Na infraestrutura, uma implementação concreta pode utilizar Entity Framework Core:

public sealed class OrderRepository : IOrderRepository
{
    private readonly AppDbContext _context;

    public OrderRepository(AppDbContext context)
    {
        _context = context;
    }

    public async Task<Order?> GetByIdAsync(
        Guid id,
        CancellationToken cancellationToken)
    {
        return await _context.Orders
            .FirstOrDefaultAsync(
                order => order.Id == id,
                cancellationToken);
    }

    public async Task AddAsync(
        Order order,
        CancellationToken cancellationToken)
    {
        await _context.Orders.AddAsync(
            order,
            cancellationToken);
    }
}

O exemplo pressupõe que Order e IOrderRepository estejam disponíveis para a infraestrutura e que AppDbContext seja configurado com o Entity Framework Core.

Observe que a aplicação pode conhecer o contrato sem precisar conhecer a implementação concreta.

Isso permite substituir detalhes de persistência sem obrigar os casos de uso a conhecer cada mudança tecnológica.

É importante, porém, não transformar essa ideia em uma regra que exige interfaces para todas as classes.

Uma abstração é útil quando estabelece um limite relevante, permite trocar uma implementação ou facilita o isolamento de uma dependência. Criar interfaces sem uma necessidade concreta pode aumentar o número de arquivos sem melhorar a arquitetura.

4. Organize o código por funcionalidade, não apenas por camada

Quando uma aplicação começa a crescer, uma das melhorias mais úteis é organizar as funcionalidades de maneira explícita.

Em vez de manter todos os serviços em uma pasta global, podemos agrupar os componentes relacionados a cada área do negócio.

Por exemplo:

MyApplication.Application/
├── Orders/
│   ├── CreateOrder/
│   │   ├── CreateOrderCommand.cs
│   │   ├── CreateOrderHandler.cs
│   │   ├── CreateOrderValidator.cs
│   │   └── CreateOrderResult.cs
│   │
│   ├── CancelOrder/
│   │   ├── CancelOrderCommand.cs
│   │   └── CancelOrderHandler.cs
│   │
│   └── GetOrder/
│       ├── GetOrderQuery.cs
│       ├── GetOrderHandler.cs
│       └── OrderDetailsDto.cs
│
├── Customers/
│   ├── CreateCustomer/
│   └── UpdateCustomer/
│
└── Inventory/
    ├── ReserveStock/
    └── ReleaseStock/

Essa organização aproxima os arquivos que precisam ser modificados em conjunto.

Se um desenvolvedor precisa alterar a criação de pedidos, consegue localizar os componentes envolvidos sem procurar em várias pastas globais.

Essa abordagem é frequentemente chamada de organização vertical por funcionalidade ou feature folders.

Ela combina bem com aplicações que utilizam CQRS, mas não depende desse padrão.

Também é possível organizar as funcionalidades sem criar classes chamadas Command, Query ou Handler.

O princípio é mais importante do que a nomenclatura: agrupar o código de acordo com a responsabilidade que ele executa.

Feature folders ou organização por camadas?

As duas abordagens podem funcionar.

A organização tradicional por camadas é simples de compreender e pode ser suficiente para aplicações pequenas e médias.

A organização por funcionalidade facilita a navegação em sistemas com muitos casos de uso, principalmente quando cada funcionalidade possui regras, validações e modelos próprios.

Uma alternativa é combinar as duas abordagens:

  • Projetos separados por responsabilidade arquitetural.

  • Pastas internas organizadas por funcionalidade.

  • Componentes compartilhados apenas quando existe uma necessidade real de compartilhamento.

Assim, a solução mantém seus limites arquiteturais sem transformar a estrutura de diretórios em um obstáculo para o desenvolvimento.

5. Considere um monólito modular antes de dividir tudo em microservices

Uma aplicação grande não precisa ser distribuída em vários serviços independentes.

Na verdade, para muitos sistemas empresariais, um monólito modular é uma alternativa bastante eficiente.

Nesse modelo, a aplicação continua sendo implantada como uma unidade principal, mas o código é organizado em módulos com responsabilidades e limites bem definidos.

Por exemplo:

MyApplication.sln
│
├── src/
│   ├── MyApplication.Api/
│   │
│   ├── Modules/
│   │   ├── Orders/
│   │   │   ├── Orders.Domain/
│   │   │   ├── Orders.Application/
│   │   │   └── Orders.Infrastructure/
│   │   │
│   │   ├── Customers/
│   │   │   ├── Customers.Domain/
│   │   │   ├── Customers.Application/
│   │   │   └── Customers.Infrastructure/
│   │   │
│   │   └── Inventory/
│   │       ├── Inventory.Domain/
│   │       ├── Inventory.Application/
│   │       └── Inventory.Infrastructure/
│   │
│   └── MyApplication.Bootstrap/
│
└── tests/

Essa estrutura é ilustrativa. Em uma aplicação real, os módulos também podem ser organizados em menos projetos, dependendo do tamanho e das necessidades de isolamento.

O módulo de pedidos, por exemplo, deve ser responsável pelas regras e operações relacionadas aos pedidos.

O módulo de estoque deve controlar as regras de disponibilidade e reserva de produtos.

O módulo de clientes deve gerenciar os conceitos e as operações que pertencem a essa área.

Quando um módulo precisa de informações de outro, a comunicação deve ocorrer por contratos explícitos, e não por acesso indiscriminado às classes internas.

Isso ajuda a evitar que os módulos se transformem em um conjunto de pastas aparentemente separadas, mas completamente dependentes umas das outras.

Como definir os limites dos módulos?

Uma boa maneira de começar é identificar as principais capacidades de negócio da aplicação.

Em um sistema de comércio eletrônico, podemos encontrar:

  • Catálogo de produtos.

  • Clientes.

  • Pedidos.

  • Estoque.

  • Pagamentos.

  • Entregas.

Essas áreas são candidatas a módulos, mas não devem ser transformadas automaticamente em módulos independentes.

A decisão depende das regras de negócio, das responsabilidades, das dependências e da necessidade de isolamento.

Por exemplo, um módulo de pagamentos pode precisar integrar-se a um provedor externo, enquanto o módulo de pedidos coordena o processo de compra.

Essa relação deve ser explícita. O módulo de pedidos não deveria depender de detalhes internos do provedor de pagamentos.

É importante também evitar um módulo genérico chamado Common ou Shared que se transforme em um local para qualquer classe reutilizada.

Compartilhar tipos e funcionalidades pode ser necessário, mas o compartilhamento indiscriminado cria dependências que dificultam a evolução independente dos módulos.

Como impedir que os módulos ultrapassem seus limites?

A separação por pastas não é suficiente.

Se o módulo de pedidos puder acessar livremente todas as classes internas do módulo de estoque, a modularidade existirá apenas visualmente.

Algumas medidas ajudam a estabelecer limites mais consistentes:

  • Expor apenas os contratos necessários para a comunicação entre módulos.

  • Evitar referências diretas às implementações internas de outros módulos.

  • Controlar as referências entre projetos.

  • Utilizar namespaces e modificadores de acesso adequados.

  • Criar testes arquiteturais quando a quantidade de módulos justificar esse investimento.

  • Documentar as dependências permitidas entre os módulos.

O objetivo é fazer com que os limites arquiteturais sejam verificáveis, e não apenas convenções que todos precisam lembrar.

6. Não transforme todas as funcionalidades em abstrações genéricas

Ao organizar uma solução grande, é comum tentar eliminar qualquer repetição de código.

Essa intenção é compreensível, mas pode levar à criação de componentes excessivamente genéricos.

Imagine uma aplicação com diferentes operações:

  • Criar pedidos.

  • Aprovar pagamentos.

  • Atualizar clientes.

  • Reservar estoque.

Cada operação possui suas próprias regras.

Uma tentativa de padronizar tudo pode resultar em serviços genéricos, interfaces com muitos métodos e classes que recebem dezenas de parâmetros para atender a situações diferentes.

O código passa a ter menos duplicação visual, mas fica mais difícil de entender.

Uma abstração deve simplificar o sistema como um todo.

Se ela exige conhecer diversas configurações, exceções e comportamentos especiais para ser utilizada corretamente, talvez esteja resolvendo o problema errado.

Reutilize comportamentos que realmente são comuns

Considere uma funcionalidade de validação de pedidos e outra de cadastro de clientes.

Ambas podem precisar verificar dados obrigatórios, mas isso não significa que devam compartilhar a mesma classe de serviço de aplicação.

A validação pode utilizar componentes compartilhados quando existir uma regra realmente comum.

Por outro lado, as regras específicas de cada funcionalidade devem permanecer próximas de seus respectivos casos de uso.

O mesmo vale para repositórios genéricos.

Um repositório genérico pode ser útil para operações simples e repetitivas, mas nem sempre representa adequadamente consultas complexas ou operações específicas de um agregado.

O padrão deve ser escolhido conforme a necessidade real, e não aplicado de maneira automática.

7. Tenha cuidado com a pasta Shared ou Common

Poucas decisões parecem tão inofensivas quanto criar uma pasta chamada Shared.

Inicialmente, ela pode conter algumas classes utilizadas em diferentes partes da aplicação.

Com o tempo, começa a receber:

  • DTOs.

  • Classes utilitárias.

  • Interfaces.

  • Serviços de aplicação.

  • Validadores.

  • Extensões.

  • Modelos de banco de dados.

  • Regras de negócio.

  • Componentes que apenas parecem genéricos.

Quando isso acontece, Shared deixa de representar um conjunto coerente de funcionalidades e passa a funcionar como um depósito de código.

O resultado é um componente que quase todos os módulos precisam referenciar.

Isso aumenta o acoplamento e torna as alterações potencialmente mais arriscadas.

O que realmente deve ser compartilhado?

Um componente compartilhado deve possuir uma justificativa clara.

Pode fazer sentido compartilhar:

  • Tipos primitivos específicos da aplicação.

  • Abstrações técnicas utilizadas por diferentes módulos.

  • Componentes de infraestrutura realmente transversais.

  • Funcionalidades que possuem semântica comum.

  • Bibliotecas internas com responsabilidades bem definidas.

Entretanto, uma classe não deve ser colocada em Shared apenas porque duas funcionalidades utilizam um objeto com propriedades semelhantes.

Dois módulos podem possuir conceitos parecidos sem que eles representem o mesmo conceito de negócio.

Por exemplo, o endereço de cobrança e o endereço de entrega podem compartilhar alguns campos, mas possuir regras e ciclos de vida diferentes.

Antes de unificá-los, é necessário entender se representam realmente o mesmo conceito.

Uma boa regra prática é simples: compartilhe código quando existir uma responsabilidade comum, e não apenas quando existir uma semelhança estrutural.

8. Separe DTOs, entidades e modelos de persistência quando isso fizer sentido

Outro problema frequente em soluções .NET grandes é utilizar a mesma classe para representar tudo.

Uma entidade do domínio passa a ser utilizada diretamente na API, na serialização JSON, na persistência e em diferentes casos de uso.

Essa estratégia pode funcionar em aplicações simples, mas se torna problemática quando os modelos possuem responsabilidades diferentes.

Considere uma entidade de cliente:

public class Customer
{
    public Guid Id { get; set; }

    public string Name { get; set; } = string.Empty;

    public string Email { get; set; } = string.Empty;

    public bool IsActive { get; set; }
}

Se essa classe for utilizada diretamente como entrada para um endpoint de criação de clientes, o contrato HTTP ficará excessivamente ligado ao modelo interno.

Além disso, alterações na entidade podem modificar involuntariamente o formato dos dados expostos pela API.

Uma alternativa é definir um DTO específico:

public sealed record CreateCustomerRequest(
    string Name,
    string Email);

O endpoint recebe o contrato de entrada e encaminha os dados para o caso de uso responsável pela criação.

O resultado pode utilizar outro DTO, contendo apenas as informações necessárias para a resposta.

Essa separação permite evoluir os contratos públicos e os modelos internos de maneira mais independente.

Mas é necessário criar um DTO para tudo?

Não.

A separação deve existir quando os modelos representam responsabilidades diferentes ou quando o desacoplamento traz benefícios concretos.

Em uma aplicação simples, criar classes adicionais para cada operação de leitura pode aumentar a complexidade sem produzir vantagens significativas.

Em sistemas maiores, com contratos públicos estáveis, regras de domínio complexas e diferentes necessidades de consulta, separar os modelos costuma ser mais importante.

O critério deve ser a necessidade de evolução independente, e não a quantidade de classes que a arquitetura consegue produzir.

9. Organize os casos de uso sem exagerar na complexidade

Em soluções maiores, uma das estratégias mais úteis é representar as operações do sistema por meio de casos de uso explícitos.

Por exemplo, em vez de criar um serviço genérico de pedidos com dezenas de métodos, podemos organizar operações distintas:

Orders/
├── CreateOrder/
├── CancelOrder/
├── ConfirmOrder/
├── GetOrderById/
└── SearchOrders/

Cada funcionalidade pode possuir seus próprios componentes, como:

  • Modelo de entrada.

  • Validação.

  • Handler ou serviço de aplicação.

  • Modelo de saída.

  • Testes.

Essa organização ajuda a manter cada operação relativamente independente.

Também facilita identificar quais componentes precisam ser modificados quando uma regra de negócio muda.

CQRS é obrigatório?

Não.

O padrão CQRS propõe separar responsabilidades de leitura e escrita. Essa separação pode ajudar a organizar aplicações com diferentes necessidades de consulta e alteração de dados.

Entretanto, não é obrigatório implementar CQRS completo para organizar os casos de uso.

Uma aplicação pode utilizar classes específicas para cada operação sem adotar mensageria, bibliotecas de mediação ou modelos de leitura separados.

Também é possível utilizar CQRS em determinados módulos e manter outros com uma abordagem mais simples.

O mais importante é que a organização reflita as necessidades reais do sistema.

Se uma operação pode ser implementada de maneira clara com um serviço de aplicação convencional, não existe motivo para introduzir uma infraestrutura de handlers apenas por preferência arquitetural.

10. Controle o acesso ao banco de dados

Em aplicações grandes, a persistência costuma ser uma das principais fontes de acoplamento.

Quando qualquer controller, serviço ou módulo pode acessar diretamente o contexto do Entity Framework Core, torna-se difícil controlar onde as consultas são realizadas e quais regras precisam ser respeitadas.

Isso não significa que DbContext seja um componente inadequado. Pelo contrário, ele é uma ferramenta importante do Entity Framework Core.

O problema está em permitir que o acesso à persistência aconteça sem limites claros.

Defina uma estratégia de acesso aos dados

Uma aplicação pode utilizar diferentes estratégias:

  • Repositórios específicos para operações de domínio.

  • Consultas com Entity Framework Core diretamente nos casos de uso.

  • Dapper para consultas específicas.

  • Serviços de persistência separados por módulo.

  • Uma combinação dessas abordagens.

A escolha depende do modelo de domínio, da complexidade das consultas e das necessidades de manutenção.

Por exemplo, uma operação de criação de pedido pode utilizar um repositório para carregar e persistir entidades envolvidas em regras de negócio.

Já uma consulta de relatório pode utilizar uma projeção específica do Entity Framework Core ou uma consulta SQL com Dapper, caso exista uma justificativa técnica para isso.

Não é necessário obrigar todas as consultas a atravessar a mesma abstração.

O importante é manter o acesso aos dados previsível, testável e coerente com as responsabilidades de cada módulo.

Evite transformar o DbContext em um componente global

Um contexto compartilhado pode ser apropriado em determinadas arquiteturas, mas exige cuidado quando a aplicação possui módulos com responsabilidades diferentes.

É importante compreender o impacto de entidades relacionadas, configurações de mapeamento, transações e operações que modificam múltiplos conceitos de negócio.

Em um monólito modular, pode ser interessante estabelecer limites de persistência por módulo, inclusive utilizando contextos distintos quando isso ajudar a preservar a independência dos modelos.

Essa decisão precisa considerar as necessidades transacionais e a complexidade operacional.

Separar contextos não garante modularidade automaticamente, assim como compartilhar um contexto não torna uma aplicação necessariamente mal projetada.

A questão central é manter claros os limites de responsabilidade e as regras de acesso aos dados.

11. Estruture os testes de acordo com a arquitetura

Uma solução grande precisa de testes que ajudem a preservar seu comportamento durante a evolução.

Entretanto, não basta criar um projeto chamado Tests e colocar todos os testes em uma única pasta.

Uma organização possível é:

tests/
├── MyApplication.Domain.UnitTests/
├── MyApplication.Application.UnitTests/
├── MyApplication.Infrastructure.IntegrationTests/
├── MyApplication.Api.IntegrationTests/
└── MyApplication.ArchitectureTests/

Essa estrutura é apenas uma sugestão. Projetos pequenos podem agrupar diferentes categorias de testes sem perder clareza.

Testes unitários

São úteis para verificar regras de negócio e comportamentos que podem ser testados de maneira isolada.

Por exemplo:

  • Cálculo de descontos.

  • Validação de estados de um pedido.

  • Regras para cancelamento.

  • Verificação de limites comerciais.

  • Comportamento de serviços de aplicação isolados.

O objetivo é encontrar problemas rapidamente, sem depender de toda a infraestrutura da aplicação.

Testes de integração

São importantes para verificar se diferentes componentes funcionam corretamente em conjunto.

Podem validar:

  • Consultas e gravações no banco de dados.

  • Mapeamentos do Entity Framework Core.

  • Integração com APIs externas.

  • Configuração de autenticação.

  • Comportamento de endpoints HTTP.

  • Publicação e consumo de mensagens.

Esses testes ajudam a identificar problemas que não aparecem quando cada componente é testado isoladamente.

Testes arquiteturais

Em aplicações com muitos módulos, pode ser interessante automatizar algumas regras de dependência.

Por exemplo, podemos estabelecer que o domínio não deve depender da infraestrutura ou que um módulo não deve referenciar diretamente as classes internas de outro.

Uma biblioteca como ArchUnitNET pode ajudar a implementar determinadas verificações arquiteturais em .NET.

A adoção desse tipo de ferramenta deve acompanhar a necessidade do projeto. Em soluções pequenas, regras simples de revisão e uma estrutura bem definida podem ser suficientes.

Em soluções grandes, automatizar as verificações reduz a possibilidade de a arquitetura se deteriorar silenciosamente.

12. Centralize as configurações compartilhadas sem criar acoplamento desnecessário

Uma solução .NET com muitos projetos pode acumular configurações repetidas.

Por exemplo:

  • Versões de pacotes NuGet.

  • Configurações de compilação.

  • Regras de análise estática.

  • Convenções de código.

  • Configurações de nullable reference types.

O MSBuild permite centralizar parte dessas configurações em arquivos compartilhados.

Dois arquivos úteis são Directory.Build.props e Directory.Packages.props.

O primeiro pode centralizar propriedades de compilação aplicáveis aos projetos dentro de determinado diretório.

O segundo permite utilizar o gerenciamento centralizado de versões de pacotes NuGet.

Um exemplo de Directory.Build.props:

<Project>
  <PropertyGroup>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
  </PropertyGroup>
</Project>

Essas configurações podem ser úteis para estabelecer um padrão comum.

Entretanto, TreatWarningsAsErrors pode exigir ajustes em projetos existentes, principalmente durante migrações ou quando existem avisos de compilação acumulados.

Por isso, antes de ativar essa opção em uma solução legada, avalie o impacto e considere introduzi-la gradualmente.

Também é importante lembrar que as propriedades do MSBuild podem ser sobrescritas em determinados níveis da configuração. A centralização facilita a consistência, mas não elimina a necessidade de compreender a configuração efetiva de cada projeto.

O objetivo é evitar diferenças acidentais entre projetos, mantendo espaço para exceções justificadas.

13. Padronize nomes e convenções de código

Em uma solução desenvolvida por várias pessoas, a consistência ajuda a reduzir o esforço necessário para compreender o código.

Isso inclui convenções de nomenclatura, organização de namespaces, nomes de projetos e estrutura de funcionalidades.

Por exemplo, uma solução pode adotar convenções como:

  • CreateOrderCommand para representar uma operação de escrita em uma abordagem baseada em comandos.

  • GetOrderByIdQuery para representar uma consulta.

  • CreateOrderHandler para implementar o processamento de um comando.

  • OrderRepository para uma implementação específica de persistência.

  • CreateOrderRequest para o contrato HTTP de entrada.

  • OrderResponse para o contrato HTTP de saída.

Esses nomes não são obrigatórios nem universais.

O importante é escolher convenções que façam sentido para a arquitetura e aplicá-las de maneira consistente.

Também vale a pena utilizar ferramentas de análise estática e formatação automática para reduzir divergências desnecessárias.

O arquivo .editorconfig, por exemplo, pode definir convenções de estilo para diferentes ferramentas de desenvolvimento.

Quanto menos tempo a equipe gastar discutindo detalhes triviais de formatação, mais atenção poderá dedicar às decisões que realmente influenciam a qualidade do software.

14. Evite projetos gigantes e projetos minúsculos demais

Outro desafio está em encontrar o equilíbrio entre separar responsabilidades e fragmentar excessivamente a solução.

Um único projeto contendo todos os componentes da aplicação pode dificultar a aplicação de limites arquiteturais.

Por outro lado, criar um projeto para cada pequena funcionalidade aumenta o número de referências, configurações e pontos de manutenção.

Cada projeto adicional pode trazer custos relacionados a:

  • Configuração de compilação.

  • Referências entre assemblies.

  • Gestão de dependências.

  • Organização dos testes.

  • Configuração da injeção de dependência.

  • Evolução das interfaces públicas.

  • Manutenção da estrutura da solução.

Isso não significa que projetos adicionais devam ser evitados.

Eles são importantes quando estabelecem fronteiras reais, ajudam a controlar dependências ou permitem que diferentes componentes evoluam de maneira independente.

A pergunta mais útil não é quantos projetos uma solução deveria ter.

É: qual responsabilidade ou limite arquitetural justifica a existência de cada projeto?

Se não existe uma resposta clara, talvez a separação não seja necessária.

15. Como organizar uma solução .NET que já está desorganizada?

Nem sempre é possível começar um sistema do zero.

Em aplicações empresariais, é comum encontrar soluções antigas com dezenas de projetos, dependências históricas e regras de negócio distribuídas por diferentes camadas.

Nesses casos, tentar reorganizar tudo de uma só vez costuma ser arriscado.

Uma abordagem incremental tende a ser mais segura.

Etapa 1: mapeie a estrutura existente

Antes de mover arquivos ou criar novos projetos, identifique:

  • Os principais módulos de negócio.

  • Os projetos existentes.

  • As dependências entre assemblies.

  • Os pontos de acesso ao banco de dados.

  • As integrações externas.

  • As áreas com maior incidência de alterações.

  • Os componentes que concentram muitas responsabilidades.

O objetivo é entender como o sistema realmente funciona, e não apenas como deveria funcionar segundo a documentação.

Etapa 2: identifique os pontos de maior acoplamento

Nem todas as áreas da aplicação precisam ser reorganizadas imediatamente.

Comece pelos componentes que dificultam o desenvolvimento de novas funcionalidades ou que provocam problemas recorrentes.

Uma classe excessivamente complexa, um módulo com muitas dependências cruzadas ou um conjunto de serviços que mistura regras de negócio e infraestrutura podem representar bons pontos de partida.

Priorize as mudanças que reduzem riscos e facilitam o trabalho futuro.

Etapa 3: estabeleça limites para novas funcionalidades

Mesmo que o código antigo continue organizado de maneira imperfeita, as novas funcionalidades podem seguir uma estrutura melhor definida.

Por exemplo, uma nova área de negócio pode começar com:

  • Casos de uso organizados por funcionalidade.

  • Dependências explícitas.

  • Testes para as regras mais importantes.

  • Contratos claros para integrações.

  • Separação entre modelos internos e contratos HTTP quando necessário.

Dessa maneira, a solução melhora progressivamente sem exigir uma reescrita completa.

Etapa 4: refatore com testes e entregas pequenas

Mover classes entre projetos, alterar interfaces e reorganizar a persistência pode modificar o comportamento do sistema.

Antes de realizar mudanças significativas, procure estabelecer testes que cubram as regras mais importantes.

Em seguida, execute a refatoração em etapas pequenas, validando a compilação e os testes a cada alteração relevante.

Quando possível, mantenha as mudanças estruturais separadas das alterações funcionais. Isso facilita a revisão do código e ajuda a identificar a origem de eventuais problemas.

Etapa 5: automatize as regras que precisam ser preservadas

Depois de estabelecer uma estrutura mais consistente, avalie quais regras arquiteturais merecem ser verificadas automaticamente.

Pode ser útil impedir referências indevidas entre módulos, padronizar configurações de compilação e verificar a integridade das principais funcionalidades.

A arquitetura não deve depender exclusivamente da memória dos desenvolvedores.

Quanto mais previsíveis forem as regras de organização, menor será o esforço necessário para preservar a qualidade ao longo do tempo.

16. Uma estrutura prática para começar

Para uma API .NET que possui diferentes funcionalidades, regras de negócio relevantes e necessidade de manutenção contínua, uma estrutura inicial pode ser relativamente simples:

MyApplication.sln
│
├── src/
│   ├── MyApplication.Api/
│   │   ├── Controllers/
│   │   ├── Middleware/
│   │   ├── Configuration/
│   │   └── Program.cs
│   │
│   ├── MyApplication.Application/
│   │   ├── Orders/
│   │   │   ├── CreateOrder/
│   │   │   └── CancelOrder/
│   │   ├── Customers/
│   │   └── Abstractions/
│   │
│   ├── MyApplication.Domain/
│   │   ├── Orders/
│   │   ├── Customers/
│   │   └── Shared/
│   │
│   └── MyApplication.Infrastructure/
│       ├── Persistence/
│       ├── Integrations/
│       └── DependencyInjection.cs
│
├── tests/
│   ├── MyApplication.UnitTests/
│   ├── MyApplication.IntegrationTests/
│   └── MyApplication.ArchitectureTests/
│
├── Directory.Build.props
├── Directory.Packages.props
└── .editorconfig

Essa estrutura não deve ser tratada como um modelo obrigatório.

Uma aplicação com regras de negócio simples pode não precisar de um projeto Domain separado.

Uma aplicação com muitos módulos pode se beneficiar de projetos ou assemblies específicos para cada área.

Um sistema que possui múltiplos serviços de hospedagem pode precisar de uma organização diferente para os pontos de entrada.

A estrutura deve acompanhar a complexidade real da aplicação.

O importante é começar com limites claros e permitir que a arquitetura evolua quando surgirem necessidades concretas.

17. Checklist para avaliar a organização da sua solução .NET

Se você trabalha em uma aplicação que está crescendo, algumas perguntas ajudam a identificar problemas arquiteturais.

Organização do código

  • As funcionalidades podem ser localizadas rapidamente?

  • Os componentes relacionados a um caso de uso estão próximos?

  • Existem classes que concentram responsabilidades demais?

  • A pasta Shared possui uma finalidade bem definida?

Dependências

  • As regras de negócio dependem diretamente de tecnologias de infraestrutura?

  • Os módulos acessam indiscriminadamente as implementações internas uns dos outros?

  • As dependências entre projetos seguem uma direção compreensível?

  • Existe uma maneira de verificar as principais regras arquiteturais?

Persistência

  • Está claro quais componentes podem acessar o banco de dados?

  • As consultas complexas estão organizadas de maneira compreensível?

  • As regras de negócio estão protegidas contra alterações acidentais nos detalhes de persistência?

  • As transações são tratadas de maneira coerente com os casos de uso?

Testes e manutenção

  • As principais regras de negócio possuem testes?

  • As integrações críticas são verificadas por testes apropriados?

  • É possível refatorar componentes importantes sem depender exclusivamente de testes manuais?

  • A solução possui convenções de código consistentes?

Evolução arquitetural

  • Cada projeto possui uma responsabilidade identificável?

  • A separação em módulos representa limites reais do negócio?

  • A equipe consegue adicionar funcionalidades sem modificar componentes não relacionados?

  • Existe uma justificativa concreta para a complexidade introduzida pelos padrões utilizados?

Não é necessário responder positivamente a todas as perguntas para considerar uma solução bem organizada.

O checklist serve para identificar riscos e priorizar melhorias, não para impor uma arquitetura idealizada a qualquer aplicação.

Conclusão

Organizar uma solução .NET grande não significa criar a maior quantidade possível de projetos, interfaces ou camadas.

Significa estabelecer uma estrutura que permita compreender o sistema, identificar as responsabilidades de cada componente e modificar uma funcionalidade sem colocar toda a aplicação em risco.

Para alcançar esse objetivo, vale a pena começar com alguns princípios fundamentais:

  • Separe as responsabilidades de acordo com as necessidades reais da aplicação.

  • Estabeleça uma direção clara para as dependências.

  • Organize o código por funcionalidade quando isso facilitar a navegação.

  • Considere um monólito modular antes de introduzir a complexidade de microservices.

  • Evite abstrações genéricas que não simplificam o sistema.

  • Compartilhe componentes somente quando existir uma responsabilidade comum.

  • Utilize testes para proteger regras de negócio e limites arquiteturais.

  • Evolua soluções legadas de maneira incremental.

Uma boa arquitetura não elimina a complexidade do negócio. Ela impede que essa complexidade seja ampliada desnecessariamente pela organização do código.

Se os desenvolvedores conseguem localizar rapidamente uma funcionalidade, compreender suas dependências e realizar alterações com segurança, a estrutura está cumprindo seu papel.

No final, o melhor sinal de uma solução .NET bem organizada não é a quantidade de projetos exibidos no Visual Studio. É a capacidade de continuar evoluindo o sistema sem transformar cada nova funcionalidade em um problema arquitetural.