Hướng dẫn phát triển ứng dụng với ABP Framework – Phần 1: Thiết lập nền tảng và mô hình miền

Đây là bài hướng dẫn thực hành từng bước nhằm xây dựng một ứng dụng quản lý sản phẩm cơ bản với các chức năng CRUD, đồng thời làm quen với kiến trúc cốt lõi của ABP Framework. Nội dung tập trung vào việc thiết lập cấu trúc dự án, định nghĩa mô hình miền (domain model), cấu hình cơ sở dữ liệu bằng EF Core, và triển khai lớp dịch vụ ứng dụng — tất cả đều tuân thủ các nguyên tắc thiết kế hướng miền (DDD) và chuẩn hóa của ABP.

Tạo giải pháp mới

Sử dụng ABP CLI để khởi tạo dự án dạng ứng dụng web đầy đủ:

abp new ProductCatalog -t app --ui angular --database-provider ef

Lệnh trên sẽ sinh ra một giải pháp đa lớp gồm: .Domain, .Domain.Shared, .EntityFrameworkCore, .Application, .Application.Contracts, và .Web. Mở giải pháp trong Visual Studio hoặc VS Code, khôi phục gói NuGet, sau đó chạy dự án .Web để kiểm tra tính khả thi ban đầu.

Xây dựng mô hình miền

Mô hình miền gồm ba thành phần chính: hai thực thể (Category, Item) và một kiểu liệt kê (InventoryStatus). Tất cả được tổ chức theo nguyên tắc phân tầng rõ ràng:

  • .Domain: Chứa các thực thể, dịch vụ miền, giao diện kho lưu trữ — chỉ phụ thuộc vào Volo.Abp.Core.
  • .Domain.Shared: Chứa các hằng số, enum, và kiểu dữ liệu chung được chia sẻ giữa các tầng.

Thực thể danh mục (Category)

Tạo lớp Category trong thư mục Categories của dự án .Domain:

using System;
using Volo.Abp.Domain.Entities.Auditing;

namespace ProductCatalog.Categories
{
    public class Category : AuditedAggregateRoot<Guid>
    {
        public string DisplayName { get; set; } = default!;
        public bool IsActive { get; set; } = true;
    }
}

Lớp này kế thừa từ AuditedAggregateRoot<Guid>, tự động tích hợp các trường kiểm soát vòng đời như CreationTime, LastModificationTime, và CreatorId. Việc sử dụng Guid làm khóa chính đảm bảo tính độc lập với hệ thống cơ sở dữ liệu.

Kiểu liệt kê trạng thái tồn kho (InventoryStatus)

Định nghĩa trong .Domain.Shared/Products:

namespace ProductCatalog.Products
{
    public enum InventoryStatus : byte
    {
        Pending,
        Available,
        LowStock,
        Discontinued
    }
}

Enum này sẽ được tái sử dụng trong DTO, giao diện người dùng và quy tắc nghiệp vụ — đảm bảo nhất quán toàn hệ thống.

Thực thể mặt hàng (Item)

Tạo lớp Item trong .Domain/Products:

using System;
using Volo.Abp.Domain.Entities.Auditing;
using ProductCatalog.Categories;

namespace ProductCatalog.Products
{
    public class Item : FullAuditedAggregateRoot<Guid>
    {
        public Guid CategoryId { get; set; }
        public Category? Category { get; set; }
        public string Title { get; set; } = default!;
        public decimal UnitPrice { get; set; }
        public DateTime LaunchDate { get; set; }
        public InventoryStatus Status { get; set; }
        public int StockQuantity { get; set; }
    }
}

Lớp này mở rộng FullAuditedAggregateRoot<Guid>, hỗ trợ xóa mềm thông qua các thuộc tính IsDeleted, DeletionTime, và DeleterId. Tính năng này được ABP xử lý tự động ở mọi truy vấn — trừ khi bạn chủ động yêu cầu bao gồm bản ghi đã xóa.

Hằng số miền

Thêm lớp CategoryConstraints trong .Domain.Shared/Categories:

namespace ProductCatalog.Categories
{
    public static class CategoryConstraints
    {
        public const int MaxDisplayNameLength = 64;
        public const int MinDisplayNameLength = 2;
    }
}

Tương tự, lớp ItemConstraints trong .Domain.Shared/Products:

namespace ProductCatalog.Products
{
    public static class ItemConstraints
    {
        public const int MaxTitleLength = 128;
        public const decimal MinUnitPrice = 0.01m;
    }
}

Các hằng số này sẽ được tham chiếu trong cấu hình EF Core và quy tắc xác thực — giúp tránh "magic numbers" và tăng khả năng bảo trì.

Cấu hình EF Core và ánh xạ cơ sở dữ liệu

ABP tích hợp sẵn EF Core qua gói Volo.Abp.EntityFrameworkCore. Toàn bộ logic ánh xạ được đặt trong lớp ProductCatalogDbContext thuộc dự án .EntityFrameworkCore.

Khởi tạo DbSet

Thêm hai thuộc tính vào ProductCatalogDbContext:

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

Cấu hình ánh xạ bằng Fluent API

Ghi đè phương thức OnModelCreating để định nghĩa chi tiết bảng và ràng buộc:

protected override void OnModelCreating(ModelBuilder builder)
{
    base.OnModelCreating(builder);

    // Cấu hình Category
    builder.Entity<Category>(b =>
    {
        b.ToTable("Categories");
        b.Property(x => x.DisplayName)
            .IsRequired()
            .HasMaxLength(CategoryConstraints.MaxDisplayNameLength);
        b.HasIndex(x => x.DisplayName).IsUnique();
    });

    // Cấu hình Item
    builder.Entity<Item>(b =>
    {
        b.ToTable("Items");
        b.Property(x => x.Title)
            .IsRequired()
            .HasMaxLength(ItemConstraints.MaxTitleLength);
        b.Property(x => x.UnitPrice)
            .HasColumnType("decimal(18,2)")
            .HasDefaultValue(0m);
        b.HasOne(x => x.Category)
            .WithMany()
            .HasForeignKey(x => x.CategoryId)
            .OnDelete(DeleteBehavior.Restrict);
        b.HasIndex(x => x.Title);
        b.HasIndex(x => x.Status);
    });
}

Fluent API được ưu tiên hơn Data Annotations vì nó giữ cho lớp thực thể thuần khiết, không phụ thuộc vào bất kỳ thư viện ORM nào — điều kiện cần để áp dụng đúng DDD.

Tạo và áp dụng migration

Sau khi hoàn tất cấu hình, tạo migration mới bằng công cụ CLI:

dotnet ef migrations add Initial_Catalog_Schema -p src/ProductCatalog.EntityFrameworkCore/ProductCatalog.EntityFrameworkCore.csproj

Chạy lệnh dưới đây để cập nhật cơ sở dữ liệu:

dotnet run --project src/ProductCatalog.DbMigrator/ProductCatalog.DbMigrator.csproj

Dự án .DbMigrator được thiết kế riêng để thực hiện migration trong môi trường production — tách biệt hoàn toàn với ứng dụng web chính, giúp loại bỏ rủi ro về quyền hạn và xung đột đồng thời.

Chèn dữ liệu mẫu (Seed Data)

Tạo lớp CatalogDataSeeder trong .Domain/Data:

using ProductCatalog.Categories;
using ProductCatalog.Products;
using System.Threading.Tasks;
using Volo.Abp.Data;
using Volo.Abp.DependencyInjection;
using Volo.Abp.Domain.Repositories;

namespace ProductCatalog.Data
{
    public class CatalogDataSeeder : IDataSeedContributor, ITransientDependency
    {
        private readonly IRepository<Category, Guid> _categoryRepo;
        private readonly IRepository<Item, Guid> _itemRepo;

        public CatalogDataSeeder(
            IRepository<Category, Guid> categoryRepo,
            IRepository<Item, Guid> itemRepo)
        {
            _categoryRepo = categoryRepo;
            _itemRepo = itemRepo;
        }

        public async Task SeedAsync(DataSeedContext context)
        {
            if (await _categoryRepo.GetCountAsync() > 0) return;

            var electronics = await _categoryRepo.InsertAsync(new Category { DisplayName = "Electronics" });
            var office = await _categoryRepo.InsertAsync(new Category { DisplayName = "Office Supplies" });

            await _itemRepo.InsertManyAsync(new[]
            {
                new Item
                {
                    CategoryId = electronics.Id,
                    Title = "Wireless Mechanical Keyboard",
                    UnitPrice = 99.99m,
                    LaunchDate = new DateTime(2023, 3, 15),
                    Status = InventoryStatus.Available,
                    StockQuantity = 42
                },
                new Item
                {
                    CategoryId = office.Id,
                    Title = "Eco-Friendly Notebook Set",
                    UnitPrice = 12.50m,
                    LaunchDate = new DateTime(2023, 8, 22),
                    Status = InventoryStatus.LowStock,
                    StockQuantity = 7
                }
            });
        }
    }
}

ABP tự động gọi SeedAsync mỗi lần chạy .DbMigrator, đảm bảo dữ liệu mẫu luôn sẵn sàng cho quá trình phát triển và kiểm thử.

Thiết kế lớp dịch vụ ứng dụng

Ứng dụng ABP phân tách rõ ràng giữa logic nghiệp vụ (application layer) và biểu diễn (presentation layer) thông qua các DTO và interface dịch vụ.

Định nghĩa DTO

Tạo ItemDto trong .Application.Contracts/Products:

using System;
using Volo.Abp.Application.Dtos;

namespace ProductCatalog.Products
{
    public class ItemDto : AuditedEntityDto<Guid>
    {
        public Guid CategoryId { get; set; }
        public string CategoryName { get; set; } = default!;
        public string Title { get; set; } = default!;
        public decimal UnitPrice { get; set; }
        public DateTime LaunchDate { get; set; }
        public InventoryStatus Status { get; set; }
        public int StockQuantity { get; set; }
    }
}

DTO không chứa navigation property — thay vào đó, các giá trị cần hiển thị (ví dụ: CategoryName) được kéo về trực tiếp — giúp giảm độ phức tạp khi serializing và tăng tính bảo mật.

Interface dịch vụ

Tạo IItemAppService trong cùng thư mục:

using System.Threading.Tasks;
using Volo.Abp.Application.Dtos;
using Volo.Abp.Application.Services;

namespace ProductCatalog.Products
{
    public interface IItemAppService : IApplicationService
    {
        Task<PagedResultDto<ItemDto>> GetListAsync(PagedAndSortedResultRequestDto input);
    }
}

ABP nhận diện interface này là dịch vụ ứng dụng nhờ kế thừa từ IApplicationService, và tự động xuất ra endpoint RESTful tương ứng mà không cần viết controller thủ công.

Cài đặt dịch vụ

Tạo ItemAppService trong .Application/Products:

using System.Linq.Dynamic.Core;
using System.Threading.Tasks;
using Volo.Abp.Application.Dtos;
using Volo.Abp.Domain.Repositories;

namespace ProductCatalog.Products
{
    public class ItemAppService : ProductCatalogAppService, IItemAppService
    {
        private readonly IRepository<Item, Guid> _itemRepository;

        public ItemAppService(IRepository<Item, Guid> itemRepository)
        {
            _itemRepository = itemRepository;
        }

        public async Task<PagedResultDto<ItemDto>> GetListAsync(PagedAndSortedResultRequestDto input)
        {
            var query = await _itemRepository.WithDetailsAsync(x => x.Category);
            
            var totalCount = await _itemRepository.GetCountAsync();
            var items = await AsyncExecuter.ToListAsync(
                query
                    .Skip(input.SkipCount)
                    .Take(input.MaxResultCount)
                    .OrderBy(input.Sorting ?? nameof(Item.Title))
            );

            return new PagedResultDto<ItemDto>(
                totalCount,
                ObjectMapper.Map<List<Item>, List<ItemDto>>(items)
            );
        }
    }
}

Phương thức sử dụng WithDetailsAsync để nạp dữ liệu liên quan (tương đương Include trong EF Core), kết hợp với AsyncExecuter để đảm bảo truy vấn bất đồng bộ — không phụ thuộc trực tiếp vào EF Core trong tầng ứng dụng.

Cấu hình AutoMapper

Cập nhật ProductCatalogApplicationAutoMapperProfile trong .Application:

using AutoMapper;
using ProductCatalog.Products;

namespace ProductCatalog
{
    public class ProductCatalogApplicationAutoMapperProfile : Profile
    {
        public ProductCatalogApplicationAutoMapperProfile()
        {
            CreateMap<Item, ItemDto>()
                .ForMember(dest => dest.CategoryName, opt => opt.MapFrom(src => src.Category.DisplayName));
        }
    }
}

AutoMapper được ABP tích hợp sẵn qua IObjectMapper. Việc ánh xạ tường minh Category.DisplayName → CategoryName đảm bảo tính rõ ràng và dễ kiểm soát hơn so với tính năng "flattening" tự động — đặc biệt khi mô hình ngày càng phức tạp.

Thẻ: abp-framework ef-core ddd aspnet-core automapper

Đăng vào ngày 24 tháng 6 lúc 03:24