组织项目结构

Lino 会组织 .NET 解决方案,使架构决策从第一次 commit 起就保持明确。生成的项目一开始就包含 Aspire 编排、共享构建块、测试、代码质量配置,并为每个服务、模块、API contract、持久化关注点和集成边界提供清晰的位置。


本节说明 lino project newlino service newlino module new 如何塑造解决方案。目标不只是创建文件夹,而是定义 runtime 边界、数据库 ownership、模块隔离,以及从简单服务演进到模块化单体或分布式系统的路径。

创建解决方案基础

lino project new 命令会为新的 .NET 解决方案创建技术基础。安装并认证 CLI 后,请在空目录中运行它。

lino project new --name <ProjectName>

<ProjectName> 参数表示解决方案的真实名称。该名称会成为 namespaces、assemblies、路径、配置、工件以及组件之间引用的一部分;因此请选择简短、稳定且有代表性的名称。

交互式向导会要求做出会影响整个解决方案结构和 runtime 行为的决策:

  • 项目 namespace:生成项目使用的根技术身份。
  • 显示名称:用于生成元数据和用户可见位置的友好名称。
  • 语言和 stack:目前为 C#、.NET 10 和 Aspire。
  • 代码 analyzers:启用共享 packages 和规则,从 bootstrap 开始保持一致性和质量。
  • CQRS:准备应用层以分离 commands 和 queries,并由所选 mediator library 进行编排。
  • 解决方案中的基类:控制是否在解决方案内部本地生成通用抽象。
  • 分布式缓存:定义 Microsoft.Extensions.Caching.Hybrid 是只使用实例本地内存,还是也使用由 Aspire 配置的 Redis 分布式层。
  • 异步通信:启用 RabbitMQ 与 MassTransit,以及集成事件使用的消息传递/outbox 构建块。
  • 数据语言:生成期间用于描述领域元数据的语言。
  • 应用支持的 cultures:为 UI 文本、验证、错误和 API 响应生成的本地化资源。
  • 默认 culture:应用需要 fallback 时使用的主要语言。

确认后,Lino 会生成一个面向增长组织好的解决方案。最小项目一开始就包含 Aspire、共享层和 Shared 区域的测试:

<ProjectName>/
├── <ProjectName>.slnx
├── Directory.Build.props
├── Directory.Packages.props
├── src/
│   ├── Aspire/
│   │   ├── AppHost/
│   │   │   └── <ProjectName>.AppHost.csproj
│   │   └── ServiceDefaults/
│   │       └── <ProjectName>.ServiceDefaults.csproj
│   └── Services/
│       └── Shared/
│           ├── Api/
│           │   └── <ProjectName>.Shared.Api.csproj
│           ├── Application/
│           │   └── <ProjectName>.Shared.Application.csproj
│           ├── Domain/
│           │   └── <ProjectName>.Shared.Domain.csproj
│           └── Infrastructure/
│               └── <ProjectName>.Shared.Infrastructure.csproj
└── tests/
    └── Services/
        └── Shared/
            └── UnitTests/
                ├── Application/
                │   └── <ProjectName>.Shared.Application.UnitTests.csproj
                └── Domain/
                    └── <ProjectName>.Shared.Domain.UnitTests.csproj

生成项目的职责

工件职责
AppHostAspire 编排项目,负责本地运行 topology、服务发现、基础设施资源和 dashboard。
ServiceDefaults共享 hosting defaults、可观测性、health checks、resilience、service discovery 和 OpenTelemetry 配置。
Shared.Domain领域共享构建块,例如基础实体、Value Objects、领域事件、结果类型和通用抽象。
Shared.Application应用层共享 contracts、behaviors、validations、mediator abstractions 和 use case 支持。
Shared.Infrastructure技术集成和横切基础设施,例如日期时间、当前用户、serialization、localization、cache 和消息传递 abstractions。
Shared.ApiHTTP API 的共享支持,例如 filters、endpoint conventions、error handling、API responses 和 localization helpers。

Shared 区域应只承载真正跨越服务和模块的技术或领域通用能力。不要把某个特定业务区域的规则放进 Shared,因为这会隐藏 ownership,并削弱每个服务或模块的职责。Shared 的目标是减少技术重复,而不是成为耦合不同领域的捷径。

Aspire 和基础设施决策

AppHost 组合解决方案及其 runtime 资源。当添加 Redis、RabbitMQ、SQL Server、PostgreSQL、Redis Insight、服务、WebApps 或 workers 时,Aspire 会成为本地编排点。这简化了开发期间的执行、服务发现、日志、指标、traces 和资源可视化。

ServiceDefaults 集中管理 hosting patterns,例如 service discovery、health checks、resilience、logging、metrics、tracing 和 OpenTelemetry 集成。这样每个服务不需要单独配置这些内容,解决方案从一开始就有一个共同的组合点。

代码 analyzers

静态代码 analyzers 会在开发期间检查代码,并在执行前暴露问题:样式不一致、脆弱模式、重构机会、潜在 bug 和安全警告。

当你在 lino project new 中启用 analyzers 时,解决方案一开始就会集中配置 StyleCop.AnalyzersSonarAnalyzer.CSharpRoslynator.Analyzers 等 packages。这避免每个项目各自决定要遵循哪些规则。

  • 质量提升:保持代码可读、一致,并与解决方案标准对齐。
  • 错误预防:在问题进入人工测试或生产之前尽早指出它们。
  • 标准化:减少服务、模块和团队之间的风格差异。
  • 辅助重构:突出 C# 代码中可能的简化和现代化机会。

分布式缓存、混合缓存和 Redis

Lino 会准备解决方案以使用 Microsoft.Extensions.Caching.Hybrid,这是 Microsoft 通过 HybridCache 集中缓存操作的库。该抽象允许 handlers、应用服务和基础设施组件存储查询结果、权限、配置或辅助数据,而不在代码中散布缓存实现细节。

如果创建项目时未启用分布式缓存,HybridCache 仍然可用,但只会使用运行中实例的本地内存。该模式简单,足以应对本地场景、小型环境或单副本应用,但每个进程都会维护自己的缓存,数据不会在实例之间共享。

启用分布式缓存时,Lino 会把 Redis 添加到 Aspire 资源中,并配置基础设施以使用共享缓存层。借助 Redis,同一服务的多个实例可以查询同一层,减少对数据库、内部 APIs 或集成的重复读取,并使项目为水平扩展做好准备。

  • 性能:减少重复读取和辅助操作的响应时间。
  • 可扩展性:允许不同实例共享缓存数据。
  • 可用性:将部分读取负载从主数据库中解耦出来。
  • 运营成本:减少高容量查询和集成中的重复处理。

这个决策在 lino project new 中完成,因为它会改变环境基础:AppHost 资源、本地 secrets、packages、基础设施配置,以及 Aspire dashboard 中显示的执行 topology。

异步通信

异步通信允许服务、模块和组件响应系统事实,而不阻塞操作的主流程。当生产者不应依赖消费者的即时可用性,或某个动作需要触发后续效果时,它尤其有用,例如通知、projections、外部集成或 contexts 之间的同步。

启用异步通信时,Lino 会向 Aspire 添加 RabbitMQ,并配置 MassTransit 以及集成事件使用的消息传递和 outbox 构建块。这样,服务或模块发布的事件会在 Integration.Events 中由显式 contracts 表示,并能以更具韧性的方式处理。

  • 性能:允许主用例继续执行,而无需等待所有消费者完成。
  • 可扩展性:通过消费者和队列分发处理,更可控地吸收峰值。
  • 韧性:与 outbox 结合时支持重新处理并减少消息丢失。
  • 解耦:当不需要即时响应时,避免生产者和消费者之间的直接依赖。

当消费者失败不应撤销生产者事务时,请使用集成事件。当消费者必须立即响应才能完成用例时,应优先选择显式同步集成,并把可用性、timeout 和 fallback 作为 contract 的一部分来处理。

本地化和 cultures

选择 cultures 后,Lino 会生成资源,使消息、验证、labels、错误和 UI 文本从一开始就可以本地化。这避免在 strings 已经散落到 endpoints、handlers 和组件之后,才把国际化当作后期补丁处理。

验证基础并继续创建服务

生成后,恢复依赖、编译解决方案并运行 Aspire host:

dotnet restore <ProjectName>.slnx
dotnet build <ProjectName>.slnx
dotnet run --project src/Aspire/AppHost/<ProjectName>.AppHost.csproj

在这个阶段,解决方案仍然只是一个基础。请在编辑器中打开项目,审查 Aspire 创建的资源,确认缓存、消息传递、本地化和代码质量决策,然后继续创建代表应用业务能力的服务。

创建和管理服务

服务是 runtime 和 ownership 边界。在 Lino 中,服务可以表示分布式系统中的独立 APIs,也可以表示能够逐步演进的解决方案中的较大业务区域。

创建项目基础后,使用以下命令添加服务:

lino service new

服务向导会要求提供:

  • 服务 namespace:用于文件夹、项目和 namespaces 的技术名称。
  • 显示名称:服务的友好名称。
  • 服务类型:简单模块化之间选择。
  • 数据库 provider:为服务数据库选择 SQL Server 或 PostgreSQL。
  • 架构风格:目前简单服务使用 Clean Architecture。
  • Strongly Typed IDs:在简单服务中,定义标识符是否生成专用类型。

服务类型

简单服务:一种更直接的结构,适用于具有清晰边界的业务能力。它适合聚焦的 API、微服务,或小到足以作为一个单元演进的业务区域。

模块化服务:适用于更大的系统、模块化单体,或需要承载多个独立能力的 runtimes。模块会提升模型的组织性和可扩展性,但要求更严格的依赖纪律。

无论类型如何,每个服务都拥有自己的数据库。在简单服务中,Strongly Typed IDs 的决策在服务层面做出;在模块化服务中,该决策按模块做出。

简单服务

简单服务会把各层直接放在 src/Services/<ServiceName> 下。当业务能力具有清晰边界,并且不需要在同一 runtime 内有多个隔离模块时,请使用这种结构。它适合聚焦的 API、微服务,或小到足以作为一个单元演进的领域区域。

src/Services/<ServiceName>/
├── Domain/
│   └── <ProjectName>.<ServiceName>.Domain.csproj
├── Application/
│   └── <ProjectName>.<ServiceName>.Application.csproj
├── Infrastructure.Persistence/
│   └── <ProjectName>.<ServiceName>.Infrastructure.Persistence.csproj
├── Infrastructure/
│   └── <ProjectName>.<ServiceName>.Infrastructure.csproj
├── Api/
│   └── <ProjectName>.<ServiceName>.Api.csproj
├── Integration.Events/              (存在消息传递时)
│   └── <ProjectName>.<ServiceName>.Integration.Events.csproj
├── Api.Contracts/                   (存在 typed HTTP consumption 时)
│   └── <ProjectName>.<ServiceName>.Api.Contracts.csproj
└── Api.Client/                      (存在 typed HTTP consumption 时)
    └── <ProjectName>.<ServiceName>.Api.Client.csproj
tests/Services/<ServiceName>/
├── UnitTests/
│   ├── Domain/
│   │   └── <ProjectName>.<ServiceName>.Domain.UnitTests.csproj
│   └── Application/
│       └── <ProjectName>.<ServiceName>.Application.UnitTests.csproj
└── IntegrationTests/
    └── <ProjectName>.<ServiceName>.IntegrationTests.csproj

这些层有不同职责:Domain 保护业务规则和不变量;Application 编排用例;Infrastructure.Persistence 包含 Entity Framework Core、repositories、Unit of Work 和 migrations;Infrastructure 包含技术集成;Api 将 HTTP 适配到用例。

当存在 typed HTTP consumption 时,尤其是在带有 Blazor Web App 的解决方案中,会出现 Api.ContractsApi.Client 项目。前者集中 requests、responses、DTOs、公共类型和共享 client interfaces;后者使用 HttpClient 提供这些 interfaces 的 HTTP 实现,使 Blazor 能够消费生成的 APIs 而无需复制 contracts。

当解决方案启用消息传递时,会出现 Integration.Events 项目。它包含由其他模块、服务或系统发布和消费的集成事件,通常与消息传递和 outbox 基础设施一起使用。

模块化服务

模块化服务适用于模块化单体,或承载多个 bounded contexts 的 runtime。服务本身拥有 host、公共基础设施和数据库 provider;业务规则位于模块内部。

src/Services/<ServiceName>/
├── Host/
│   └── <ProjectName>.<ServiceName>.Host.csproj
├── Infrastructure/
│   └── <ProjectName>.<ServiceName>.Infrastructure.csproj
└── Modules/
tests/Services/<ServiceName>/
└── Modules/

Host 组合该服务的模块、配置、endpoints 和共享基础设施。它不应包含业务规则。服务层级的 Infrastructure 项目为模块提供共享的技术组合支持。

添加模块后,DomainApplicationInfrastructure.PersistenceInfrastructureApi 项目会出现在 Modules/<ModuleName> 内部,从而保留内部边界。Api.ContractsApi.ClientIntegration.Events 遵循相同条件:Blazor/API clients 的 typed HTTP consumption,以及集成事件所需的消息传递启用状态。

数据库 ownership

每个服务都拥有自己的数据库。在包含多个服务的解决方案中,一个服务可以使用 PostgreSQL,另一个服务可以使用 SQL Server。该决策可以根据领域需求、性能、运营、团队成熟度或与现有基础设施的集成而变化。

在模块化服务中,数据库作为 runtime 属于服务,但模块通过 schema、持久化项目和自己的 migrations 保持隔离。即使表位于同一个物理数据库中,一个模块也不应直接查询另一个模块的表。

在简单和模块化之间选择

选择能够保护边界的最小结构。当只有一个领域边界时,简单服务就足够了。当多个能力需要共享 runtime、deploy 或事务,但仍必须保持模型、持久化、APIs 和测试分离时,模块化服务才有意义。

选择何时使用承担的成本
简单服务聚焦的领域、少量内部边界、隔离 API 或直接的微服务。初始结构更少,但如果领域增长过大,内部隔离也更少。
模块化服务同一 runtime 中的多个子领域、模块化单体、具有独立区域的 SaaS,或需要按模块划分 schemas。更多项目、更严格的依赖纪律,以及对内部 contracts 的更多关注。

架构风格

Lino 生成的服务遵循 Clean Architecture,以便将业务规则与技术细节分离。领域层不需要了解 HTTP、Entity Framework Core、消息传递、UI 或外部 providers;这些关注点位于应用的边缘。

  • 解耦:核心规则不依赖 frameworks 或交付机制。
  • 可维护性:基础设施变更通常会与业务规则保持隔离。
  • 可测试性:用例和领域可以用更少的外部依赖进行测试。
  • 演进:技术细节可以替换,并且对服务核心的影响更小。

Clean Architecture 和 Strongly Typed IDs

生成的服务遵循 Clean Architecture,使业务代码不依赖 HTTP、EF Core、消息传递、UI 或基础设施细节。这种分离让测试更直接,减少耦合,并允许在不重写核心规则的情况下替换技术细节。

Strongly Typed IDs 通过防止标识符被意外混用来提高安全性。领域不必接受任意 Guidlongint,而是可以为每个实体或聚合使用专属类型,通常采用 <EntityName>Id 格式。这可以避免把一个实体的标识符传到期望另一个实体的位置,并让 commands、queries、实体、handlers 和 mappings 中的签名更具表达力。

  • 类型安全:防止意外混用不同实体的标识符。
  • 清晰性:让签名和 contracts 比松散的 primitive types 更具表达力。
  • 重构:将格式或序列化变更集中到对应的 ID 类型中。
  • 减少错误:尽可能在编译时暴露不正确的使用方式。

服务之间的集成

当一个服务需要响应另一个服务时,优先选择集成事件、显式 HTTP 集成或有意识复制的数据。直接访问另一个服务的数据库会产生结构耦合,使 migrations 更困难,并让独立 deploy 更有风险。

模块化服务的后续步骤

创建模块化服务后,下一步是使用 lino module new 添加模块。每个模块都应表示自己的业务能力,并通过领域、应用、持久化、API、测试和集成来保留服务的内部边界。

创建和管理模块

模块只存在于模块化服务内部。一个模块表示同一 runtime 内的业务边界:它拥有自己的领域模型、用例、持久化、API 表面、集成事件、测试和数据库 schema。

当服务需要承载多个业务能力,同时又不能混合实体、规则、migrations 和 contracts 时,请使用模块。在模块化单体中,所有内容都可以在同一进程中运行,但内部边界仍然重要:真正的隔离是依赖控制,而不只是文件夹分离。

lino module new --service <ServiceName>

创建期间,向导会要求提供:

  • 服务:将承载该模块的模块化服务。Lino 不允许在简单服务中创建模块,因为简单服务没有内部隔离所需的结构。
  • 模块 namespace:用于文件夹、namespaces、assemblies 和项目的技术名称。
  • 显示名称:在人类可读位置使用的模块友好名称。
  • Strongly Typed IDs:定义模块内生成的标识符是否使用专用类型,通常采用 <EntityName>Id 格式。

确认后,Lino 会在不破坏架构边界的情况下,把模块添加到服务内部:

src/Services/<ServiceName>/
├── Host/
├── Infrastructure/
└── Modules/
    └── <ModuleName>/
        ├── Domain/
        │   └── <ProjectName>.<ServiceName>.<ModuleName>.Domain.csproj
        ├── Application/
        │   └── <ProjectName>.<ServiceName>.<ModuleName>.Application.csproj
        ├── Infrastructure.Persistence/
        │   └── <ProjectName>.<ServiceName>.<ModuleName>.Infrastructure.Persistence.csproj
        ├── Infrastructure/
        │   └── <ProjectName>.<ServiceName>.<ModuleName>.Infrastructure.csproj
        ├── Api/
        │   └── <ProjectName>.<ServiceName>.<ModuleName>.Api.csproj
        ├── Integration.Events/              (存在消息传递时)
        │   └── <ProjectName>.<ServiceName>.<ModuleName>.Integration.Events.csproj
        ├── Api.Contracts/                   (存在 typed HTTP consumption 时)
        │   └── <ProjectName>.<ServiceName>.<ModuleName>.Api.Contracts.csproj
        └── Api.Client/                      (存在 typed HTTP consumption 时)
            └── <ProjectName>.<ServiceName>.<ModuleName>.Api.Client.csproj
tests/Services/<ServiceName>/Modules/<ModuleName>/
├── UnitTests/
│   ├── Domain/
│   │   └── <ProjectName>.<ServiceName>.<ModuleName>.Domain.UnitTests.csproj
│   └── Application/
│       └── <ProjectName>.<ServiceName>.<ModuleName>.Application.UnitTests.csproj
└── IntegrationTests/
    └── <ProjectName>.<ServiceName>.<ModuleName>.IntegrationTests.csproj

生成工件的职责

工件模块内的职责
Domain实体、聚合、Value Objects、enumerations、领域事件、repository contracts 和模块不变量。
Application用例、commands、queries、handlers、验证、内部输入和输出 contracts,以及模块规则的编排。
Infrastructure.PersistenceDbContext、Entity Framework Core 配置、具体 repositories、Unit of Work 和模块 migrations。
Infrastructure模块特定的技术实现、adapters、providers 和依赖组合。
ApiHTTP endpoints、版本控制、filters、授权,以及外部请求和用例之间的适配。
Api.Contracts当存在 typed HTTP consumption 时生成,通常由 Blazor Web Apps 使用。包含 API 与消费者之间共享的 requests、responses、DTOs、公共类型和 client interfaces。
Api.Client当存在 typed HTTP consumption 时与 contracts 一起生成。包含这些 interfaces 的 HTTP 实现,使用 HttpClient、providers、options 和 helpers,使 Blazor 项目能够以一致且 strongly typed 的方式消费生成的 APIs。
Integration.Events当项目具有消息传递能力时生成。包含由其他模块、服务或系统发布和消费的集成事件,并将 payload 保持为显式 contract。

数据库结构

数据库仍然绑定到服务,而不是单独绑定到模块。在模块化服务内部,每个模块在关联数据库中由自己的 schema 表示,并拥有自己的持久化项目和 migrations。这提供了隔离和组织性,而不要求为每个模块创建多个物理数据库。

模块之间的隔离与独立性

一个模块不应直接访问另一个模块的 DbContext、实体、repositories 或内部服务。每个模块都有自己的模型和自己的持久化。当另一个模块需要数据时,应使用显式集成,而不是通过内部实现细节跨越边界。

这可以避免看似局部的变更破坏系统的其他区域。如果消费方模块需要查询属于另一个模块的数据,它不应依赖来源模块的完整实体。它可以维护一个 shadow entity,其中只包含自身用例所需的最小数据,并由集成或事件填充。

这种解耦的优势:

  • 隔离:每个模块都可以演进规则、持久化和测试,而无需穿透另一个模块的内部细节。
  • 组织性:应用尊重 bounded context,并使 ownership 明确。
  • 灵活性:可以添加、移除或重构模块,同时对服务其余部分的影响更小。
  • 测试便利性:每个模块都可以更独立地验证,从而提高对变更的信心。

模块之间的通信

通信应使用显式 contracts。对于同一 runtime 内的调用,内部集成可以在 Integration.Contracts 中暴露 contracts,并在生成时提供 in-process 实现。对于 runtimes 之间的调用,使用 HTTP 集成。对于异步传播,在 Integration.Events 中发布集成事件;当发布属于事务性操作的一部分时,使用消息传递/outbox 基础设施。

Schemas、migrations 和数据库

在模块化服务中,模块共享服务的数据库 provider,但每个模块都由自己的 schema 和自己的 Infrastructure.Persistence 项目表示。Migrations 按模块生成,使数据库演进与业务边界保持一致。

这种分离允许每个模块演进自己的表、seeds、索引和 foreign keys,而不会把服务数据库变成所有人共享的单一模型。Schema 是强化业务边界的技术边界,即使这些模块运行在同一进程中并使用同一个物理数据库。

良好的模块边界

围绕业务能力创建模块,而不是围绕技术层创建模块。好的模块名称应描述领域可识别的职责,例如具有自身规则的区域、流程或能力。对于业务规则,避免使用 CommonCoreUtilities 这样的通用名称,因为它们会隐藏 ownership,并倾向于变成过度共享的依赖。

  • 内聚性:模块应拥有会一起变化的语言、规则和数据。
  • 自治性:应能够在不访问另一个模块内部实体的情况下测试和演进该模块。
  • 清晰 contracts:模块外部所需的数据应通过 API、集成、事件或 shadow entity 暴露,而不是通过直接访问数据库暴露。
  • 低耦合:如果两个模块总是需要修改相同实体,那么边界很可能需要重新审视。

创建模块后,文档后续主题会展示如何在这些边界内建模实体、Value Objects、enumerations、commands、queries、APIs、事件、集成和 migrations。

发生了未处理的错误。 重新加载 🗙