PAYATHON 2026

API 与应用层之间的 contract 应放在哪一层或项目中?

技术老齐

结论

如果 modular monolith 需要明确限制模块的访问范围,建议为每个 bounded context 建立独立的契约项目,例如 Orders.Contracts。API 适配器和其他模块只引用契约项目,由应用层实现契约。

契约描述的是模块对外提供的应用能力,不必归属于 REST、GraphQL 或 gRPC 层。独立项目将公开接口与实现分开,不会增加业务处理层。

项目引用也需要一并控制。单独创建 Contracts 项目,无法阻止 API 通过其他引用路径访问应用层或领域层。

对于规模较小的系统,也可以把契约放在应用层,将实现设为 internal。如何选择,取决于是否需要独立、可检查的模块边界。

为什么依赖倒置不要求契约放在 API 层

依赖倒置中的“高层”指承载业务策略的组件,不能直接等同于调用链最上方的 Controller。

API 层负责协议适配,包括接收 HTTP 请求、转换参数、调用用例和转换响应。应用层负责用例编排,可以定义对外提供的能力,供不同的 API 适配器共同依赖。

契约的位置取决于它的职责:

契约适合的位置
应用向调用方提供的用例接口、请求和结果类型应用层,或独立的模块契约项目
应用执行用例时需要的仓储、时钟等抽象应用层或领域层,具体取决于其职责
HTTP 请求模型、GraphQL schema、gRPC .proto对应的协议适配层

例如,IOrderQueries 可以供 REST 和 GraphQL 共用;HttpContext、HTTP 状态码和 gRPC 生成类型则不应出现在应用契约中。

调用另一个 bounded context 时,可以直接依赖对方的公开契约。如果还需要隔离对方的业务模型,调用方也可以定义自己的接口,再通过适配器调用对方契约。两种做法对耦合的控制程度不同。

如何安排项目引用

应用层和领域层保留独立项目时,可以采用以下引用关系。箭头表示项目引用:

Orders.Api            -> Orders.Contracts
Orders.Application    -> Orders.Contracts
Orders.Application    -> Orders.Domain
Orders.Infrastructure -> Orders.Application / Orders.Domain

Host                  -> Orders.Api
Host                  -> Orders.Application
Host                  -> Orders.Infrastructure

Orders.Infrastructure 具体引用哪些项目,取决于仓储等抽象放在哪里。

这里的 Orders.Api 与 Host 是两个项目:

  • Orders.Api 是类库,包含 Controller、Endpoint 或其他协议适配代码,只依赖契约。
  • Host 是可执行的启动项目,负责创建容器、调用模块注册方法和启动服务。

Host 是 composition root,可以知道实现如何装配。它引用应用层,不会让 Orders.Api 也获得应用层引用。

如果 Controller 和 Program.cs 都在同一个 Web 项目中,而该项目直接引用了应用层,Controller 就同样拥有这份引用。C# 无法按文件夹或 namespace 限定只有 Program.cs 可以使用某个被引用程序集。要实现这种限制,需要拆分程序集,或增加分析器规则。

Orders.Contracts 自身也不能引用 Orders.Application 或 Orders.Domain,否则调用方仍会通过契约间接依赖实现。

契约与 DI 注册示例

1. 在契约项目中定义公开能力

Orders.Contracts 只暴露调用所需的接口和数据:

using System;
using System.Threading;
using System.Threading.Tasks;

namespace Orders.Contracts;

public sealed record OrderSummary(Guid Id, decimal Total);

public interface IOrderQueries
{
    Task<OrderSummary?> FindAsync(
        Guid id,
        CancellationToken cancellationToken = default);
}

返回类型使用独立 DTO,避免把领域实体直接作为模块之间的共享模型。

2. 在应用层隐藏实现

假设 Orders.Domain 已定义以下最小类型,仓储由基础设施层实现:

using System;
using System.Threading;
using System.Threading.Tasks;

namespace Orders.Domain;

public sealed record Order(Guid Id, decimal Total);

public interface IOrderRepository
{
    Task<Order?> FindAsync(
        Guid id,
        CancellationToken cancellationToken);
}

应用层将实现设为 internal,并在同一程序集中注册:

using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Orders.Contracts;
using Orders.Domain;

namespace Orders.Application;

internal sealed class OrderQueries : IOrderQueries
{
    private readonly IOrderRepository _repository;

    public OrderQueries(IOrderRepository repository)
    {
        _repository = repository;
    }

    public async Task<OrderSummary?> FindAsync(
        Guid id,
        CancellationToken cancellationToken = default)
    {
        var order = await _repository.FindAsync(
            id, cancellationToken);

        return order is null
            ? null
            : new OrderSummary(order.Id, order.Total);
    }
}

public static class OrdersRegistration
{
    public static IServiceCollection AddOrdersApplication(
        this IServiceCollection services)
    {
        services.AddScoped<IOrderQueries, OrderQueries>();
        return services;
    }
}

Host 调用公开的 AddOrdersApplication(),基础设施层另行注册 IOrderRepository。API 代码只注入 IOrderQueries。

注册代码与 OrderQueries 位于同一程序集,可以直接引用该类型;容器随后根据注册信息创建实例,因此无需通过 InternalsVisibleToAttribute 将 OrderQueries 开放给容器。

示例中的实现类型是 internal,构造函数是 public。内置 DI 容器通过构造函数注入创建对象时,需要可用的公共构造函数。类声明为 internal,仍会限制其他程序集直接使用它。.NET DI 文档

如何限制应用层和领域层的访问

internal 以程序集为边界

C# 的 internal 表示同一程序集内可访问,不按 namespace、目录或 Java package 划分访问范围。通常一个 .csproj 生成一个程序集,因此项目拆分会影响封装边界。C# internal 文档

具体做法取决于应用层与领域层是否在同一程序集中:

  • 如果分属不同程序集,应用层需要访问的领域类型通常保持 public,通过引用关系限制 API 使用这些类型。
  • 如果位于同一模块实现程序集,内部类型可以使用 internal,模块内部协作无需配置友元程序集。

模块化单体也可以按模块拆分项目,在模块内部用目录组织各层:

Orders.Contracts
Orders.Module
  Application/
  Domain/
  Infrastructure/
Orders.Api

这种结构以模块作为程序集边界。代价是编译器不会自动限制模块内部各目录之间的依赖,需要通过架构检查维持分层。

InternalsVisibleToAttribute 开放的是整个程序集的内部成员

如果领域层必须单独编译,又希望领域类型保持 internal,可以在领域程序集中声明:

using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("Orders.Application")]

这里填写目标程序集名称。指定程序集将能访问当前程序集的所有 internal 类型和成员,无法只授权某几个类。友元程序集文档

这种做法适用于确实需要紧密协作的程序集,也适用于测试程序集。如果应用层、基础设施层和注册项目之间需要配置大量友元关系,可以重新评估是否有必要将它们拆成独立程序集。

传递引用需要单独处理

SDK 风格的 .NET 项目通常支持传递项目引用。假设存在以下关系:

Api -> Application -> Domain

API 可能直接使用领域层中的 public 类型。只删除 API 到 Domain 的直接引用,仍无法阻止这种访问。

需要严格控制引用的项目可以配置:

<PropertyGroup>
  <DisableTransitiveProjectReferences>true</DisableTransitiveProjectReferences>
</PropertyGroup>

这会关闭隐式的传递项目引用,项目需要的引用都必须显式声明。也可以在 Directory.Build.props 中统一设置,但应先评估现有项目是否依赖传递引用。MSBuild 属性文档

这个设置有两个限制:

  1. API 如果直接引用了 Application,仍能访问 Application 的 public 类型。
  2. 开发者仍可以添加新的显式引用,因此还需要检查项目引用白名单。

在前面的 Orders.Api -> Orders.Contracts 结构中,只要契约没有引用实现,API 就没有通向 Application 和 Domain 的传递引用路径。此时,应用层和领域层中的部分类型可以保持 public,隔离效果取决于完整的引用关系。

使用 MediatR 时怎么处理

请求类型、handler 类型和 handler 方法的可见性需要分别考虑:

  • 对外公开的 command/query 及其结果类型可以放在契约项目中。
  • 实现 IRequestHandler<TRequest, TResponse> 的 handler 类可以是 internal。
  • handler 类中的 Handle 方法可以是 public。公开方法不会使它所属的 internal 类变成公开类型。

如果请求实现了 IRequest<TResponse>,契约项目也会依赖定义该接口的 MediatR 包。采用这种设计,就需要接受这项框架依赖。

内部 handler 能否被自动扫描和注册,要根据实际使用的 MediatR 版本及注册方式确认,必要时可在实现程序集内显式注册。访问修饰符和自动发现机制需要分别处理。

ISender 或 IMediator 本身不提供 bounded context 之间的调用权限控制。如果希望模块只暴露少量明确入口,可以对外提供 IOrderQueries 这样的接口,在模块内部使用 MediatR。

常见做法如何选择

做法适用情况原因与代价
契约与实现都放在 Application,隐藏实现类型小型系统、调用方较少项目少,但需要控制公开类型和领域层访问
每个模块独立 Contracts 项目多种 API、多个模块调用、要求明确边界调用方只引用公开能力,需要维护契约稳定性
Contracts 加一个模块实现程序集希望减少项目和友元配置internal 易于使用,模块内部的分层需要额外检查

对于需要明确模块边界的系统,优先采用独立的模块契约项目,并将 API 适配代码与 Host 分开。契约应保持精简,不暴露领域实体、仓储、DbContext 或 handler 实现。每个模块维护自己的契约,避免形成全系统共用的大型 Contracts 项目。

用构建检查或架构测试约束引用关系,例如禁止 API 引用 Application、Domain,禁止其他模块引用本模块实现。程序集可见性可以防止普通代码误用,引用检查则能防止后续修改逐渐破坏边界。这些机制维护的是代码结构,不构成同一进程内的安全隔离。

备注:内容仅供参考。