While designing an application solution, it is a best practice to organize our solution in such a way that the code is maintainable, extensible and testable. As application grows and new functionalities are added, the solution grows in size with new files and can result in less efficient design.
Developers should try to build functionalities in the form of discrete components which are not tightly coupled. These components should communicate with others through explicit abstractions or through messaging patterns.
Core Architectural Principles
The following are the core principles that developers should keep in mind while designing their application solution. These help in designing a maintainable and testable solution for the functionalities of the application. These also form basis for the architectural patterns that are explained later.
1. Separation of Concerns
- Developers must ensure that the components they are building MUST be separated based on the kind of work it performs.
- For example, the Core business behavior must be separated from the Infrastructure and UI components.
- The Business rules and logic should reside in a separate project, and should not depend on other project layers
2. Dependency Inversion
- The direction of Dependencies within application should be in the direction of abstractions and not in concrete implementations
- It is a key part of building loosely coupled applications, since implementations can be written to depend on higher-level abstractions and not vice versa
3. Explicit Dependencies
- Classes should explicitly require any collaborating components they need for their proper functioning.
- For example, a class can declare all its dependencies through a parameterized constructor so that calling components can fully understand what all are required for this class to be invoked.
- It also helps the class to have all the dependencies upfront so that when the said functionality is invoked all the dependencies are available for it to consume.
4. Single Responsibility
- As an architecture principle, Single Responsibility is applied to all the layers within the Monolith.
- It recommends a clear responsibility for each layer; for example the UI layer must only contain the presentation logic, while the Infrastructure must only contain the persistence logic and the business logic must only reside inside the Application Core.
While structuring a solution, we can design it in a single solution that accommodates the various behavior types in one place. A Monolithic architecture is one of the most widely used architecture with this approach.
What is a Monolith?
A Monolith is a solution design, one that is entirely self-contained in terms of behavior. Although it may interact with other data sources or invokes other services for its functionality, the core behavior is contained within the solution and is deployed as a single unit.
When we deploy a monolith application, the entire functionality falls into a single set of binaries which are sufficient for the entire application to work.
Aspects of a Monolithic Architecture
- A Monolith is generally scaled horizontally, meaning the binaries are replicated into multiple servers or virtual machines when needed to handle high demand.
- While beginning an application, developers tend to place all the functional code inside a single project which we call a Monolith. This solution contains all the business, presentation and persistence logic within it.
- Separation is achieved by using folders within this single project, which contains everything. But this is disastrous as the application grows over in size and in functionality.
To solve this problem, developers split this single project into multiple projects, each pertaining to a Single Responsibility. These are called Layers.
What is a Layered architecture?
A traditional N-Layer monolith is designed by splitting a single project.
It generally contains three layers:
- User Interface Layer or Presentation Layer
- Business Logic Layer
- Data Access Layer
The dependency map is as shown below:
- The UI layer forms the top most layer and is client facing.
- The Business Logic Layer contains the core behavior and logic
- The Data Access Layer helps in data connectivity and persistence
- The UI connects to the Data Access via the Business Logic Layer, and has NO direct dependency with the Data Access
Advantages of an N-Layer Solution
- When the solution is split into multiple layers, we create a clear separation of concerns about which layer contains which code.
- It also creates new opportunities to reuse code which is already present in the lower layers when needed in higher layers, following a DRY principle.
- Developers can decide the dependency map; its up to us to design which layer depends on which based on the requirement.
- It is also easy to swap implementations of a functionality when required. For example, we can design a solution that connects to a local database for persistence and later swap the layer to another that uses a similar design but stores data in a cloud storage.
The Disadvantages of an N-Layer Solution
Although this approach works and is one of the most commonly used architecture, it has its own drawbacks:
- The compile-time dependency runs linearly between the UI, Business Logic and Data Access layers
- This design is hard to test, because to test any component in the Business Layer we need to create a Test database because of the tight coupling
A modern and an alternative approach for this N-Layer architecture is called an Onion Architecture or a Clean Architecture. It is also called with various other names such as Onion Architecture, Hexagonal Architecture, Ports and Adapters etc…
It leverages techniques such as Dependency Inversion and Separation of Concerns and creates a simple layered architecture that is both testable and extensible.
What is a Clean Architecture?
In a Clean Architecture, the solution is designed with the Application Core at the center and the UI and Persistence depending on the Core.
The Application Core defines the required abstractions, that the Infrastructure layer implements and the UI layer uses to call functionalities. This way, the interactions happen via abstractions defined in the Core layer following Dependency Inversion principle.
One can visualize this approach in the form of an Onion (hence the name Onion Architecture). The Application Core is at the center, with both the persistence and the presentation layers forming two sides of the outer layer.
Why two sides? Because they don’t have a dependency on one another (conceptually). So they both are assumed to be on the same level with one another.
To explain how Clean Architecture solution looks like, I’m using the example of the repository ContainerNinja.CleanArchitecture.
It is a boilerplate template project to demonstrate building a multi-container Full Stack application with ASP.NET Core (.NET 6) Web API following Clean Architecture, and Angular. You can find the solution repository here – ContainerNinja.CleanArchitecture
The blocks of a Clean Architecture Solution
A simple solution designed with Clean Architecture has three layers:
1. The Application Core
The application core is the center of the architecture, and is the lowest layer.
- It contains the core business logic along with Entities and Interfaces for communicating with Infrastructure services.
- It also contains the DTOs (Data Transfer Objects) which might be required by the services to pass data to the higher layers.
A typical Application Core contains the following types:
- Business services
- Custom Exceptions
- Events and Handlers
We can further split it into two projects: One project which holds all the contracts (the classes) and abstractions, while the other which contains behaviors and handlers.
2. The Persistence Layer
The Persistence Layer contains logic to persist data (store data).
- Some also call this as the Infrastructure layer since it communicates with underlying Infrastructure for storing and retrieving data or other services.
- In the context that the project uses Entity Framework Core with a DbContext (so as the case with most of the enterprise business applications), the Infrastructure contains the DatabaseContext and the necessary migration files to communicate with the database through the DbContext.
- It also contains implementations for the abstractions that the Application Core defined to handle data access operations, we call them Repositories.
- The Infrastructure layer depends on the Application Core to access these abstractions.
A typical Infrastructure contains the following:
- Migration classes
- Repository implementations defined in Application Core
- Any other service implementations which require calling Infrastructure services such as SmtpNotifiers or FileLoggers
We can further split it into two projects, one where the Migrations are run and the other where the Repository implementations exist. It purely a design choice, but it works well.
3. The UI Layer
The UI Layer or the presentation layer is the entry point for the application through which the communication starts
- The UI Layer references the Application Core and uses the abstractions defined in it to call the inner functionalities.
- Since the UI layer needs to register the abstractions with their implementations for Dependency Injection, it has a runtime only dependency on the Infrastructure layer
The UI layer contains purely the code needed for presentation or API, such as the following:
- Custom Filters
- Custom Middlewares
- Startup class where the implementations are registered into DI
How the Dependencies are mapped?
As mentioned before,
- The Application Core is the lowest layer and has no dependencies
- The Persistence Layer depends on Application Core for abstractions and entities
- The UI Layer depends on Application Core for abstractions and core behavior
- The UI Layer has a Runtime-Only dependency on the Persistence, because it needs to register and resolve the implementations during runtime.
- The Test project (if any) also depends only on the Application Core for the abstractions and for testing the core behavior
Testing with a Clean Architecture solution
Since the Application Core doesn’t depend on any other layer, unit testing the core behaviors present inside the application core is relatively simpler and this increases the scope for unit testing.
When we want to perform integration testing together with the Infrastructure, we can do it as well because we now require only the Application Core and Infrastructure project dependencies. Comparing this with the case of a linear N-Layer where in order to perform an integration you need to pass through the Presentation, you can feel that its easy.
Conclusion and Final Thoughts
Designing and maintaining an application solution is a developer choice. However it is an important practice and recommendation for applications which are expected to grow in size. This creates a loosely-coupled testable solution which can also improve code coverage.
To understand how a Clean Architecture works in action, you can checkout ContainerNinja.CleanArchitecture. It is a boilerplate template project to demonstrate building a multi-container Full Stack application with ASP.NET Core (.NET 6) Web API following Clean Architecture, and Angular. You can find the solution repository here – ContainerNinja.CleanArchitecture