Also, I will try to take into account the comments of the first part,
Onion architecture
Suppose we are designing an application in order to record which books we have read, but for accuracy, we want to record even how many pages have been read. We know that this is a personal program that we need on our smartphone, like a bot for telegrams and, possibly, for desktop, so feel free to choose this architecture option:
(Tg Bot, Phone App, Desktop) => Asp.net Web Api => Database
Create a project in Visual studio of Asp.net Core type, where further we select the Web Api project type.
How is it different from the usual one?
First, the controller class inherits from the ControllerBase class, which is designed to be the base one for MVC without support for returning views (html code).
Secondly, it is designed to implement REST services covering all types of HTTP requests, and in response to requests you receive json with an explicit indication of the response status. Also, you will see that the default controller will be marked with the [ApiController] attribute, which has useful options specifically for the API.
Now you need to decide how to store the data. Since I know that I read no more than 12 books a year, the csv file will be enough for me, which will represent the database.
So I create a class that describes the book:
Book.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace WebApiTest
{
public class Book
{
public int id { get; set; }
public string name { get; set; }
public string author { get; set; }
public int pages { get; set; }
public int readedPages { get; set; }
}
}
And then I describe the class for working with the database:
CsvDB.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace WebApiTest
{
public class CsvDB
{
const string dbPath = @"C:\\csv\books.csv";
private List<Book> books;
private void Init()
{
if (books != null)
return;
string[] lines = File.ReadAllLines(dbPath);
books = new List<Book>();
foreach(var line in lines)
{
string[] cells = line.Split(';');
Book newBook = new Book()
{
id = int.Parse(cells[0]),
name = cells[1],
author = cells[2],
pages = int.Parse(cells[3]),
readedPages = int.Parse(cells[4])
};
books.Add(newBook);
}
}
public int Add(Book item)
{
Init();
int nextId = books.Max(x => x.id) + 1;
item.id = nextId;
books.Add(item);
return nextId;
}
public void Delete(int id)
{
Init();
Book selectedToDelete = books.Where(x => x.id == id).FirstOrDefault();
if(selectedToDelete != null)
{
books.Remove(selectedToDelete);
}
}
public Book Get(int id)
{
Init();
Book book = books.Where(x => x.id == id).FirstOrDefault();
return book;
}
public IEnumerable<Book> GetList()
{
Init();
return books;
}
public void Save()
{
StringBuilder sb = new StringBuilder();
foreach(var book in books)
sb.Append($"{book.id};{book.name};{book.author};{book.pages};{book.readedPages}");
File.WriteAllText(dbPath, sb.ToString());
}
public bool Update(Book item)
{
var selectedBook = books.Where(x => x.id == item.id).FirstOrDefault();
if(selectedBook != null)
{
selectedBook.name = item.name;
selectedBook.author = item.author;
selectedBook.pages = item.pages;
selectedBook.readedPages = item.readedPages;
return true;
}
return false;
}
}
}
Then the matter is small, to add the API to be able to interact with it:
BookController.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace WebApiTest.Controllers
{
[ApiController]
[Route("[controller]")]
public class BookController : ControllerBase
{
private CsvDB db;
public BookController()
{
db = new CsvDB();
}
[HttpGet]
public IEnumerable<Book> GetList() => db.GetList();
[HttpGet("{id}")]
public Book Get(int id) => db.Get(id);
[HttpDelete("{id}")]
public void Delete(int id) => db.Delete(id);
[HttpPut]
public bool Put(Book book) => db.Update(book);
}
}
And then all that remains is to add the UI, which would be convenient. And everything works!
Cool! But no, the wife asked that she also have access to such a convenient thing.
What difficulties await us? First, now you need to add a column for all books that will indicate the user's ID. Trust me, it won't be comfortable with a csv file. Also, now you need to add the users themselves! And even now some kind of logic is needed so that my wife does not see that I am finishing reading Dontsova's third collection instead of the promised Tolstoy.
Let's try to expand this project to the required requirements: The
ability to create a user account, which will be able to keep a list of his books and add how many he read.
Honestly, I wanted to write an example, but the number of things that I would not want to do sharply killed my desire:
Creation of a controller that would be responsible for authorizing and sending data to the user;
Creation of a new entity User, as well as a handler for it;
Pushing logic either into the controller itself, which would make it bloated, or into a separate class;
Rewriting the logic of working with the "database", because now or two csv-files, or move to the database ...
As a result, we got a large monolith, which is very βpainfulβ to expand. It has a large set of tight links in the application. A tightly bound object depends on another object; this means that changing one object in a tightly coupled application often requires changing a number of other objects. This is not difficult when the application is small, but it is too difficult to make changes in an enterprise-grade application.
Weak ties mean that two objects are independent, and one object can use the other without being dependent on it. This type of relationship aims to reduce the interdependencies between the components of the system in order to reduce the risk that changes in one component will require changes in any other component.
Therefore, we will try to implement our application in the Onion style to show the advantages of this method.
Onion architecture is the division of an application into layers. Moreover, there is one independent level, which is in the center of the architecture.
Onion architecture relies heavily on Dependency Inversion. The user interface interacts with business logic through interfaces.
Dependency Inversion Principle
(Dependency Inversion Principle) , , . :
. .
. .
. .
. .
A classic project in this style has four layers:
- Domain Object Level (Core)
- Repository level (Repo)
- Service level
- Front End Layer (Web / Unit Test) (Api)
All layers are directed towards the center (Core). The center is independent.
Domain Object Level
This is the central part of the application that describes the objects that work with the database.
Let's create a new project in the solution, which will have the output type "Class Library". I named it WebApiTest.Core
Let's create a BaseEntity class that will have common properties of objects.
BaseEntity.cs
public class BaseEntity
{
public int id { get; set; }
}
Off-top
, Β«idΒ», , dateAdded, dateModifed ..
Next, let's create a Book class that inherits from BaseEntity
Book.cs
public class Book: BaseEntity
{
public string name { get; set; }
public string author { get; set; }
public int pages { get; set; }
public int readedPages { get; set; }
}
{
public string name { get; set; }
public string author { get; set; }
public int pages { get; set; }
public int readedPages { get; set; }
}
For our application, this will be enough for now, so let's move on to the next level.
Repository level
Now let's move on to implementing the repository level. Create a Class Library project called WebApiTest.Repo
We will use Dependency Injection , so we will pass parameters through the constructor to make them more flexible. Thus, we create a common repository interface for entity operations so that we can develop a loosely coupled application. The below code snippet is for the IRepository interface.
IRepository.cs
public interface IRepository <T> where T : BaseEntity
{
IEnumerable<T> GetAll();
int Add(T item);
T Get(int id);
void Update(T item);
void Delete(T item);
void SaveChanges();
}
Now, let's implement a repository class to perform database operations on an entity that implements IRepository. This repository contains a constructor with a pathToBase parameter, so when we create an instance of the repository, we pass the file path so that the class knows where to get the data from.
CsvRepository.cs
public class CsvRepository<T> : IRepository<T> where T : BaseEntity
{
private List<T> list;
private string dbPath;
private CsvConfiguration cfg = new CsvConfiguration(CultureInfo.InvariantCulture)
{
HasHeaderRecord = false,
Delimiter = ";"
};
public CsvRepository(string pathToBase)
{
dbPath = pathToBase;
using (var reader = new StreamReader(pathToBase)) {
using (var csv = new CsvReader(reader, cfg)) {
list = csv.GetRecords<T>().ToList(); }
}
}
public int Add(T item)
{
if (item == null)
throw new Exception("Item is null");
var maxId = list.Max(x => x.id);
item.id = maxId + 1;
list.Add(item);
return item.id;
}
public void Delete(T item)
{
if (item == null)
throw new Exception("Item is null");
list.Remove(item);
}
public T Get(int id)
{
return list.SingleOrDefault(x => x.id == id);
}
public IEnumerable<T> GetAll()
{
return list;
}
public void SaveChanges()
{
using (TextWriter writer = new StreamWriter(dbPath, false, System.Text.Encoding.UTF8))
{
using (var csv = new CsvWriter(writer, cfg))
{
csv.WriteRecords(list);
}
}
}
public void Update(T item)
{
if(item == null)
throw new Exception("Item is null");
var dbItem = list.SingleOrDefault(x => x.id == item.id);
if (dbItem == null)
throw new Exception("Cant find same item");
dbItem = item;
}
We have developed the entity and context that are required to work with the database.
Service level
Now we are creating the third layer of the onion architecture, which is the service layer. I named it WebApiText.Service. This layer interacts with both web applications and repository projects.
We create an interface named IBookService. This interface contains the signature of all methods accessed by the external layer on the Book object.
IBookService.cs
public interface IBookService
{
IEnumerable<Book> GetBooks();
Book GetBook(int id);
void DeleteBook(Book book);
void UpdateBook(Book book);
void DeleteBook(int id);
int AddBook(Book book);
}
Now let's implement it in the BookService class
BookService.cs
public class BookService : IBookService
{
private IRepository<Book> bookRepository;
public BookService(IRepository<Book> bookRepository)
{
this.bookRepository = bookRepository;
}
public int AddBook(Book book)
{
return bookRepository.Add(book);
}
public void DeleteBook(Book book)
{
bookRepository.Delete(book);
}
public void DeleteBook(int id)
{
var book = bookRepository.Get(id);
bookRepository.Delete(book);
}
public Book GetBook(int id)
{
return bookRepository.Get(id);
}
public IEnumerable<Book> GetBooks()
{
return bookRepository.GetAll();
}
public void UpdateBook(Book book)
{
bookRepository.Update(book);
}
}
External interface level
Now we create the last layer of the onion architecture, which, in our case, is the external interface, with which external applications (bot, desktop, etc.) will interact. To create this layer, we clean out our WebApiTest.Api project by removing the Book class and cleaning out the BooksController. This project provides an opportunity for operations with the entity database, as well as a controller for performing these operations.
Since the concept of Dependency Injection is central to an ASP.NET Core application, we now need to register everything we have created for use in the application.
Dependency injection
In small ASP.NET MVC applications, we can relatively easily replace one class with another, instead of using one data context, use another. However, in large applications this will already be problematic to do, especially if we have dozens of controllers with hundreds of methods. In this situation, a mechanism such as dependency injection can come to our aid.
And if earlier in ASP.NET 4 and other previous versions it was necessary to use various external IoC containers to install dependencies, such as Ninject, Autofac, Unity, Windsor Castle, StructureMap, then ASP.NET Core already has a built-in dependency injection container, which represented by the IServiceProvider interface. And the dependencies themselves are also called services, which is why the container can be called a service provider. This container is responsible for mapping dependencies to specific types and for injecting dependencies into various objects.
At the very beginning, we used hard linking to use CsvDB in the controller.
private CsvDB db;
public BookController()
{
db = new CsvDB();
}
At first glance, there is nothing wrong with this, but, for example, the database connection scheme has changed: instead of Csv, I decided to use MongoDB or MySql. In addition, you might need to dynamically change one class to another.
In this case, a hard link binds the controller to a specific implementation of the repository. This code is harder to maintain and harder to test as your application grows. Therefore, it is recommended to move away from using rigidly coupled components to loosely coupled ones.
Using a variety of dependency injection techniques, you can manage the lifecycle of the services you create. Services created by Depedency Injection can be of one of the following types:
- Transient: . , . ,
- Scoped: . , .
- Singleton: ,
The corresponding AddTransient (), AddScoped () and AddSingleton () methods are used to create each type of service in the embedded .net core container.
We could use a standard container (service provider), but it doesn't support parameter passing, so I'll have to use the Autofac library.
To do this, add two packages to the project via NuGet: Autofac and Autofac.Extensions.DependencyInjection.
Now we change the ConfigureServices method in the Startup.cs file to:
ConfigureServices
public IServiceProvider ConfigureServices(IServiceCollection services)
{
services.AddMvc();
var builder = new ContainerBuilder();//
builder.RegisterType<CsvRepository<Book>>()// CsvRepository
.As<IRepository<Book>>() // IRepository
.WithParameter("pathToBase", @"C:\csv\books.csv")// pathToBase
.InstancePerLifetimeScope(); //Scope
builder.RegisterType<BookService>()
.As<IBookService>()
.InstancePerDependency(); //Transient
builder.Populate(services); //
var container = builder.Build();
return new AutofacServiceProvider(container);
}
This way we have bound all the implementations to their interfaces.
Let's go back to our WebApiTest.Api project.
All that remains is to change BooksController.cs
BooksController.cs
[Route("[controller]")]
[ApiController]
public class BooksController : ControllerBase
{
private IBookService service;
public BooksController(IBookService service)
{
this.service = service;
}
[HttpGet]
public ActionResult<IEnumerable<Book>> Get()
{
return new JsonResult(service.GetBooks());
}
[HttpGet("{id}")]
public ActionResult<Book> Get(int id)
{
return new JsonResult(service.GetBook(id));
}
[HttpPost]
public void Post([FromBody] Book item)
{
service.AddBook(item);
}
[HttpPut("{id}")]
public void Put([FromBody] Book item)
{
service.UpdateBook(item);
}
[HttpDelete("{id}")]
public void Delete(int id)
{
service.DeleteBook(id);
}
}
Press F5, wait for the browser to open, go to / books and ...
[{"name":"Test","author":"Test","pages":100,"readedPages":0,"id":1}]
Outcome:
In this text, I wanted to update all my knowledge on the Onion architectural pattern, as well as on dependency injection, always using Autofac.
I think the goal is achieved, thanks for reading;)
n-Tier
n- .
β . . , .
. ( ). , . . , - .
β . . , .
. ( ). , . . , - .