Làm thế nào để sắp xếp Clean Architecture theo Modular Patterns trong 10 phút?

Bạn vẫn còn thắc mắc về định nghĩa của modern software architecture? Bạn đang tìm kiếm software architecture tốt nhất để áp dụng cho dự án của mình? Hy vọng bài viết này sẽ giúp bạn tìm thấy câu trả lời.

Introduction

Trong định nghĩa về software development thì architecture của dự án đóng vai trò rất quan trọng nhằm đảm bảo quá trình maintenance & reusability. Software architecture đảm bảo rằng phần mềm mà bạn xây dựng có bộ khung cơ bản. Từ đây, chúng ta có thể build bất cứ thứ gì chúng ta muốn.

Câu hỏi luôn đặt ra trong đầu tôi những ngày này làm cách nào để chúng ta kết hợp Clean Architecture & Modular pattern? Tôi đã code vài thử nghiệm và cuối cùng quyết định tổng hợp lại trong bài viết này. Bài viết sẽ dựa trên kinh nghiệm có được trong quá trình phát triển phần mềm và cách để phương pháp modular có thể áp dụng concept Clean Architecture vào quá trình phát triển phần mềm này.

Tìm việc làm Software Developer các công ty

Backgrounds

Modular patterns

Cách đây 5 năm, tôi đã từng làm trong 1 dự án lớn với nhiều thành viên tham gia và lúc đó, tôi đã sắp xếp architecture theo phương pháp modular. Chúng tôi nhận ra với modular chúng tôi có thể cắt software kiểu monolith lớn thành nhiều monoliths dọc nhỏ hơn & hỗ trợ team làm việc dễ dàng hơn vì mỗi team chỉ cần tập trung vào module mà họ đang làm. Liệu có ai còn nhớ được các đoạn code xung đột trong dự án lớn không? Liệu bạn có thể dành nửa ngày (hoặc nhiều) chỉ để merge code? Chẳng khác nào là ác mộng, phải không?

Vì vậy mà trong phương pháp modular, chúng ta cần phải đảm bảo rất các modules đủ độc lập để vẫn hoạt động được dù được viết bởi các developer đơn lẻ ở mỗi team khác nhau. Phương pháp này sẽ theo style design logic với những ưu điểm như:

  • Giúp hệ thống software có thể mở rộng được, reuse được, maintain được & tùy biến được
  • Phá stack dạng nguyên khối lớn thành hỗn hợp linh hoạt các modules cộng tác với nhau (theo style nguyên khối)
  • Giúp những người mới dễ dàng hiểu được các tính năng business & các chức năng của hệ thống (vì nó đủ nhỏ).
  • Mở ra cánh cửa để tích hợp vào kiến trúc Microservices

Clean Architecture

Clean Architecture ra đời từ năm 2012 bởi Uncle Bob và theo thời gian, Clean Architecture ngày càng đóng vai trò quan trọng trong thế giới kiến trúc phần mềm. Chúng ta có thể thấy được Android architecture đã sử dụng Clean Architecture bằng cách kết hợp với MVP pattern để xây dựng kiến trúc phần mềm cho ứng dụng Mobile. Một vài bài nghiên cứu còn đề nghị sử dụng Clean Architecture cho ứng dụng web. Đầu năm năm, Uncle Bob đã cho ra mắt quyển sách Clean Architecture: A Craftsman’s Guide to Software Structure and Design, đề cập đến các best practices khi sử dụng các nguyên lý SOLID, các pattern design & 1 vài mẹo khi deploy.

Giới thiệu sơ về clean architecture, nếu bạn đã biết thì có thể bỏ qua. Theo Clean Architecture, chúng ta cần phải đảm bảo 1 vài điểm quan trọng sau:

  • Tính độc lập của Frameworks. Architecture không phụ thuộc vào sự tồn tại của 1 vài thư viện đến từ phần mềm nặng về tính năng. Điều này cho phép chúng ta sử dụng những frameworks như các công cụ, thay vì phải nhồi nhét hệ thống của bạn vào các constraints đã bị giới hạn
  • Có thể test được. Nguyên tắc business là có thể test được dù không có UI, Database, Web Server hay bất kì element bên ngoài nào khác
  • Tính độc lập của UI. UI có thể thay đổi dễ dàng mà không thay đổi phần còn lại của hệ thống. Ví dụ, 1 Web UI có thể được thay thế bởi 1 console UI mà không thay đổi các nguyên tắc business.
  • Tính độc lập của Database. Bạn có thể hoán đổi Oracle hoặc SQL Server với Mongo, BigTable, CouchDB… Các nguyên tắc business của bạn sẽ không gắn liền với database.
  • Tính độc lập của bất kì agency ngoài nào. Trên thực tế, các nguyên tắc business sẽ không liên quan gì đến những thứ bên ngoài khác

Vậy làm thế nào để Clean Architecture hoạt động hiệu quả?

Context là vua nhưng khách hàng còn quan trọng hơn thế. Vì vậy, hãy bắt đầu với câu chuyện chính là bất kì dự án nào cũng phải phân tích & hỏi khách hàng về những gì họ muốn cho hệ thống của mình, họ cần gì để giúp hệ thống làm việc. Các khách hàng sẽ cung cấp rất nhiều use cases hoặc user stories (nếu có 1 dự án Agile). Và bước cuối cùng, chúng ta sẽ phải vẽ lại biểu đồ use case. Chẳng hạn như trường hợp blog engine domain. Chúng tôi muốn xây dựng trang blog có vài tính năng như đọc blog, thấy các posts của blog này, thêm vài comments trong giao diện public. Và chúng tôi có 1 cách để CRUD các actions trên blog, post và comments. Ngoài ra, chúng tôi cũng cần phải đăng nhập vào hệ thống trước khi có thể modify blogs, posts và comments. Dựa trên requirement của các tính năng cho ứng dụng này, chúng ta sẽ dừng lại ở giản đồ use case như bên dưới:

Câu hỏi đặt ra là làm sao để tạo architecture theo phương pháp modular? Thực sự thì nhân tố cốt lõi ở đây xuất phát từ Domain-Driven Design (theo quan điểm của tôi, Tackling Complexity in the Heart of Software Implementing Domain-Driven Design là những quyển sách mà bạn nên đọc) và trong trường hợp này, chúng tôi sử dụng pattern Bounded Context design pattern để phân tích & thiết kế business domain hiện tại. Nếu nhìn vào biểu đồ ở trên, bạn sẽ nhận ra chúng ta chỉ có 3 nhân tố chính cần quản lý là xác thực, blog & post. Tôi đã tách domain ứng dụng ra khỏi Access Control Context, Blog Context & Post Context (tôi chia nó thành 3 Bounded Contexts vì nó sẽ khác biệt so với các context khác do khác về kinh nghiệm domain, nhưng ít nhất cần phải phân loại yêu cầu business). Biểu đồ sẽ như thế này:

Giải thích 1 chút về biểu đồ trên: Access Control Context màu đỏ dùng cho các tasks về authentication (xác thực) & authorization (trao quyền). Blog Context màu xanh để quản lý blog như set up blog, phân công status, theme… Thứ 3 là Post Context để quản lý comments & tags. Như bạn có thể thấy, Post Context có mối quan hệ với các context khác. Bạn có thể tìm hiệu thêm về cách chúng tôi design Bounded Context (Root aggregate) qua loạt bài Effective Aggregate Design.

Do giới hạn của bài viết này, tôi chỉ có thể trình bày 1 đoạn code của Bounded Context và tôi muốn chọn Post Context vì có vẻ như đây là context thú vị nhất. Bạn có thể xem các phần khác trong codes ở GitHub của tối (link ở cuối bài viết này). Cuối cùng, sẽ có vài người đặt câu hỏi là vậy Clean Architecture sẽ liên quan đến phần nào? Đừng lo lắng, tôi sẽ trình bày project structure & bạn sẽ hiểu phần còn lại.

Để tôi giải thích structure ở trên. Đầu tiên, chúng ta có folder Framework trong biểu đồ, nó chứa tất cả mọi thứ liên quan đến toolkit cần cho dự án. Đừng quên chúng ta đang tránh sử dụng abstraction trong code của mình, mà sử dụng composition (Composition over inheritance trong OOP). BlogCore.Core sẽ không cần phải phụ thuộc vào bất kì framework hay thư viện nào, trong trong .NET SDK thì có (vài trường hợp chúng ta sẽ gọi là vanilla code của nó). Thêm nữa, chúng ta có 3 dự án: BlogCore.Infrastructure, BlogCore.Infrastructure.AspNetCore & BlogCore.Infrastructure.EfCore sẽ phụ thuộc vào EntityFrameworkCore, AspNetCore & các thư viện khác như Autofac, AutoMapper, FluentValidation, MediatR…

Thứ 2, folder Hosts ở giữa biểu đồ, chúng ta sử dụng để đặt các host projects ở đó. Bạn sẽ thấy chúng ta có 2 hosts: 1 host cho API (BlogCore.API) & host khác Single Page Application (BlogCore.App).

Thứ 3, folder Migrations được sử dụng để thực hiện các công việc migrating, trong trường hợp này, chúng ta migrate data cho Access Control Context, Blog Context & Post Context. Chúng ta có thể chọn cách migrate bằng cách sử dụng Entity Framework migration & đưa data cho chúng. Trái lại, bạn có thể sử dụng T-SQL scripts để thực hiện migration.

Cuối cùng, chúng ta có folder Modules chứa “trái tim” của ứng dụng. Chúng tôi đã chia thành các folder Bounded Context và giúp architecture trở nên rõ ràng hơn. Mỗi Bounded Context có 2 projects phụ như BlogCore.PostContext & BlogCore.PostContext.Core. BlogCore.PostContext.Core chỉ chứa các domain objects, contracts và interfaces thực sự phù hợp với các project references khác. Nguyên tắc chính là nếu chúng ta có module khác muốn sử dụng 1 vài classes, objects để nó sẽ tham chiếu <Module name>.Core & tham chiếu đến interface trong project này (chỉ phụ thuộc vào interface trong module khác. Việc này sẽ tạo loose-coupling cho những module đó. Chúng ta sẽ thu lại được nhiều lợi ích với phương pháp này).

Cùng xem chi tiết cấu trúc Post Bounded Context.

Theo tôi, chúng ta nên phân tích sau 1 vài đoạn code để hiểu hơn cách implement Clean Architecture với pattern Modular cho dự án này.

Chúng ta có Post.cs entity hoạt động như Root Aggregate trong Post Context như

namespace BlogCore.PostContext.Core.Domain
{
    public class Post : EntityBase
    {
        internal Post()
        {
        }

        internal Post(BlogId blogId, string title, string excerpt, string body, AuthorId authorId)
            : this(blogId, IdHelper.GenerateId(), title, excerpt, body, authorId)
        {
        }

        internal Post(BlogId blogId, Guid postId, string title, string excerpt, string body, AuthorId authorId) 
            : base(postId)
        {
            Blog = blogId;
            Title = title;
            Excerpt = excerpt;
            Slug = title.GenerateSlug();
            Body = body;
            Author = authorId;
            CreatedAt = DateTimeHelper.GenerateDateTime();
            Events.Add(new PostedCreated(postId));
        }

        public static Post CreateInstance(BlogId blogId, Guid postId, string title, string excerpt, string body, AuthorId authorId)
        {
            return new Post(blogId, postId, title, excerpt, body, authorId);
        }

        public static Post CreateInstance(BlogId blogId, string title, string excerpt, string body, AuthorId authorId)
        {
            return new Post(blogId, title, excerpt, body, authorId);
        }

        [Required]
        public string Title { get; private set; }

        [Required]
        public string Excerpt { get; private set; }

        [Required]
        public string Slug { get; private set; }

        [Required]
        public string Body { get; private set; }

        [Required]
        public BlogId Blog { get; private set; }

        public ICollection Comments { get; private set; } = new HashSet();

        public ICollection Tags { get; private set; } = new HashSet();

        [Required]
        public AuthorId Author { get; private set; }

        [Required]
        public DateTime CreatedAt { get; private set; }

        public DateTime UpdatedAt { get; private set; }

        public Post ChangeTitle(string title)
        {
            if (string.IsNullOrEmpty(title))
            {
                throw new BlogCore.Core.ValidationException("Title could not be null or empty.");
            }

            Title = title;
            Slug = title.GenerateSlug();
            return this;
        }

        public Post ChangeExcerpt(string excerpt)
        {
            if (string.IsNullOrEmpty(excerpt))
            {
                throw new BlogCore.Core.ValidationException("Excerpt could not be null or empty.");
            }

            Excerpt = excerpt;
            return this;
        }

        public Post ChangeBody(string body)
        {
            if (string.IsNullOrEmpty(body))
            {
                throw new BlogCore.Core.ValidationException("Body could not be null or empty.");
            }

            Excerpt = body;
            return this;
        }

        public bool HasComments()
        {
            return Comments?.Any() ?? false;
        }

        public Post AddComment(string body, AuthorId authorId)
        {
            Comments.Add(new Comment(body, authorId));
            return this;
        }

        public Post UpdateComment(Guid commentId, string body)
        {
            var comment = Comments.FirstOrDefault(x => x.Id == commentId);
            if (comment == null)
            {
                throw new NotFoundCommentException($"Could not find the comment with Id={commentId} for updating.");

            }
            comment.UpdateComment(body);
            return this;
        }

        public Post RemoveComment(Guid commentId)
        {
            var comment = Comments.FirstOrDefault(x => x.Id == commentId);
            if (comment == null)
            {
                throw new NotFoundCommentException($"Could not find the comment with Id={commentId} for deleting.");
                
            }
            Comments.Remove(comment);
            return this;
        }

        public bool HasTags()
        {
            return Tags?.Any() ?? false;
        }

        public Post AssignTag(string name)
        {
            var tag = Tags.FirstOrDefault(x => x.Name == name);
            if (tag == null)
            {
                Tags.Add(new Tag(IdHelper.GenerateId(), name, 1));
            }
            else
            {
                tag.IncreaseFrequency();
            }
            return this;       
        }

        public Post RemoveTag(string name)
        {
            var tag = Tags.FirstOrDefault(x => x.Name == name);
            if (tag != null)
            {
                tag.DecreaseFrequency();
                Tags.Remove(tag);
            }
            return this;    
        }
    }
}

Sau đó, chúng ta có PostGenericRepository.cs

public class BlogEfRepository : EfRepository
        where TEntity : EntityBase
{
    public BlogEfRepository(PostDbContext dbContext)
        : base(dbContext)
    {
    }
}

Chúng ta cần tạo DbContext cho PostContext.cs như

namespace BlogCore.PostContext.Infrastructure
{
    public class PostDbContext : DbContext
    {
        public PostDbContext(DbContextOptions options)
            : base(options)
        {
        }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            var entityTypes = new List
            {
                typeof(Post),
                typeof(Comment),
                typeof(Tag)
            };

            var valueTypes = new List
            {
                typeof(BlogId),
                typeof(AuthorId)
            };

            base.OnModelCreating(modelBuilder.RegisterTypes(entityTypes, valueTypes, "post", "post"));
        }
    }
}

Trong Clean Architecture, use case rất quan trọng và nên được design thật cẩn thận. Trong dự án của mình, tôi đã đặt tên nó là ListOutPostByBlogInteractor.cs

namespace BlogCore.PostContext.UseCases.ListOutPostByBlog
{
    public class ListOutPostByBlogInteractor 
        : IUseCaseRequestHandler&lt;ListOutPostByBlogRequest, PaginatedItem&lt;ListOutPostByBlogResponse>>
    {
        private readonly IEfRepository&lt;PostDbContext, Post> _postRepository;
        public IOptions&lt;PagingOption> _pagingOption;

        public ListOutPostByBlogInteractor(
            IEfRepository&lt;PostDbContext, Post> postRepository,
            IOptions&lt;PagingOption> pagingOption)
        {
            _postRepository = postRepository;
            _pagingOption = pagingOption;
        }

        public IObservable&lt;PaginatedItem&lt;ListOutPostByBlogResponse>> Process(ListOutPostByBlogRequest request)
        {
            var criterion = new Criterion(request.Page, _pagingOption.Value.PageSize, _pagingOption.Value);
            var includes = new Expression&lt;Func&lt;Post, object>>[] { p => p.Comments, p => p.Author, p => p.Blog, p => p.Tags };
            Expression&lt;Func&lt;Post, bool>> filterFunc = x => x.Blog.Id == request.BlogId;

            return _postRepository.ListStream(filterFunc, criterion, includes)
                .Select(y =>
                {
                    return new PaginatedItem&lt;ListOutPostByBlogResponse>(
                            y.TotalItems,
                            (int)y.TotalPages,
                            y.Items.Select(x =>
                            {
                                return new ListOutPostByBlogResponse(
                                    x.Id,
                                    x.Title,
                                    x.Excerpt,
                                    x.Slug,
                                    x.CreatedAt,
                                    new ListOutPostByBlogUserResponse(
                                            x.Author.Id.ToString(),
                                            string.Empty,
                                            string.Empty
                                        ),
                                    x.Tags.Select(
                                        tag => new ListOutPostByBlogTagResponse(
                                            tag.Id,
                                            tag.Name))
                                        .ToList()
                                );
                            }).ToList()
                        );
                });
        }
    }
}

Sau khi xử lý business case cho Post Bounded Context, chúng ta cần tổng hợp vài data liên quan đến Access Control Bounded Context và trong case này, chúng ta có thông tin Author, sử dụng interface IUserRepository để lấy thông tin chi tiết của author đó. Vì vậy, chúng ta giới thiệu class khác là ListOutPostByBlogPresenter.cs

namespace BlogCore.Api.Features.Posts.ListOutPostByBlog
{
    public class ListOutPostByBlogPresenter
    {
        private readonly IUserRepository _userRepository;

        public ListOutPostByBlogPresenter(IUserRepository userRepository)
        {
            _userRepository = userRepository;
        }

        public async Task&lt;PaginatedItem&lt;ListOutPostByBlogResponse>> Transform(IObservable&lt;PaginatedItem&lt;ListOutPostByBlogResponse>> stream)
        {
            var result = await stream.Select(x => x);

            var authors = result.Items
                .Select(x => x.Author.Id)
                .Distinct()
                .Select(y => _userRepository.GetByIdAsync(y).Result)
                .ToList();

            var items = result.Items.Select(x =>
            {
                var author = authors.FirstOrDefault(au => au.Id == x.Author.Id.ToString());
                return x.SetAuthor(author?.Id, author?.FamilyName, author?.GivenName);
            });

            return new PaginatedItem&lt;ListOutPostByBlogResponse>(
                result.TotalItems,
                (int)result.TotalPages,
                items.ToList());
        }
    }
}

Và chúng ta cần có 1 nơi để đăng kí những đối tượng dependency này. Vì vậy, Dependency Injection đã xuất hiện, và chúng ta sử dụng module Autofac trong dự án này. Ý tưởng này là module sẽ tự đăng kí tất cả dependency.

namespace BlogCore.PostContext
{
    public class PostUseCaseModule : Module
    {
        protected override void Load(ContainerBuilder builder)
        {
            base.Load(builder);

            builder.Register(x =>
                DbContextHelper.BuildDbContext&lt;PostDbContext>(
                    x.ResolveKeyed&lt;string>("MainDbConnectionString")))
                .SingleInstance();

            builder.RegisterType&lt;ListOutPostByBlogInteractor>()
                .AsSelf()
                .SingleInstance();

            builder.RegisterType&lt;ListOutPostByBlogPresenter>()
                .AsSelf()
                .SingleInstance();
        }
    }
}

Sau đó, chúng ta chỉ cần giới thiệu API đó cho những gì chúng ta đã làm

namespace BlogCore.PostContext
{
    [Produces("application/json")]
    [Route("public/api/blogs")]
    public class PostApiPublicController : Controller
    {
        private readonly ListOutPostByBlogInteractor _listOutPostByBlogInteractor;
        private readonly ListOutPostByBlogPresenter _listOutPostByBlogPresenter;

        public PostApiPublicController(
            ListOutPostByBlogInteractor listOutPostByBlogInteractor,
            ListOutPostByBlogPresenter listOutPostByBlogPresenter)
        {
            _listOutPostByBlogInteractor = listOutPostByBlogInteractor;
            _listOutPostByBlogPresenter = listOutPostByBlogPresenter;
        }

        [HttpGet("{blogId:guid}/posts")]
        public async Task> GetForBlog(Guid blogId, [FromQuery] int page)
        {
            var result = _listOutPostByBlogInteractor.Process(new ListOutPostByBlogRequest(blogId, page &lt;= 0 ? 1 : page));
            return await _listOutPostByBlogPresenter.Transform(result);
        }
    }
}

Tổng kết

Như vậy, tôi đã giúp bạn hiểu được cách giúp modular hoạt động hiệu quả trong Clean Architecture. Ít nhất chúng ta cũng biết được modular là gì, điểm nào thực sự quan trọng. Chúng ta cũng đã đi qua 1 vài overview về Clean Architecture và 1 vài điểm mạnh của nó. Và cuối cùng, biết cách implement bằng .NET Core 2.0

Hy vọng bạn có thể trả lời được câu hỏi đặt ra trên tiêu đề. Tuy nhiên, có những nội dung không được phân tích trong bài này chính là Data flowSynchronized giữa Bounded ContextsUnit TestingDeployment cho Clean Architecture.

Những điểm thú vị về nội dung

  • Biết cách modular patterns làm việc với Clean Architecture trong cùng stack
  • Hiểu thêm vài điểm mạnh & điểm yếu của Modular & Clean Architecture
  • Hiểu thêm về Clean Architecture trong thực tế
  • Biết cách sử dụng .NET Core 2.0 để implement Blog Domain

Source Code

Xem tại https://github.com/thangchung/blog-core