Organization of code in microservices and my approach of using hexagonal architecture and DDD


Hello, Habr! In the Monolith, all the code should be in the same style, and in different microservices you can use different approaches, programming languages โ€‹โ€‹and frameworks. For simple microservices with 1 to 2 controllers and 1 to 10 actions, there is no particular sense in blocking layers of abstractions. For complex microservices with different states and the logic of transition between them, on the contrary, it is better not to be lazy initially. I want to talk about my experience with organizing code and using the DDD, Ports, and Adapters approaches for both cases. There is a brief summary of the article: June - writes code in the controller. Middle - writes a bunch of abstractions. Senior - knows when to write code in the controller, and when abstractions are needed.Here you need to immediately put an end to I - Ports in C # are interface or abstract, and Adapters are concrete implementations (class, struct). For reference, I recommend reading this translation of DDD, Hexagonal, Onion, Clean, CQRS ... how I put it all together and this article of Clean Architecture misconception . Here, just keep in mind that the approach is described for a large and complex monolith. I want to talk about the variations of this approach in microservices 80 percent of which are simple and 20 medium or high complexity.

Terminology


1) PrimaryAdapters


Introduce a user-friendly interface for an external calling system. Call and use SecondaryApdapters and Logic. They tell the application to do something. Entry points to your code. Typical representatives: ItemsService, CreateItemCommandHandler. Use Logic and SecondaryAdapters for their work.

2) SecondaryAdapters


Provides an interface to an external called system convenient for use by our system. He receives commands from the application. Typical representatives: ItemsRepository, EmailSender, Logger, DistributedCache. They use libraries and frameworks like EntityFramework for their work.

3) Logic


3.1) Entities


Combine data and logic. Typical OOP objects. Item, Money, User. There are also ValueObjects and Events. Only other objects from the Entities layer are used. In theory, this layer is the core of the application that does not depend on anyone. All depend on him.

3.2) DomainServices


Naked computing. They are needed for logic that cannot be attached to one specific entity. Use Entities or other DomainServices. Do not use PrimaryAdapters (they always do themselves all use) and SecondaryAdapters. Usually this is all sorts of AmountCalculator, Validator and more.

The code call flow should always be in one direction PrimaryAdapters -> Logic and SecondaryAdapters.

Domain


This is the subject area for which the system is being developed. For example, for the Domain Online Store, this is E-Commerce.

Boundedcontext


BoundedContext is used to split the system into isolated parts with a certain responsibility. One of the ways to success in system design is to find and highlight all of its BoundedContexts in it. In a microservice architecture, 1 microservice = 1 BoundedContext. For example: An online store may have a BoundedContext Shopping Cart and BoundedContext working with orders, BoundedContext working with files. And one big BoundedContext can be broken into small pieces inside. For example, for the BoundedContext of the basket of goods, you can make a division into the context of adding goods to the basket and the context of removing goods from the basket. In monolith 1, a large BoundedContext is one Module. Here it is necessary to make a remark that all Entities live within a specific context i.e.Item from the context of the basket of goods and Item from the context of the showcase of goods, these can be different classes with different fields and methods. In a monolith, they simply map into each other and use some ItemDto that EF passes to work with the database and which has fields that are common to all, that is, if Item from the context (module) of the Basket has the property Property1, and Item from the context of the storefront of goods has property Property2 then ItemDto will have both Property1 and Property2. In each context there will be its own repository which will pull out the Item, which is already characteristic of this context, from the database. In microservices, this is easy. Each has its own database and its own essence. Each world service has its own classes. SimplyIn a monolith, they simply map into each other and use some ItemDto that EF passes to work with the database and which has fields that are common to all, that is, if Item from the context (module) of the Basket has the property Property1, and Item from the context of the storefront of goods has property Property2 then ItemDto will have both Property1 and Property2. In each context there will be its own repository which will pull out the Item, which is already characteristic of this context, from the database. In microservices, this is easy. Each has its own database and its own essence. Each world service has its own classes. SimplyIn a monolith, they simply map into each other and use some ItemDto that EF passes to work with the database and which has fields that are common to all, that is, if Item from the context (module) of the Basket has the property Property1, and Item from the context of the storefront of goods has property Property2 then ItemDto will have both Property1 and Property2. In each context there will be its own repository which will pull out the Item, which is already characteristic of this context, from the database. In microservices, this is easy. Each has its own database and its own essence. Each world service has its own classes. SimplyIn each context there will be its own repository which will pull out the Item, which is already characteristic of this context, from the database. In microservices, this is easy. Each has its own database and its own essence. Each world service has its own classes. SimplyIn each context there will be its own repository which will pull out the Item, which is already characteristic of this context, from the database. In microservices, this is easy. Each has its own database and its own essence. Each world service has its own classes. Simplyremember that Item and ItemsRepository from different BoundedContext can be different objects with different methods and properties. Often, BoundedContext in microservices is violated by the introduction of some common libraries. The result is a class that has several dozen methods or properties and each microservice uses 1 - 2 only that it needs, so you need to be careful with shared libraries in microservices.

Dependencies between layers




Option for simple microservices of which Pareto law 80 percent


Throw away the layer PrimaryAdatapters and SecondaryAdapters. Leave only the Logic layer. More precisely, in a typical ASP.NET application we use - PimaryAdater is Controller, and SecondaryAdapter is DbContext from EF. If the Controller suddenly becomes too large, then we cut it into pieces using partial. This is much better than breeding unnecessary abstractions and spending time on them. For example, Microsoft did this for the BasketMicrotservice in the example of its EShop application on docker containers. Yes, just write the code to the controller. Just do not write spaghetti code. Importantremember that the Logic layer remains with us and we still use Entities with their logic and DomainServices with their calculations and not just write a wall of spaghetti code in the controller. We just throw away the typical ItemService and ItemRepository. The good old ItemValidator, ItemMapper, AmountCalculator, etc. in this spirit still remain with us. Since we still have a formed Logic layer, we can switch to a more complicated option at any time by wrapping additional abstractions with ItemsService, ItemsRepository.

Example


A service that calculates the final price of a product at a discount. In theory, it is part of the Domain Online Store and represents its BoundedContext product catalog. For simplicity, I skipped all the validation logic. It can be described in some sort of ItemValidator and DiscountValidator.

1) Logic folder:
1.1) Entities folder:

Discount:

    public class Discount
    {
        [Key]
        public Guid Id { get; set; }

        public decimal Value { get; set; }
    }

Money:

    [Owned]
    public class Money
    {
        public decimal Amount { get; set; }

        public Money Apply(Discount discount)
        {
            var amount = Amount * (1 - discount.Value);
            return new Money()
            {
                Amount = amount
            };
        }
    }

Product:

    public class Item
    {
        [Key]
        public Guid Id { get; set; }

        public Money Price { get; set; } = new Money();
    }

1.2) DomainServices folder Product

price calculator, including discounts:

    public interface IPriceCalculator
    {
        Money WithDiscounts(Item item, IEnumerable<Discount> discounts);
    }

     public sealed class PriceCalculator:IPriceCalculator
    {
        public Money WithDiscounts(Item item, IEnumerable<Discount> discounts)
        {
            return discounts.Aggregate(item.Price, (m, d) => m.Apply(d));
        }
    }

2) DbContext

    public class ItemsDbContext : DbContext
    {
        public ItemsDbContext(DbContextOptions<ItemsDbContext> options) : base(options)
        {

        }

        public DbSet<Item> Items { get; set; }

        public DbSet<Discount> Discounts { get; set; }
    }

3) Controller

    [ApiController]
    [Route("api/v1/items")]
    public class ItemsController : ControllerBase
    {
        private readonly ItemsDbContext _context;
        private readonly IPriceCalculator _priceCalculator;

        public ItemsController(ItemsDbContext context, IPriceCalculator priceCalculator)
        {
            _context = context;
            _priceCalculator = priceCalculator;
        }

        [HttpGet("{id}/price-with-discounts")]
        public async Task<decimal> GetPriceWithDiscount(Guid id)
        {
            var item = await _context.Items.FindAsync(id);
            var discounts = await _context.Discounts.ToListAsync();
            return _priceCalculator.WithDiscounts(item, discounts).Amount;
        }
    }


Option for complex microservices of which 20 percent and for most monoliths


Here we use a standard approach with maximum isolation of the layers from each other. Add a class for PrimaryAdapter (ItemService) and for SecondaryAdapter (ItemRepository). In the Logic folder, everything remains as it was before.

Example


1) SecondaryAdapters folder

    public interface IItemsRepository
    {
        Task<Item> Get(Guid id);
    }

    public sealed class ItemsRepository : IItemsRepository
    {
        private readonly ItemsDbContext _context;

        public ItemsRepository(ItemsDbContext context)
        {
            _context = context;
        }

        public async Task<Item> Get(Guid id)
        {
            var item = await _context.Items.FindAsync(id);
            return item;
        }
    }

    public interface IDiscountsRepository
    {
        Task<List<Discount>> Get();
    }

    public sealed class DiscountsRepository : IDiscountsRepository
    {
        private readonly ItemsDbContext _context;

        public DiscountsRepository(ItemsDbContext context)
        {
            _context = context;
        }

        public Task<List<Discount>> Get()
        {
            return _context.Discounts.ToListAsync();
        }
    }


2) PrimaryAdapters folder

    public interface IItemService
    {
        Task<decimal> GetPriceWithDiscount(Guid id);
    }

    public class ItemService : IItemService
    {
        private readonly IItemsRepository _items;
        private readonly IDiscountsRepository _discounts;
        private readonly IPriceCalculator _priceCalculator;

        public ItemService(IItemsRepository items, IDiscountsRepository discounts, IPriceCalculator priceCalculator)
        {
            _items = items;
            _discounts = discounts;
            _priceCalculator = priceCalculator;
        }

        public async Task<decimal> GetPriceWithDiscount(Guid id)
        {
            var item = await _items.Get(id);
            var discounts = await _discounts.Get();
            return _priceCalculator.WithDiscounts(item, discounts).Amount;
        }
    }

Our controller now uses our IItemService

    [ApiController]
    [Route("api/v1/items")]
    public class ItemsController : ControllerBase
    {
        private readonly IItemService _service;

        public ItemsController(IItemService service)
        {
            _service = service;
        }

        [HttpGet("{id}/price-with-discounts")]
        public async Task<decimal> GetPriceWithDiscount(Guid id)
        {
            var result = await _service.GetPriceWithDiscount(id);
            return result;
        }
    }

Added a lot of additional code. Instead, increased system flexibility. Now itโ€™s easier to change the Contoller and generally ASP.NET Core to something else or change the DbContext and EntityFramework Core to something else or add caching to Redis. The first approach wins in simple microservices and very simple and small monoliths. The second in all other cases, when you will need to add and finish this code for more than a year. Well, in the second approach, in addition to Entity Item and Discount, it is useful to make separate DTOs that will use DbContex EF and separate DTOs (Models) that ASP.NET Controller will use i.e. ItemDto and ItemModel. This will make domain models even more independent. And finally, an example of a simple application that I wrote using the 2nd TestRuvds approach. In fact, he is redundant here, and all these abstractions are here for an example.

Implementation example


VirtualServersModule

Acknowledgments


thank canxes and AndrewDemb for grammatical errors found.

All Articles