具有垂直切片体系结构的 .NET 领域驱动设计模板

作者:微信公众号:【架构师老卢】
6-25 8:8
47

概述:本文源代码在文末获取!在本文中,我们将探讨域驱动设计 (DDD) 模板与垂直切片体系结构的使用。该方法旨在促进高度解耦的整体式 .NET 应用程序的开发,同时提供随着业务需求的发展过渡到微服务的灵活性。个案研究从“为什么”开始我们是一个中等规模的开发人员团队,他们使用传统的 N 层架构在法律技术应用程序上工作了大约两年。随着我们进入一个相当大的增长阶段,并看到各个业务领域的使用量不断增加,我们认识到可能需要进一步解耦这些领域,并考虑到未来向微服务的过渡。经过深思熟虑,我们确定采用域驱动设计 (DDD) 和垂直切片方法是实现这一目标的最佳选择。微服务或模块化单体我们的团队不愿意在现阶段迁移到微服

本文源代码在文末获取!

在本文中,我们将探讨域驱动设计 (DDD) 模板与垂直切片体系结构的使用。

该方法旨在促进高度解耦的整体式 .NET 应用程序的开发,同时提供随着业务需求的发展过渡到微服务的灵活性。

个案研究

从“为什么”开始

我们是一个中等规模的开发人员团队,他们使用传统的 N 层架构在法律技术应用程序上工作了大约两年。随着我们进入一个相当大的增长阶段,并看到各个业务领域的使用量不断增加,我们认识到可能需要进一步解耦这些领域,并考虑到未来向微服务的过渡。

经过深思熟虑,我们确定采用域驱动设计 (DDD) 和垂直切片方法是实现这一目标的最佳选择。

微服务或模块化单体

我们的团队不愿意在现阶段迁移到微服务,因为这会带来额外的 DevOps 开销。我们的近期目标是减少业务域之间的耦合,通过引入错误来最大程度地降低概率,并简化扩展每个边界上下文的过程,同时通过单个发布管道部署所有边界上下文。

通过实现更高级别的解耦,我们的目标是增强应用程序的可维护性和可伸缩性,为在微服务变得更加可行时向微服务的潜在过渡做好准备。

溶液

具有模块化垂直切片整体的简洁架构

实施领域驱动设计 (DDD) 带来了几个显著的挑战,对我们来说,最主要的挑战是防止在单个解决方案中处理多个领域的动态团队中出现领域耦合。确保域保持解耦至关重要。该模板提供以下解决方案:

按解决方案目录划分的有界上下文

每个有界上下文将包含在一个解决方案目录中,该目录与域的名称相对应,并且每个域将有一个基于流行的 Clean Architecture 的项目拆分。从本质上讲,每个域都将代表您的业务垂直切片。

为什么结构可以解决DDD域耦合问题? 因为它给交叉引用域带来了重大摩擦。这种摩擦阻碍了开发人员耦合域,因为很明显,无论开发人员对 DDD 的经验如何,仅仅为了使用服务而引用另一个项目都是不正确的。该结构有效地阻止了交叉引用域的尝试,促进了使用其他预期工具与其他域进行通信 - 主要通过 API 调用事件溯源

通过 ProjectStartup 简化开发

通过 StartupProject 将所有上下文合并到单个二进制文件中,我们可以使用单个发布管道部署所有 Bounded 上下文。此方法避免了在项目早期阶段管理多个微服务部署的复杂性,通过最大程度地减少与 DevOps 任务(如服务业务流程、服务发现和通用 NuGet 包管理)相关的开销,从而加快开发进度。

ProjectStartup 充当根业务流程协调程序解决方案,该解决方案聚合每个项目的 Web 层的所有控制器并运行它们

var builder = WebApplication.CreateBuilder(args);  
  
builder  
    .Services  
    .AddProductCatalogDomain()  
    .AddProductCatalogApplication(builder.Configuration)  
    .AddProductCatalogInfrastructure(builder.Configuration)  
    .AddProductCatalogWebComponents();  
  
builder  
    .Services  
    .AddOrderManagementDomain()  
    .AddOrderManagementApplication(builder.Configuration)  
    .AddOrderManagementInfrastructure(builder.Configuration)  
    .AddOrderManagementWebComponents();  
  
builder  
    .Services  
    .AddStatisticsDomain()  
    .AddStatisticsApplication(builder.Configuration)  
    .AddStatisticsInfrastructure(builder.Configuration)  
    .AddStatisticsWebComponents();  
  
builder.Services  
    .AddTokenAuthentication(builder.Configuration)  
    .AddEventSourcing()  
    .AddModelBinders()  
    .AddSwaggerGen(c =>  
    {  
        c.SwaggerDoc("v1", new() { Title = "Web API", Version = "v1" });  
    })  
    .AddHttpClient();  
  
var app = builder.Build();  
  
app  
    .UseWebService(app.Environment)  
    .Initialize();  
  
app.Run();

领域建模和开发流程

从领域驱动设计 (DDD) 开始,与客户深入接触以掌握业务领域和用例的复杂性至关重要。此初始步骤对于定义有界上下文、聚合根和值对象至关重要。

在设计有界上下文时,请优先考虑其自主运行的能力。如果您发现两个域始终一起使用,请考虑合并它们。相反,如果一个域中的特定用例经常被独立使用,请考虑将它们拆分为不同的域。

显式定义应用模型的上下文。在团队组织、应用程序特定部分中的使用以及物理表现形式(如代码库和数据库架构)方面显式设置边界。在这些范围内保持模型的严格一致性,但不要被外部问题分散注意力或混淆——埃里克·埃文斯

数据存储

数据存储有两个主要选项:

  • 对所有域使用单个数据库,每个域都有自己的边界上下文 - 这种方法简化了开发并加快了流程。稍后过渡到微服务将只需要数据的迁移脚本。
  • 为每个域使用单独的数据库 — 这简化了向微服务的过渡,因为您只需要将域拆分为单独的存储库。但是,从一开始就管理多个数据库可能会在一定程度上减慢开发速度。

.NET 中的存储库

在 .NET 中,存储库主要充当聚合根或实现 CQRS 的反损坏层,尤其是在希望与实体框架分离(例如将 READ 操作移动到 Dapper)时。如果您的项目没有利用这些优势,直接使用 Db Context 可以简化开发。

请考虑将存储库组织为查询存储库和存储库:查询存储库返回响应对象,通常位于 Application 项目中,而 Domain 存储库返回 Domain 对象并在 Domain 项目中定义。

边界上下文之间的通信

边界上下文通过事件溯源或 API 调用进行通信。

例如,如果您需要跟踪统计信息,例如一天内的订单数量,则解决方案可能涉及在 OrderManagement 域中触发**“OrderCreated”域事件**,然后统计将捕获和处理该事件。

如何使用域事件:

所有实体都扩展了 Entity 类,该类包含用于引发事件的接口:

public abstract class Entity : IEntity  
{  
    private readonly ICollection<IDomainEvent> events;  
  
    protected Entity() => events = new List<IDomainEvent>();  
  
    public IReadOnlyCollection<IDomainEvent> Events  
        => events.ToList().AsReadOnly();  
  
    public void ClearEvents() => events.Clear();  
  
    protected void RaiseEvent(IDomainEvent domainEvent)  
        => events.Add(domainEvent);  
  
    ...

如何发起活动:

public class Order : Entity, IAggregateRoot  
{  
    public Order(Guid customerId, DateTime orderDate)  
    {  
        ...  
  
        RaiseEvent(new OrderAddedEvent());  
    }

事件处理程序示例:

public class OrderAddedEventHandler : IEventHandler<OrderAddedEvent>  
{  
    private readonly IStatisticsDomainRepository statistics;  
  
    public OrderAddedEventHandler(IStatisticsDomainRepository statistics)  
        => this.statistics = statistics;  
  
    public Task Handle(OrderAddedEvent domainEvent)  
        => statistics.IncrementProducts();  
}

所有事件处理程序都在扩展 IEventHandler 接口,该接口通过 .NET 中的程序集扫描程序自动注册到 DI 中。

跨越多个边界上下文的用例

如果您遇到跨越多个有界上下文且不适合现有上下文的用例,请考虑创建新的有界上下文。

验证和一致状态

反腐败层和验证

工厂和存储库充当反腐败层,补充了流畅的验证。

域对象是内部对象,只能通过工厂创建。验证在所有层中实现,特别强调域层。确保核心域得到正确验证且没有错误至关重要,因为此级别的无效状态或错误将传播到其余层。

跨不同层的验证

域层 — 每个域模型都使用Guard类封装自己的验证。有关示例,请参阅文件,其中为与产品相关的每个属性调用该方法。同样,Value 对象封装其自己的验证,以确保无论使用情况如何,状态都一致。有关此方法的示例,请参阅类。除了这些工具之外,Factory Builders 还用于以一致和统一的方式实例化复杂的聚合根模型。Product.csValidateAddress.cs

应用层 — 通过使用 FluentValidation NuGet 包来简化请求验证。在文件中,该方法添加了一个管道行为,该行为扫描每个程序集以查找请求验证程序,并自动将它们注册到依赖项注入 (DI) 中。此设置可确保您为项目创建的任何验证器都自动集成并正常运行。有关示例,请参阅。ApplicationConfiguration.csAddCommonApplicationProductCommandValidator.cs

public class ProductCommandValidator : AbstractValidator<ProductCommand>  
{  
    public ProductCommandValidator()  
    {  
        RuleFor(b => b.Name)  
            .NotEmpty().WithMessage("Name is required.")  
            .Length(ProductModelConstants.Product.MinNameLength, ProductModelConstants.Product.MaxNameLength)  
            .WithMessage($"Name must be between {ProductModelConstants.Product.MinNameLength} and {ProductModelConstants.Product.MaxNameLength} characters.");  
  
        RuleFor(b => b.Description)  
            .NotEmpty().WithMessage("Description is required.")  
            .Length(ProductModelConstants.Product.MinDescriptionLength, ProductModelConstants.Product.MaxDescriptionLength)  
            .WithMessage($"Description must be between {ProductModelConstants.Product.MinDescriptionLength} and {ProductModelConstants.Product.MaxDescriptionLength} characters.");  
  
        RuleFor(b => b.Price.Amount)  
            .NotEmpty().WithMessage("Price amount is required.")  
            .GreaterThan(CommonModelConstants.Common.Zero).WithMessage("Price amount must be greater than zero.")  
            .ScalePrecision(2, ProductModelConstants.Price.MaxAmountDigits)  
            .WithMessage($"Price amount must have at most {ProductModelConstants.Price.MaxAmountDigits} digits.");  
  
        RuleFor(b => b.Price.Currency)  
            .NotEmpty().WithMessage("Price currency is required.")  
            .MaximumLength(ProductModelConstants.Price.MaxCurrencyLength)  
            .WithMessage($"Price currency must have at most {ProductModelConstants.Price.MaxCurrencyLength} characters.");  
  
        RuleFor(b => b.Weight.Value)  
            .NotEmpty().WithMessage("Weight value is required.")  
            .GreaterThan(CommonModelConstants.Common.Zero).WithMessage("Weight value must be greater than zero.")  
            .ScalePrecision(2, ProductModelConstants.Weight.MaxValueDigits)  
            .WithMessage($"Weight value must have at most {ProductModelConstants.Weight.MaxValueDigits} digits.");  
  
        RuleFor(b => b.Weight.Unit)  
            .NotEmpty().WithMessage("Weight unit is required.")  
            .MaximumLength(ProductModelConstants.Weight.MaxUnitLength)  
            .WithMessage($"Weight unit must have at most {ProductModelConstants.Weight.MaxUnitLength} characters.");  
    }  
}

基础架构层:基础架构层中的验证是使用 Fluent API 实现的。有关详细信息,请参阅文件。ProductConfiguration.cs

为了防止验证规则重复,建议在 Common 项目的 Constants 类中定义它们。此方法允许在整个应用程序中轻松重用。

干净的架构层

域层

职责包括:

  • 域模型/实体
  • 值对象
  • 枚举
  • 异常
  • 域事件
  • 核心域业务逻辑
  • 域名工厂/建设者

使用域的关键原则是确保没有来自其他层的细节与之耦合。

域层结构示例:

应用层

职责包括:

  • 接口
  • 请求/响应模型
  • 使用案例
  • 命令和查询
  • 验证者
  • 映射

应用程序层负责管理用例,充当域的编排层。涉及跨多个域交互的用例在应用程序层处理,由应用程序层协调它们。例如,通过 OrderManagement 域创建订单时,应用程序层可以通过 API 调用与 ProductCatalog 域交互,以验证订单中包含的产品。

应用程序层结构示例:

示例应用层用例/命令:

using MediatR;  
  
public class CreateProductCommand : ProductCommand, IRequest<CreateProductResponse>  
{  
    public class CreateProductCommandHandler : IRequestHandler<CreateProductCommand, CreateProductResponse>  
    {  
        private readonly IProductDomainRepository productRepository;  
        private readonly IProductFactory productFactory;  
  
        public CreateProductCommandHandler(  
 IProductDomainRepository productRepository,  
 IProductFactory productFactory)  
        {  
            this.productRepository = productRepository;  
            this.productFactory = productFactory;  
        }  
  
        public async Task<CreateProductResponse> Handle(  
 CreateProductCommand request,  
 CancellationToken cancellationToken)  
        {  
            var product = productFactory  
                .WithName(request.Name)  
                .WithDescription(request.Description)  
                .WithProductType(Enumeration.FromValue<ProductType>(request.ProductType))  
                .WithPrice(request.Price.Amount, request.Price.Currency)  
                .WithWeight(request.Weight.Value, request.Weight.Unit)  
                .Build();  
  
            await productRepository.Save(product, cancellationToken);  
  
            return new CreateProductResponse(product.Id);  
        }  
    }  
}

基础架构层

职责包括:

  • 坚持
  • 身份
  • 文件系统
  • API 客户端
  • 电子邮件

基础结构层负责处理数据如何存储、检索以及与外部系统通信的详细信息。这是实体框架 DBContexts 将驻留的位置。

Web 层

职责包括:

  • 控制器
  • 斯瓦格
  • 中间件
  • 拦截 器

在干净架构的上下文中,Web 层负责处理外部世界(通常是用户或外部系统)与应用程序核心业务逻辑之间的接口。

将边界上下文迁移到微服务

将域转换为微服务时,该过程可能很简单。只需创建一个迁移脚本来传输数据库表,将 .NET 解决方案文件夹重新定位到新的存储库,迁移就完成了!

结论

通过利用领域驱动设计 (DDD),我们可以优化 .NET 开发,并为持续增长进行战略定位。这种方法增强了我们的敏捷性,使我们能够更好地处理不断变化的业务需求,并提供未来无缝迁移到微服务所需的灵活性。

源代码获取:公众号回复消息【code:34936

相关代码下载地址
重要提示!:取消关注公众号后将无法再启用回复功能,不支持解封!
第一步:微信扫码关键公众号“架构师老卢”
第二步:在公众号聊天框发送code:34936,如:code:34936 获取下载地址
第三步:恭喜你,快去下载你想要的资源吧
阅读排行