👻

.Net CoreのエンドポイントをVertical Slice Architectureで実装してみたら結構良かった

に公開

はじめに

DDDやクリーンアーキテクチャでC#のAPIを開発していて、ファイル肥大化してきていやだなあ。見づらいなあとおもっていた。

「このサービスクラス、この機能でしか使わないのに、なんでSharedフォルダに入れなきゃいけないの?」とか、「Repositoryパターン使ってるけど、実際のところ使い回してる処理ってほとんどないよね...」みたいな。

プロジェクトが大きくなるにつれて、フォルダを行ったり来たりすることも増えて、「あのロジックどこに書いたっけ?」と探す時間も地味に積み重なっていきそうです。Controllers、Services、Repositories、Models...と、レイヤーごとにフォルダが分かれているのは確かに整理されているように見えるけど、機能を追加するときに複数のフォルダを渡り歩くのがちょっとしんどそうだなと。

そんなとき、ふと思い出しました。Reactでフロントエンドを書くときは、featuresフォルダで機能ごとにまとめているじゃないかと。あの構成、実装しやすい、テストも書きやすい、AIに書かせやすいのでお気に入りです。

Vertical Slice Architectureとの出会い

「バックエンドでもfeature単位でコード管理できないかな」と調べていたら、まさにドンピシャなアーキテクチャがありました。それがVertical Slice Architectureです。

従来のレイヤードアーキテクチャが「横切り」でコードを整理するのに対して、Vertical Sliceは「縦切り」で整理します。つまり、一つの機能に必要なものを全部同じ場所にまとめちゃおう、という発想です。

アーキテクチャの比較図

まず、従来のレイヤードアーキテクチャとVertical Slice Architectureの違いを視覚的に理解してみましょう:

【レイヤードアーキテクチャ】
┌─────────────────────────────────────────┐
│          Presentation Layer              │
├─────────────────────────────────────────┤
│          Application Layer               │
├─────────────────────────────────────────┤
│            Domain Layer                  │
├─────────────────────────────────────────┤
│         Infrastructure Layer             │
└─────────────────────────────────────────┘
   ↓ 機能追加時:全レイヤーを横断して変更

【Vertical Slice Architecture】
┌──────┬──────┬──────┬──────┬──────┐
│      │      │      │      │      │
│ User │ User │Order │Order │Payment│
│Create│ Get  │Create│ Get  │Process│
│      │      │      │      │      │
│  ↓   │  ↓   │  ↓   │  ↓   │  ↓   │
│  DB  │  DB  │  DB  │  DB  │  DB  │
└──────┴──────┴──────┴──────┴──────┘
   ↑ 各機能が独立した縦のスライス

フォルダ構造の詳細比較

// 従来のレイヤード構成
├── Controllers/
│   ├── UserController.cs
│   └── ProductController.cs
├── Services/
│   ├── UserService.cs
│   └── ProductService.cs
└── Repositories/
    ├── UserRepository.cs
    └── ProductRepository.cs

// Vertical Slice構成
src/
├─ Api/
│  ├─ Program.cs
│  └─ Modules/
│     ├─ UsersModule.cs        # Users のエンドポイント束ね
│     └─ PaymentsModule.cs
│
├─ Features/
│  ├─ Users/
│  │  ├─ Create/
│  │  │  ├─ Endpoint.cs        # ルート定義(Minimal APIs)
│  │  │  ├─ Command.cs         # リクエスト/入力(CreateUserCommand)
│  │  │  ├─ Response.cs        # 出力DTO
│  │  │  ├─ Handler.cs         # アプリケーションロジック
│  │  │  └─ Validator.cs       # 入力バリデーション(FluentValidation 任意)
│  │  ├─ GetById/
│  │  │  ├─ Endpoint.cs
│  │  │  ├─ Query.cs           # 取得系は Query として分ける
│  │  │  ├─ Response.cs
│  │  │  └─ Handler.cs
│  │  └─ Update/ ...           # 同様に Slice を増やす
│  │
│  └─ Payments/
│     └─ Process/...
│
├─ Shared/                      # 横断関心(共通)
│  ├─ Persistence/
│  │  ├─ ApplicationDbContext.cs
│  │  └─ Configurations/        # EF Core の IEntityTypeConfiguration*
│  ├─ Entities/
│  │  ├─ User.cs
│  │  └─ Payment.cs
│  ├─ Services/
│  │  ├─ EmailService.cs
│  │  └─ PaymentGateway.cs
│  ├─ Mapping/                  # AutoMapper/Mapster 等を使う場合
│  │  └─ MappingConfig.cs
│  └─ Errors/                   # 共通例外/エラー応答の形
│
└─ BuildingBlocks/              # さらに分けたい場合(任意)
   ├─ Abstractions/             # IFooService, IDateTimeProvider など
   └─ Behaviors/                # Pipeline/Validation/Logging など

見てもらえばわかると思いますが、機能ごとにすべてがまとまっているんです。これなら「ユーザー作成機能を修正したい」となったとき、CreateUserフォルダだけ見ればOK。めちゃくちゃシンプル

試しに実装してみる

理論はわかったので、実際にC#で実装してみることにしました。今回はMinimal APIとMediatRを使って、ユーザー作成のエンドポイントを作ってみようと思います。

1. Command(リクエスト)の定義

// Features/Users/Create/CreateUserCommand.cs
namespace MyApp.Features.Users.Create;

public record CreateUserCommand(
    string Email,
    string FirstName,
    string LastName,
    string Password
) : IRequest<Result<UserResponse>>;

public record UserResponse(
    Guid Id,
    string Email,
    string FullName,
    DateTime CreatedAt
);

2. Validatorの実装

// Features/Users/Create/CreateUserValidator.cs
public class CreateUserValidator : AbstractValidator<CreateUserCommand>
{
    public CreateUserValidator()
    {
        RuleFor(x => x.Email)
            .NotEmpty()
            .EmailAddress()
            .WithMessage("有効なメールアドレスを入力してください");

        RuleFor(x => x.FirstName)
            .NotEmpty()
            .MaximumLength(50);

        RuleFor(x => x.LastName)
            .NotEmpty()
            .MaximumLength(50);

        RuleFor(x => x.Password)
            .MinimumLength(8)
            .Matches(@"[A-Z]").WithMessage("大文字を含める必要があります")
            .Matches(@"[a-z]").WithMessage("小文字を含める必要があります")
            .Matches(@"[0-9]").WithMessage("数字を含める必要があります");
    }
}

3. Handlerの実装

// Features/Users/Create/CreateUserHandler.cs
public class CreateUserHandler : IRequestHandler<CreateUserCommand, Result<UserResponse>>
{
    private readonly ApplicationDbContext _context;
    private readonly IPasswordHasher _passwordHasher;
    private readonly IEmailService _emailService;

    public CreateUserHandler(
        ApplicationDbContext context,
        IPasswordHasher passwordHasher,
        IEmailService emailService)
    {
        _context = context;
        _passwordHasher = passwordHasher;
        _emailService = emailService;
    }

    public async Task<Result<UserResponse>> Handle(
        CreateUserCommand request,
        CancellationToken cancellationToken)
    {
        // ビジネスルール: メールアドレスの重複チェック
        var emailExists = await _context.Users
            .AnyAsync(u => u.Email == request.Email, cancellationToken);
        
        if (emailExists)
        {
            return Result<UserResponse>.Failure("このメールアドレスは既に使用されています");
        }

        // エンティティの作成
        var user = User.Create(
            request.Email,
            request.FirstName,
            request.LastName,
            _passwordHasher.Hash(request.Password)
        );

        _context.Users.Add(user);
        await _context.SaveChangesAsync(cancellationToken);

        // ウェルカムメールの送信
        await _emailService.SendWelcomeEmailAsync(user.Email, user.FullName);

        // レスポンスの作成
        var response = new UserResponse(
            user.Id,
            user.Email,
            user.FullName,
            user.CreatedAt
        );

        return Result<UserResponse>.Success(response);
    }
}

4. Endpointの定義

// Features/Users/Create/CreateUserEndpoint.cs
public class CreateUserEndpoint : IEndpoint
{
    public void MapEndpoint(IEndpointRouteBuilder app)
    {
        app.MapPost("/api/users", async (
            CreateUserCommand command,
            ISender sender,
            CancellationToken ct) =>
        {
            var result = await sender.Send(command, ct);
            
            return result.IsSuccess 
                ? Results.Created($"/api/users/{result.Value.Id}", result.Value)
                : Results.BadRequest(result.Error);
        })
        .WithName("CreateUser")
        .WithTags("Users")
        .Produces<UserResponse>(StatusCodes.Status201Created)
        .Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
        .WithOpenApi();
    }
}

5. モジュールでエンドポイントを束ねる

// Api/Modules/UsersModule.cs
public class UsersModule : IModule
{
    public void RegisterEndpoints(IEndpointRouteBuilder app)
    {
        var endpoints = new List<IEndpoint>
        {
            new CreateUserEndpoint(),
            new GetUserEndpoint(),
            new UpdateUserEndpoint(),
            new DeleteUserEndpoint()
        };

        foreach (var endpoint in endpoints)
        {
            endpoint.MapEndpoint(app);
        }
    }
}

期待できそうなメリット

このアーキテクチャを採用すると、いくつか良いことがありそうです。

1. 機能の全体像が一目でわかる

CreateUserフォルダを開けば、その機能に関するすべてが見えるはず。レビューする側も「この機能はこのフォルダだけ見ればいいんだな」とすぐわかるので、コードレビューもスムーズになりそうです。

実際に、Milan Jovanovićの記事でも以下のように述べられています:

"Each slice is self-contained and includes everything needed to handle a specific request. This makes the codebase easier to navigate and understand."

2. 依存関係がシンプル

各機能が独立しているので、「このサービスクラスを変更したら、どこに影響があるんだろう...」という不安が減りそう。CreateUserの処理を変更しても、GetUserには影響しない。当たり前のようで、これがすごく安心感がありそうですよね。

【依存関係の可視化】

レイヤードアーキテクチャ:
Controller → Service → Repository → Database
     ↓          ↓          ↓
   (共有)     (共有)     (共有)
     ↓          ↓          ↓
Other Controllers/Services/Repositories

Vertical Slice:
CreateUser → Database
   (独立)

GetUser → Database
   (独立)

3. テストが書きやすい

機能ごとに必要なものがまとまっているので、テストも同じフォルダに置けます。テストファイルと実装ファイルが近いので、「このテスト、何をテストしてるんだっけ?」となることも減るんじゃないでしょうか。

// Features/Users/Create/CreateUserHandlerTests.cs
public class CreateUserHandlerTests
{
    [Fact]
    public async Task Handle_ValidCommand_CreatesUser()
    {
        // Arrange
        var command = new CreateUserCommand(
            "test@example.com",
            "John",
            "Doe",
            "Password123!"
        );

        // テストに必要なものだけをモック
        var context = GetInMemoryContext();
        var handler = new CreateUserHandler(
            context, 
            new FakePasswordHasher(), 
            new FakeEmailService()
        );

        // Act
        var result = await handler.Handle(command, CancellationToken.None);

        // Assert
        result.IsSuccess.Should().BeTrue();
        result.Value.Email.Should().Be("test@example.com");
    }
}

4. 新メンバーへの説明が楽

新しくチームに入ったメンバーに「ユーザー作成機能を修正してほしい」と頼むとき、「Features/Users/CreateUserを見てね」で済みそう。以前なら「Controllerはここで、Serviceはここで、Repositoryは...」と説明する必要があったのが、シンプルになるはずです。

5. 機能の追加・削除が容易

新機能を追加する際は、新しいスライス(フォルダ)を作るだけ。削除する際も、該当フォルダを削除すれば完了。レイヤードアーキテクチャのように、複数の場所に散らばったコードを探し回る必要がありません。

気をつけたほうが良さそうなポイント

もちろん、課題もありそうなので、先に考えておきたいです。

1. 共通処理の扱いをどうするか

たとえば、メール送信みたいな複数の機能で使う処理をどこに置くか悩みそう。Sharedフォルダを作るのが一般的らしいですが、「どこからが共通処理なのか」の判断基準を決めておいたほうが良さそうです。

実践的なガイドライン:

  • 最初は機能フォルダに入れておく(YAGNI原則)
  • 2回目に使うタイミングで共通化を検討
  • 3回目で確実に共通化(Rule of Three)
// 共通化の判断フロー
1回目: Features/Users/Create/EmailNotification.cs
2回目: 「あれ、似たようなの前も書いたな...」→ まだ我慢
3回目: 「OK、これは共通化しよう」→ Shared/Services/EmailService.cs

2. コードの重複をどこまで許容するか

GetUserとGetUserListで、似たようなレスポンスクラスを別々に定義することになるかも。DRY原則的には気になりますが、「多少の重複より、独立性の方が大事」という考え方もあるみたいです。

Milan Jovanovićも指摘しているように:

"Some duplication is acceptable if it means keeping slices independent and maintainable."

重複を許容すべきケース:

  • DTOやViewModelなど、機能固有の表現
  • バリデーションロジック(微妙に異なることが多い)
  • 簡単なユーティリティ関数

共通化すべきケース:

  • ドメインエンティティ
  • 外部サービスとの通信
  • 複雑なビジネスロジック

3. フォルダ構造が深くなる問題

Features/Users/CreateUser/CreateUserHandler.cs みたいに、パスが長くなりがち。IDEの検索機能があれば大丈夫そうですが、慣れるまでは戸惑うかもしれません。

対策案:

// ファイル名を短縮する
Features/Users/Create/Handler.cs  // CreateUserHandler.cs の代わり
Features/Users/Create/Command.cs  // CreateUserCommand.cs の代わり

// 名前空間を活用
namespace MyApp.Features.Users.Create;
// すべてのクラスがこの名前空間に属するので、接頭辞は不要

4. トランザクション境界の管理

複数の集約を跨ぐトランザクションが必要な場合、どこで管理するか悩みそうです。

// MediatRのPipelineBehaviorを使った解決策
public class TransactionBehavior<TRequest, TResponse> 
    : IPipelineBehavior<TRequest, TResponse>
{
    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        // トランザクション属性をチェック
        if (request.GetType().GetCustomAttribute<TransactionalAttribute>() != null)
        {
            using var transaction = await _context.Database
                .BeginTransactionAsync(cancellationToken);
            try
            {
                var response = await next();
                await transaction.CommitAsync(cancellationToken);
                return response;
            }
            catch
            {
                await transaction.RollbackAsync(cancellationToken);
                throw;
            }
        }

        return await next();
    }
}

導入するときのアイデア

もし実際に導入するなら、こんな感じで進めるのが良さそうです。

1. スモールスタートで試してみる

いきなり全部変えるのはリスクが高いので、新機能1つだけVertical Sliceで実装してみて、チームで感触を確かめるのが良さそう。

Phase 1: 新機能1つで試験導入(1-2週間)
Phase 2: チームでレトロスペクティブ
Phase 3: 改善点を反映して2-3機能で展開
Phase 4: 全体移行の判断

2. 無理に全部適用しようとしない

認証処理みたいな横断的関心事は、従来通りミドルウェアやフィルターで実装する方が自然かもしれません。

// これらは従来通りの実装で OK
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiting();
app.UseCors();

パフォーマンスとスケーラビリティ

Vertical Slice Architectureは、マイクロサービス化への移行も容易にします。各スライスが独立しているため、特定の機能だけを切り出してサービス化することが可能です。

【マイクロサービス移行パス】

Step 1: モノリス(Vertical Slice)
Features/
├─ Users/
├─ Orders/
└─ Payments/

Step 2: 切り出し
UserService/
└─ Features/Users/

OrderService/
└─ Features/Orders/

PaymentService/
└─ Features/Payments/

まとめ

Vertical Slice Architectureは、機能ごとにコードをまとめることで、開発体験を向上させてくれそうなアーキテクチャです。特に「この機能、どこから手をつければいいんだ?」という迷いが減りそうなのが魅力的。

もちろん銀の弾丸ではないし、プロジェクトの性質によっては合わないケースもあるでしょう。でも、「レイヤードアーキテクチャに疲れた」「もっとシンプルに機能開発したい」と感じているなら、試してみる価値はありそう。

個人的にはチームで開発する際はこの実装になっているとfeatures(ドメイン)がレビューしやすくてありがたい。

実際に導入を検討される方は、Milan Jovanovićの詳細な記事も参考にしてみてください。実装パターンやベストプラクティスがより詳しく解説されています。

参考資料:

Accenture Japan (有志)

Discussion