组织项目结构
Lino 会组织 .NET 解决方案,使架构决策从第一次 commit 起就保持明确。生成的项目一开始就包含 Aspire 编排、共享构建块、测试、代码质量配置,并为每个服务、模块、API contract、持久化关注点和集成边界提供清晰的位置。
本节说明 lino project new、lino service new 和 lino 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
生成项目的职责
| 工件 | 职责 |
|---|---|
| AppHost | Aspire 编排项目,负责本地运行 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.Api | HTTP 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.Analyzers、SonarAnalyzer.CSharp 和 Roslynator.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.Contracts 和 Api.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 项目为模块提供共享的技术组合支持。
添加模块后,Domain、Application、Infrastructure.Persistence、Infrastructure 和 Api 项目会出现在 Modules/<ModuleName> 内部,从而保留内部边界。Api.Contracts、Api.Client 和 Integration.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 通过防止标识符被意外混用来提高安全性。领域不必接受任意 Guid、long 或 int,而是可以为每个实体或聚合使用专属类型,通常采用 <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.Persistence | DbContext、Entity Framework Core 配置、具体 repositories、Unit of Work 和模块 migrations。 |
| Infrastructure | 模块特定的技术实现、adapters、providers 和依赖组合。 |
| Api | HTTP 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 是强化业务边界的技术边界,即使这些模块运行在同一进程中并使用同一个物理数据库。
良好的模块边界
围绕业务能力创建模块,而不是围绕技术层创建模块。好的模块名称应描述领域可识别的职责,例如具有自身规则的区域、流程或能力。对于业务规则,避免使用 Common、Core 或 Utilities 这样的通用名称,因为它们会隐藏 ownership,并倾向于变成过度共享的依赖。
- 内聚性:模块应拥有会一起变化的语言、规则和数据。
- 自治性:应能够在不访问另一个模块内部实体的情况下测试和演进该模块。
- 清晰 contracts:模块外部所需的数据应通过 API、集成、事件或 shadow entity 暴露,而不是通过直接访问数据库暴露。
- 低耦合:如果两个模块总是需要修改相同实体,那么边界很可能需要重新审视。
创建模块后,文档后续主题会展示如何在这些边界内建模实体、Value Objects、enumerations、commands、queries、APIs、事件、集成和 migrations。
