第75章【演習】ASP.NET Coreでクリーンアーキテクチャ最小構成を自作する 🧱✨
この章は「4つのプロジェクトに分けて、ちゃんと動くWeb APIまで通す」のがゴールだよ😊🎯 完成したら、“変更に強い形” の土台があなたの手元に残る✨
今日つくるもの(超ミニToDo API)📝🌱
POST /todos… ToDoを作るGET /todos… 一覧GET /todos/{id}… 1件取得POST /todos/{id}/done… 完了にする
まず全体図(ここが「迷わない」ポイント)🧭✨
- Domain:ルール(ビジネスの心臓)🫀
- Application:ユースケース(やりたいこと)🎮
- Infrastructure:保存や外部(DBとか)🧰
- Web:HTTPの入り口(API)🚪
依存(矢印)は 外→内 だけにするよ🙆♀️

※ この構成なら、DBを変えても Domain が揺れにくい✨(土台が強い!)
1) ソリューション&プロジェクトを作る 🧱✨
A. クリック派(Visual Studio)🖱️💕
-
空のソリューションを作る(例:
MyApp) -
4つのプロジェクトを追加
MyApp.Domain(クラスライブラリ)MyApp.Application(クラスライブラリ)MyApp.Infrastructure(クラスライブラリ)MyApp.Web(ASP.NET Core Web API)
-
ターゲットは .NET 10(LTS) にしてね(2025の最新ど真ん中)✨ (Microsoft)
B. コマンド派(VS CodeでもOK)⌨️✨
(フォルダ構成は src 配下にまとめるのが気持ちいい🥰)
mkdir MyApp
cd MyApp
mkdir src
dotnet new sln -n MyApp
dotnet new classlib -n MyApp.Domain -o src/MyApp.Domain -f net10.0
dotnet new classlib -n MyApp.Application -o src/MyApp.Application -f net10.0
dotnet new classlib -n MyApp.Infrastructure -o src/MyApp.Infrastructure -f net10.0
dotnet new webapi -n MyApp.Web -o src/MyApp.Web -f net10.0
dotnet sln add src/MyApp.Domain
dotnet sln add src/MyApp.Application
dotnet sln add src/MyApp.Infrastructure
dotnet sln add src/MyApp.Web
2) 参照(依存)をつなぐ 🔗🧠
この順でOK✨
- Application → Domain
- Infrastructure → Application, Domain
- Web → Application, Infrastructure(Webは“組み立て係”なので両方参照してOK)
コマンドでやるなら👇
dotnet add src/MyApp.Application reference src/MyApp.Domain
dotnet add src/MyApp.Infrastructure reference src/MyApp.Application
dotnet add src/MyApp.Infrastructure reference src/MyApp.Domain
dotnet add src/MyApp.Web reference src/MyApp.Application
dotnet add src/MyApp.Web reference src/MyApp.Infrastructure
3) Domain(ルール)を作る 🫀✨
ここは「string や Guid をそのまま信用しない」がキモ😎 (=“意味のある型”にする)
TodoId.cs(Domain)
namespace MyApp.Domain.Todos;
public readonly record struct TodoId(Guid Value)
{
public static TodoId New() => new(Guid.NewGuid());
public override string ToString() => Value.ToString("D");
}
TodoTitle.cs(Domain)
namespace MyApp.Domain.Todos;
public sealed record TodoTitle
{
public string Value { get; }
private TodoTitle(string value) => Value = value;
public static bool TryCreate(string? value, out TodoTitle title, out string error)
{
title = default!;
error = "";
var v = (value ?? "").Trim();
if (v.Length == 0)
{
error = "タイトルが空だよ🥺";
return false;
}
if (v.Length > 50)
{
error = "タイトルは50文字以内にしてね🙏";
return false;
}
title = new TodoTitle(v);
return true;
}
public override string ToString() => Value;
}
TodoItem.cs(Domain)
namespace MyApp.Domain.Todos;
public sealed class TodoItem
{
public TodoId Id { get; }
public TodoTitle Title { get; private set; }
public bool IsDone { get; private set; }
public DateTimeOffset CreatedAt { get; }
public TodoItem(TodoId id, TodoTitle title, DateTimeOffset createdAt)
{
Id = id;
Title = title;
CreatedAt = createdAt;
}
public void MarkDone() => IsDone = true;
}
4) Application(ユースケース)を作る 🎮✨
Web(HTTP)からの要求を、**「アプリでやりたいこと」**に翻訳する層だよ🙂
Result.cs(Application)
namespace MyApp.Application.Common;
public readonly record struct Result<T>(bool IsSuccess, T? Value, string? Error)
{
public static Result<T> Success(T value) => new(true, value, null);
public static Result<T> Fail(string error) => new(false, default, error);
}
TodoDto.cs(Application)
namespace MyApp.Application.Todos;
public sealed record TodoDto(
string Id,
string Title,
bool IsDone,
DateTimeOffset CreatedAt);
ITodoRepository.cs(Application)
using MyApp.Domain.Todos;
namespace MyApp.Application.Todos;
public interface ITodoRepository
{
Task AddAsync(TodoItem item, CancellationToken ct);
Task<TodoItem?> FindAsync(TodoId id, CancellationToken ct);
Task<IReadOnlyList<TodoItem>> ListAsync(CancellationToken ct);
Task SaveChangesAsync(CancellationToken ct);
}
TodoApplicationService.cs(Application)
using MyApp.Application.Common;
using MyApp.Domain.Todos;
namespace MyApp.Application.Todos;
public sealed class TodoApplicationService
{
private readonly ITodoRepository _repo;
public TodoApplicationService(ITodoRepository repo)
{
_repo = repo;
}
public async Task<Result<TodoDto>> CreateAsync(string? title, CancellationToken ct)
{
if (!TodoTitle.TryCreate(title, out var todoTitle, out var error))
return Result<TodoDto>.Fail(error);
var item = new TodoItem(TodoId.New(), todoTitle, DateTimeOffset.UtcNow);
await _repo.AddAsync(item, ct);
await _repo.SaveChangesAsync(ct);
return Result<TodoDto>.Success(ToDto(item));
}
public async Task<Result<TodoDto>> GetAsync(string id, CancellationToken ct)
{
if (!Guid.TryParse(id, out var guid))
return Result<TodoDto>.Fail("IDがGUIDじゃないよ🥺");
var item = await _repo.FindAsync(new TodoId(guid), ct);
if (item is null)
return Result<TodoDto>.Fail("見つからないよ😢");
return Result<TodoDto>.Success(ToDto(item));
}
public async Task<IReadOnlyList<TodoDto>> ListAsync(CancellationToken ct)
{
var items = await _repo.ListAsync(ct);
return items.Select(ToDto).ToList();
}
public async Task<Result<TodoDto>> MarkDoneAsync(string id, CancellationToken ct)
{
if (!Guid.TryParse(id, out var guid))
return Result<TodoDto>.Fail("IDがGUIDじゃないよ🥺");
var item = await _repo.FindAsync(new TodoId(guid), ct);
if (item is null)
return Result<TodoDto>.Fail("見つからないよ😢");
item.MarkDone();
await _repo.SaveChangesAsync(ct);
return Result<TodoDto>.Success(ToDto(item));
}
private static TodoDto ToDto(TodoItem item) =>
new(item.Id.Value.ToString("D"), item.Title.Value, item.IsDone, item.CreatedAt);
}
5) Infrastructure(保存役)を作る 🧰✨
今回は最小構成なので **DBなし(インメモリ)**でいくよ🙆♀️ あとでEF Coreに差し替えるのがめっちゃ簡単になる✨
InMemoryTodoRepository.cs(Infrastructure)
using System.Collections.Concurrent;
using MyApp.Application.Todos;
using MyApp.Domain.Todos;
namespace MyApp.Infrastructure.Todos;
public sealed class InMemoryTodoRepository : ITodoRepository
{
private readonly ConcurrentDictionary<Guid, TodoItem> _store = new();
public Task AddAsync(TodoItem item, CancellationToken ct)
{
_store[item.Id.Value] = item;
return Task.CompletedTask;
}
public Task<TodoItem?> FindAsync(TodoId id, CancellationToken ct)
{
_store.TryGetValue(id.Value, out var item);
return Task.FromResult(item);
}
public Task<IReadOnlyList<TodoItem>> ListAsync(CancellationToken ct)
{
IReadOnlyList<TodoItem> list = _store.Values
.OrderByDescending(x => x.CreatedAt)
.ToList();
return Task.FromResult(list);
}
public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask;
}
6) Web(API入口)を作る 🚪🌐
MyApp.Web のテンプレにあるサンプル(WeatherForecast)は消してOK✂️✨
そして Minimal API でサクッといくよ(最小APIは学習にも相性いい🫶)(Microsoft Learn)
Program.cs(Web)
using MyApp.Application.Todos;
using MyApp.Infrastructure.Todos;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// アプリ層
builder.Services.AddScoped<TodoApplicationService>();
// インフラ層(今はインメモリ)
builder.Services.AddSingleton<ITodoRepository, InMemoryTodoRepository>();
var app = builder.Build();
app.UseSwagger();
app.UseSwaggerUI();
app.MapPost("/todos", async (CreateTodoRequest req, TodoApplicationService svc, CancellationToken ct) =>
{
var result = await svc.CreateAsync(req.Title, ct);
return result.IsSuccess
? Results.Created($"/todos/{result.Value!.Id}", result.Value)
: Results.BadRequest(new { message = result.Error });
});
app.MapGet("/todos", async (TodoApplicationService svc, CancellationToken ct) =>
{
var list = await svc.ListAsync(ct);
return Results.Ok(list);
});
app.MapGet("/todos/{id}", async (string id, TodoApplicationService svc, CancellationToken ct) =>
{
var result = await svc.GetAsync(id, ct);
return result.IsSuccess
? Results.Ok(result.Value)
: Results.NotFound(new { message = result.Error });
});
app.MapPost("/todos/{id}/done", async (string id, TodoApplicationService svc, CancellationToken ct) =>
{
var result = await svc.MarkDoneAsync(id, ct);
return result.IsSuccess
? Results.Ok(result.Value)
: Results.NotFound(new { message = result.Error });
});
app.Run();
public sealed record CreateTodoRequest(string Title);
7) 実行して動作確認しよ〜!🚀✨
実際にAPIを叩いたときの動き(シーケンス)はこんな感じになります👇
コマンド派
dotnet run --project src/MyApp.Web
起動したら Swagger UI が出るはず😍
POST /todos → GET /todos で増えてたら成功🎉
AI(Copilot等)を“正しく”使うコツ 🤖💡
AIは超便利なんだけど、レイヤー違反を平気でやることがあるよ😂 だから、お願いの仕方をちょい工夫するのが勝ち✨
使えるお願いテンプレ(コピペOK)📌
- 「Domain は他の層に依存しないで。
MyApp.Domainには外部ライブラリ持ち込まないで」 - 「Web から Domain を直接触らず、ApplicationService 経由にして」
- 「Infrastructure は Repository 実装だけ。ユースケースの判断は Application に置いて」
AIに最後にこれ聞くと強い💪
- 「このコード、依存方向が Clean Architecture に違反してない?違反箇所があれば指摘して✨」
よくある事故(ここで迷いがち)⚠️🥺
- WebがDomainを直接いじる(最初はやりがち!) → “ユースケース” を Application に寄せるとスッキリ😊
- ApplicationにEF Coreの型が混ざる → DBの都合は Infrastructure に隔離🧊
- DomainにDTOを置く → DTOは“画面や通信の都合”だから Domain じゃないよ🙅♀️
追加課題(できたら一気に強くなる🔥)🎯
-
✅
PUT /todos/{id}でタイトル変更(ただし空文字は禁止) -
✅
DELETE /todos/{id}を追加 -
🌟 Infrastructure を EF Core + SQLite に差し替え(Web側の変更は最小で済むはず!)
-
🌟 テストを1本だけ書く:
TodoTitle.TryCreate("")が false になることを確認🧪✨
ここまでできたら、あなたはもう「設計の型」を持ってる 🥰🧱
この章の勝ちポイントはこれ👇
- 4層に分けた
- 依存方向を守った
- ユースケースを Application に置いた
- 保存方法(Infrastructure)を差し替え可能にした
次に進むなら、課題3の「EF Coreに差し替え」が一番伸びるよ🚀✨(“構造の強さ”を体感できる!)