Recently the question Number of projects per solution has been asked on reddit which led to interesting debates. Of course the answer depends largely on the overall size and business of the application. In this post I’ll go through various code bases to see what architecture choices are taken by industry leaders.
Before going through case studies let’s first remind a few points:
Points to keep in mind when partitioning .NET code
- Refactoring impact : If your code is defined in several solutions, this can significantly slows down the daily refactoring process since popular refactoring tools (Visual Studio refactoring, Resharper…) work within the boundary of a single Visual Studio solution. There’s room for a hybrid approach: use smaller solutions for the most part, but create a single including all projects for times when larger scale changes are required. But it means an extra solution to maintain.
- Build time : If you work on a large enough code base, the build time can become a problem since the build is often triggered to run manual and automatic tests. Relying on incremental build where only projects impacted by changes are rebuilt helps a lot. But sometime – to obtain an acceptable build experience – some projects need to be unloaded manually or trimmed down through some Visual Studio Solution Filters .slnf files. But doing so degrades the refactoring and exploration experience. For more details on this point here is a related article I wrote recently Improve Visual Studio Build Performance.
- Focus : having a few projects in multiple solutions can help enforce the separation of concerns, and keep build time low, and may be well suited to having multiple teams with narrower focus, and well defined service boundaries. The last case study of the present article exhibits an application made of 1.600+ projects and 200.000+ classes : in such situation no-one can develop without multiple solutions.
- Cross solutions reference: One drawback of having several solutions is that one needs to reference DLL of other solutions instead of referencing projects defined into the same solution. The DLL reference is a more brittle approach that breaks when project output location gets changed. In such situation NuGet is here to reference projects as components from other solutions but doing so introduces some extra assets to maintain.
- Project cycles prevented by IDE : all .NET IDEs detect and prevent dependency cycles in the project dependency graph. This advocates for many fine-grained projects to prevent anarchical structure in large projects. Unless some sort of rules let properly layer classes defined in large projects. A modular approach is necessary to build an application and this questions your definition of a component: a unit of re-use, unit of development, unit of feature, unit of versioning, unit of testing, unit of compilation?
- The physical nature of projects : Typically each project compiles to a .DLL or a .EXE assembly file. Those are physical artifacts and having dozens or hundreds of DLL can lead to versioning, deployment and maintenance difficulties. This is why when a new project is about to be created, it is worth questioning if there is a physical reason underlying the need for this new project. One such common physical reason is: will it be loaded dynamically at runtime through a Dependency Injection (DI) framework.
- Classes that don’t run in the same process : is a good indication that these classes should be declared in different projects.
- Test and Application Code segregations : one instance of the point above is that test code runs in test processes while application code runs both in test and production processes. Thus it is recommended to segregate tests and application code in distinct projects.
- Project as encapsulation container : If a class is only used in the scope of its parent project it should be declared as
internal. Such class can then be consumed by tests declared in another project, thanks to InternalsVisibleToAttribute. However this attribute should not be used in the context of application projects and if you stumble on this need, it is an indication that some classes should be merged in the same project.
- Code that compile against various .NET flavors : To increase re-use, some code like domain classes fits well in .NET Standard projects (that runs everywhere) while some infrastructure code requires .NET 6 / 7 projects to harness latest improvements of the platform.
All those points draw trades-off between:
- A single or multiple solutions.
- A few large projects or many smaller projects in a solution.
There is no perfect approach so let’s explore the choices made by some industry leaders.
Clean Architecture is a term coined by Uncle Bob and refers to principles to structure projects so that it is easy to understand and easy to change as the project grows. It is becoming increasingly popular to structure ASP.NET Core web applications. Here is the Project Dependency Diagrams of the Jason Taylor’s CleanArchitecture .NET solution template available here on github.
We can see test/code segregation through src and tests solution folders. Also each application project represent a layer with standardized names and roles : Domain, Application, Infrastructure and WebUI. You can refer to this post Clean Architecture for ASP.NET Core Solution: A Case Study for an in-depth analysis of this way of structuring a .NET solution.
NopCommerce is a popular OSS project eCommerce platform. It is way bigger way than the CleanArchitecture prototype above and has a total of 28 projects. However most of these projects are small plugins and the application code is nested in a few large projects: Core, Services, Data and Web.
- Core contains mostly domain and some infrastructure abstractions. In the context of eCommerce domain contains classes like Order, Payment, Store, Affiliate, Vendor, Catalog, Discount, Gdpr…
- Services contains infrastructure code to implement the domains listed above (order checkout, caching, various discount…).
- Data contains the code related to persistence.
- Web contains the ASP.NET Core code.
Thus NopCommerce’s engineers choose the few large projects approach. However as mentioned earlier, this approach lacks the benefit of IDE dependency cycles control between components that only work at projects level. As a consequence, the large projects like Nop.Services become super-components where pretty much everything is entangled with everything else (screenshot below).
Such large entangled portion of code is also known as spaghetti code or big ball of mud which is by definition: a software or a component that lacks a perceivable architecture. It doesn’t mean that this piece of code is not working well or that a lot of effort has been put in it. It means that the 700 types defined in Nop.Services haven’t been segregated into layers and altogether form a large unit of compilation, development and test that cannot be easily refactored into smaller components. This situation leads to extra maintenance cost. Later I’ll explain a way to counter this phenomenon because it is worth having large and cohesive projects.
Structuring a web application in multiple micro-services is becoming more and more popular. Micro-services promises are:
- Scalability: There’s less work involved because developers concentrate on individual services rather than the whole monolithic app.
- Faster development: faster development cycles because developers can focus on specific services.
- Improved data security: Microservices communicate with one another through secure APIs, which might provide development teams with better data security than the monolithic method
- Become “language and technology agnostic”: Because teams work somewhat independently of each other, microservices allow different developers to use different programming languages and technologies
See below the projects dependency diagram of the OSS solution run-aspnetcore-microservices. We can also see the CleanArchitecture principles applied to the Ordering concern (Domain, Application, Infrastructure).
Also the projects in this Microservices diagram seem less coupled than in the NopCommerce diagram (from the previous section). But some dependencies are not reported. For example the service Basket.API consumes Discount.API by calling the method
GetDiscount() even though their projects are not statically coupled. The key is that the gRPC framework is used to handle such
GetDiscount() call (RPC stands for Remote procedure Call) as illustrated within the screenshot above.
log4Net is a popular OSS logging framework. Its code is nested in a single project and another project contains the test. In such situation the single project approach makes sense because log4Net is a cohesive enough framework and its clients don’t want to mess up with multiple assemblies, even if they are packed in a single NuGet package. However here also having a single large project led to the super-component phenomenon. In the log4Net project pretty much everything statically depends on everything else.
.NET Base Class Libraries
See below the graph of the 166 assemblies of .NET 7.0 preview BCL, found in the directory C:\Program Files\dotnet\shared\Microsoft.NETCore.App\7.0.0-preview.3.22175.4. Obviously the BCL is not cohesive as a smaller scale API like log4Net. For example all the XML related implementations shouldn’t be loaded in-memory if the application is dealing with JSON only. Thus it makes sense to split its 18K types (10K of them being public) over 166 projects.
Here is the project dependency graph of our application. We also choose to have a few large projects (NDepend.Core and NDepend.UI) surrounded by smaller projects for the various NDepend flavors (analysis & reporting, Visual Studio extension, Azure DevOps extension, ILSpy extension…). The base project NDepend.API only contains abstractions and is consumed both by our code and by third-party consumers of NDepend.API to automatically pilot the core features of the product. Some users reported having literally thousands of .NET solutions to analyze so such automation really makes sense for them.
Despite the fact that we have large projects, we don’t face the super-component phenomenon because NDepend dog-food itself with rules like Avoid namespaces mutually dependent and Avoid namespaces dependency cycles. Thus inside a large project, we group classes in a hierarchy of namespaces that we consider as our components. As mentioned, relying on fewer large projects has benefits: easier refactoring, easier versioning, less maintenance, fewer physical assets to maintain. Fortunately the C# compiler is very fast and compiles the 1.400 classes of NDepend.UI in 3 seconds on a modern hardware.
The core of Roslyn are the 3x compiler projects Roslyn.CodeAnalysis, Roslyn.CodeAnalysis.CSharp and Roslyn.CodeAnalysis.VisualBasic. Around those projects there is a galaxy of smaller projects to handle services like Workspace / Solution / Project, Analyzer Runner, Scripting, Expression evaluation…
Again the large projects approach makes sense here because a compiler is something cohesive: one might want to compile some C# code without hosting the VB.NET compiler in-memory but one certainly doesn’t want to only use a partial version of the C# compiler.
With its 1.600+ projects and 200.000+ classes Visual Studio might be well the largest .NET application on earth. I have no insider info about the number of solutions required but it is certainly a lot. Clearly there are multiple teams that need narrower focus and acceptable build time. Most of features are processed as extensions and are loaded on-demand. No-ones uses all Visual Studio features in a single solution which means that most of assemblies remain unloaded most of the time. Also for performance reasons Visual Studio spawns many child processes at runtime, which enforce the relevance of having multiple solutions.
It seems that for large enough applications the industry favors less but larger projects and that for smaller scale applications, guidance like Clean Architecture prevails. Also the Microservices section makes clear the benefits of this approach.
If you are wondering how to structure your next .NET solution or how to improve existing ones, I hope that the various points and real world examples covered will help you make the right choices.
If you are interested to visualize your .NET projects architecture, just download NDepend 14-day free trial full featured now, start VisualNDepend.exe, analyze your solution(s) and go to the Dependency Graph panel.