C#の循環参照とは?原因・エラー例・解決策を初心者向けにわかりやすく解説

はじめに

C#で開発していると、「循環参照」や「循環依存」という言葉に出会うことがあります。初心者のうちは、クラス同士を便利に呼び合えるようにしたり、プロジェクト参照を追加したりしているうちに、いつの間にか循環参照が発生していることがあります。

循環参照は、単に「エラーが出るから悪い」というだけではありません。ビルドできなくなったり、DIコンテナでアプリケーションが起動しなくなったり、JSON変換で例外が発生したり、コードの保守性が大きく下がったりする原因になります。

この記事では、C#の循環参照とは何か、なぜ発生するのか、どのようなエラーが出るのか、そしてどう解決すればよいのかを初心者向けにわかりやすく解説します。

1. C#の循環参照とは?

1-1. 循環参照の基本的な意味

循環参照とは、あるものが別のものを参照し、その参照先がさらに元のものを参照している状態のことです。

たとえば、AがBを参照し、BがAを参照している状態は循環参照です。

C#
class A
{
public B B { get; set; }
}

class B
{
public A A { get; set; }
}

このように、AからBへ、BからAへと参照が戻ってくる構造になっています。

C#では、循環参照という言葉がいくつかの場面で使われます。クラス同士の参照、プロジェクト同士の参照、DIの依存関係、Entity Frameworkのナビゲーションプロパティ、JSONシリアライズなどです。

重要なのは、「循環参照」という言葉が常に同じ種類の問題を指すわけではないという点です。コード上では問題なく書ける場合もあれば、ビルドエラーになる場合もあります。また、ビルドは通るけれど実行時に例外が出る場合もあります。

1-2. C#で循環参照が問題になる主な場面

C#で循環参照が問題になりやすい場面は、主に次のようなケースです。

まず、クラス同士が互いに依存しているケースです。たとえば、UserクラスがOrderクラスを持ち、OrderクラスもUserクラスを持つような構造です。この状態自体が必ずエラーになるわけではありませんが、設計が複雑になりやすくなります。

次に、プロジェクト参照の循環依存です。たとえば、App.CoreプロジェクトがApp.Infrastructureを参照し、App.InfrastructureがApp.Coreを参照しているような状態です。C#のプロジェクトでは、このような循環したプロジェクト参照は基本的にビルドエラーになります。

また、ASP.NET CoreなどでDIを使っている場合、サービスAがサービスBを必要とし、サービスBがサービスAを必要とすると、DIコンテナがインスタンスを作れなくなることがあります。

さらに、Entity FrameworkのエンティティやJSONシリアライズでも循環参照はよく問題になります。たとえば、UserがOrdersを持ち、OrderがUserを持つ場合、そのままJSONに変換しようとすると、User、Order、User、Orderと無限にたどってしまう可能性があります。

1-3. 初心者が混同しやすい「循環参照」と「相互参照」の違い

初心者が混同しやすい言葉に、「循環参照」と「相互参照」があります。

相互参照とは、2つのものが互いに参照し合っている状態です。たとえば、UserクラスがOrderクラスを参照し、OrderクラスがUserクラスを参照している状態です。

一方、循環参照はもう少し広い意味で使われます。AとBだけでなく、AがBを参照し、BがCを参照し、CがAを参照するような状態も循環参照です。

A → B → C → A

つまり、相互参照は循環参照の一種と考えることができます。

ただし、相互にプロパティを持っているだけで必ず悪いわけではありません。たとえばEntity Frameworkでは、親から子を参照し、子から親を参照する双方向のナビゲーションプロパティを定義することがあります。問題になるのは、その構造によってビルド、DI、シリアライズ、保守性などに悪影響が出る場合です。

2. C#で循環参照が発生する原因

2-1. クラス同士が互いに依存している

C#で循環参照が発生する代表的な原因は、クラス同士が互いに直接依存していることです。

たとえば、UserServiceがOrderServiceを呼び出し、OrderServiceもUserServiceを呼び出すような設計です。

C#
public class UserService
{
private readonly OrderService _orderService;

public UserService(OrderService orderService)
{
_orderService = orderService;
}
}

public class OrderService
{
private readonly UserService _userService;

public OrderService(UserService userService)
{
_userService = userService;
}
}

このようなコードでは、UserServiceを作るためにOrderServiceが必要で、OrderServiceを作るためにUserServiceが必要になります。どちらを先に作ればよいのか判断できず、DIコンテナで問題になることがあります。

原因は、片方のクラスが本来持つべきでない責務まで持っていることが多いです。UserServiceはユーザーに関する処理、OrderServiceは注文に関する処理に集中すべきですが、互いの内部処理に深く入り込みすぎると循環依存が起きやすくなります。

2-2. プロジェクト参照が相互依存している

C#では、複数のプロジェクトに分けてアプリケーションを構成することがあります。たとえば、次のような構成です。

MyApp.Web
MyApp.Application
MyApp.Domain
MyApp.Infrastructure

このとき、プロジェクト参照の方向が整理されていないと循環依存が発生します。

たとえば、MyApp.ApplicationがMyApp.Infrastructureを参照し、MyApp.InfrastructureがMyApp.Applicationを参照している状態です。

MyApp.Application → MyApp.Infrastructure
MyApp.Infrastructure → MyApp.Application

このような参照関係は循環しています。

プロジェクト参照の循環依存は、クラス同士の相互参照よりも深刻です。Visual Studioや.NETのビルド時に、循環依存としてエラーになることがあります。

原因として多いのは、「このクラスを使いたいから、とりあえず参照を追加する」という対応を繰り返すことです。最初は小さな便利さのために追加した参照でも、プロジェクト全体の依存関係を複雑にしてしまいます。

2-3. DI(依存性注入)でサービス同士が依存している

ASP.NET Coreなどでは、DIを使ってサービスを登録し、必要なクラスをコンストラクタで受け取ることがよくあります。

C#
builder.Services.AddScoped<UserService>();
builder.Services.AddScoped<OrderService>();

DIは便利ですが、サービス同士が循環依存していると問題が起きます。

C#
public class UserService
{
public UserService(OrderService orderService)
{
}
}

public class OrderService
{
public OrderService(UserService userService)
{
}
}

この場合、DIコンテナはUserServiceを作成するためにOrderServiceを作成しようとします。しかし、OrderServiceを作成するにはUserServiceが必要です。その結果、依存関係がループしてしまいます。

DIで循環参照が起きる場合は、単に型をインターフェースに変えるだけでは根本解決にならないことがあります。依存の向きや責務の分け方そのものを見直す必要があります。

2-4. Entity FrameworkやJSON変換でオブジェクトがループしている

Entity Frameworkでは、エンティティ同士の関連をナビゲーションプロパティで表現します。

たとえば、Userが複数のOrderを持ち、OrderがUserを持つ構造です。

C#
public class User
{
public int Id { get; set; }
public List<Order> Orders { get; set; } = new();
}

public class Order
{
public int Id { get; set; }
public User User { get; set; } = null!;
}

このような構造は、データベースの関係を表現するうえでは自然です。しかし、これをそのままJSONに変換しようとすると問題になることがあります。

UserをJSONに変換すると、UserのOrdersが変換されます。Ordersの中のOrderにはUserがあります。そのUserにはまたOrdersがあります。このように、オブジェクトの参照がループしてしまいます。

APIでEntity Frameworkのエンティティをそのままレスポンスとして返すと、この問題に遭遇しやすくなります。

2-5. 設計段階で責務の分離ができていない

循環参照の根本原因は、設計段階で責務の分離ができていないことにある場合が多いです。

たとえば、ユーザー処理、注文処理、通知処理、ログ処理などが複雑に絡み合っている場合、クラス同士が互いに呼び合いやすくなります。

本来は、次のように責務を分ける必要があります。

UserServiceはユーザー情報の取得や更新を担当します。OrderServiceは注文の作成や取得を担当します。NotificationServiceはメール送信や通知を担当します。共通処理が必要な場合は、別のサービスに切り出します。

このように役割を明確にしておくと、あるクラスが別のクラスに過度に依存することを防ぎやすくなります。

3. C#の循環参照で起きる代表的なエラー例

3-1. プロジェクト参照で発生する循環依存エラー

C#のプロジェクト参照で循環依存が発生すると、ビルド時にエラーになることがあります。

たとえば、ProjectAがProjectBを参照し、ProjectBがProjectAを参照しているケースです。

ProjectA → ProjectB
ProjectB → ProjectA

この場合、どちらのプロジェクトを先にビルドすればよいか判断できません。ProjectAをビルドするにはProjectBが必要で、ProjectBをビルドするにはProjectAが必要になるためです。

Visual Studioでは、プロジェクト参照を追加しようとした時点で循環依存を検出して、参照を追加できないことがあります。また、ビルド時に循環依存に関するエラーが表示されることもあります。

この問題は、共通で使いたい型や処理をどちらか一方のプロジェクトに置いてしまっていることが原因で起きやすいです。

3-2. DIコンテナで発生する循環依存エラー

DIコンテナで循環依存が発生すると、アプリケーション起動時やサービス解決時に例外が発生します。

たとえば、次のような構造です。

C#
public class AService
{
public AService(BService bService)
{
}
}

public class BService
{
public BService(AService aService)
{
}
}

この状態でAServiceをDIから取得しようとすると、AServiceを作るためにBServiceが必要になり、BServiceを作るためにAServiceが必要になります。

ASP.NET CoreのDIでは、循環依存が検出されると、サービスを構築できないという内容の例外が発生します。

このエラーが出た場合、まず確認すべきなのはコンストラクタです。サービスAのコンストラクタでサービスBを受け取り、サービスBのコンストラクタでサービスAを受け取っていないかを確認します。

3-3. JSONシリアライズ時に発生する循環参照エラー

JSONシリアライズ時にも循環参照のエラーが発生することがあります。

たとえば、次のようなエンティティをAPIでそのまま返すケースです。

C#
public class User
{
public int Id { get; set; }
public List<Order> Orders { get; set; } = new();
}

public class Order
{
public int Id { get; set; }
public User User { get; set; } = null!;
}

このUserをJSONに変換しようとすると、UserからOrdersへ、OrderからUserへ、さらにUserからOrdersへと参照が続きます。

その結果、JSONシリアライザーが循環を検出して例外を投げたり、オブジェクトの深さが上限を超えたりすることがあります。

この問題は、APIレスポンスにエンティティをそのまま返していると起きやすいです。解決策としては、DTOを使って必要なデータだけ返す方法がよく使われます。

3-4. StackOverflowExceptionが発生するケース

循環参照によって、無限再帰が発生し、StackOverflowExceptionになることもあります。

たとえば、プロパティの中で互いを呼び合っているケースです。

C#
public class A
{
private B _b = new B();

public string Name => _b.Name;
}

public class B
{
private A _a = new A();

public string Name => _a.Name;
}

この例では、AのNameを取得するとBのNameを取得し、BのNameを取得するとAのNameを取得します。これが無限に続き、最終的にスタック領域を使い切ってStackOverflowExceptionが発生します。

StackOverflowExceptionは通常の例外のように簡単にcatchして処理できるものではありません。そのため、発生してから対処するのではなく、無限再帰が起きない設計にすることが大切です。

3-5. ビルドできる場合でも保守性が下がる理由

循環参照は、必ずしもすぐにエラーになるとは限りません。クラス同士が相互にプロパティを持つだけなら、ビルドできることもあります。

しかし、ビルドできるからといって安全とは限りません。

循環参照があると、片方のクラスを修正したときに、もう片方のクラスにも影響が出やすくなります。テストもしづらくなります。どのクラスがどのクラスに依存しているのか分かりにくくなり、コードを読む人の負担も増えます。

また、新しい機能を追加するときに、さらに別のクラスが依存関係に加わり、循環が複雑化することもあります。

そのため、循環参照は「エラーが出たら直すもの」ではなく、「設計段階でできるだけ避けるもの」と考えるのがよいです。

4. クラス同士の循環参照の具体例

4-1. 悪い例:UserクラスとOrderクラスが相互依存するコード

まずは、UserクラスとOrderクラスが相互依存している例を見てみましょう。

C#
public class User
{
public int Id { get; set; }
public string Name { get; set; } = "";

public List<Order> Orders { get; set; } = new();

public decimal GetTotalAmount()
{
return Orders.Sum(order => order.GetAmountForUser(this));
}
}

public class Order
{
public int Id { get; set; }
public decimal Amount { get; set; }

public User User { get; set; } = null!;

public decimal GetAmountForUser(User user)
{
if (User.Id == user.Id)
{
return Amount;
}

return 0;
}
}

この例では、UserがOrderの一覧を持ち、OrderもUserを持っています。さらに、Userの処理の中でOrderのメソッドを呼び出し、Orderの処理でもUserを利用しています。

Entity Frameworkのエンティティとしては、UserとOrderが関連を持つこと自体は珍しくありません。しかし、エンティティの中にビジネスロジックを強く持たせ、互いに処理を呼び合うようになると、依存関係が複雑になります。

4-2. なぜこのコードが問題になるのか

このコードの問題は、UserとOrderが互いの内部構造に依存していることです。

UserはOrderのメソッドを呼び出しています。OrderはUserのIdを見ています。つまり、Userを変更するとOrderに影響が出る可能性があり、Orderを変更するとUserに影響が出る可能性があります。

また、JSONに変換する場合にも問題が起きやすくなります。UserにはOrdersがあり、OrderにはUserがあります。この状態でUserをそのままJSONにすると、循環参照になる可能性があります。

さらに、テストもしづらくなります。UserのテストをしたいだけなのにOrderの状態を用意する必要があり、OrderのテストをしたいだけなのにUserの状態を用意する必要があります。

このように、クラス同士が強く結びついていると、小さな変更が広い範囲に影響しやすくなります。

4-3. 循環参照を避けるための考え方

循環参照を避けるためには、「どちらがどちらを知るべきか」を考えることが大切です。

たとえば、注文の合計金額を計算したいのであれば、Userクラス自身がOrderの細かい処理を知る必要があるでしょうか。場合によっては、OrderServiceやOrderCalculatorのような別のクラスに計算処理を分離したほうがよいです。

C#
public class OrderCalculator
{
public decimal CalculateTotalAmount(IEnumerable<Order> orders)
{
return orders.Sum(order => order.Amount);
}
}

このようにすると、UserとOrderが互いに処理を呼び合う必要がなくなります。

Userはユーザー情報を表すクラス、Orderは注文情報を表すクラス、OrderCalculatorは注文金額を計算するクラス、というように責務を分けることができます。

循環参照を避ける基本は、クラスの責務を小さくし、依存の向きを一方向にすることです。

4-4. インターフェースを使って依存を弱める方法

循環参照を避ける方法の一つに、インターフェースを使う方法があります。

たとえば、具象クラス同士が直接依存している場合、片方をインターフェースに置き換えることで依存を弱められることがあります。

C#
public interface IOrderReader
{
IEnumerable<Order> GetOrdersByUserId(int userId);
}

public class UserService
{
private readonly IOrderReader _orderReader;

public UserService(IOrderReader orderReader)
{
_orderReader = orderReader;
}

public decimal GetTotalAmount(int userId)
{
return _orderReader.GetOrdersByUserId(userId)
.Sum(order => order.Amount);
}
}

public class OrderService : IOrderReader
{
public IEnumerable<Order> GetOrdersByUserId(int userId)
{
return new List<Order>();
}
}

この例では、UserServiceはOrderServiceそのものではなく、IOrderReaderに依存しています。これにより、UserServiceは注文の取得方法を詳しく知る必要がなくなります。

ただし、インターフェースを使えば必ず循環参照が解決するわけではありません。インターフェースを使っても、依存の向きが循環していれば問題は残ります。

重要なのは、インターフェースを使うこと自体ではなく、依存関係を整理することです。

5. プロジェクト参照の循環依存を解決する方法

5-1. 共通処理を別プロジェクトに切り出す

プロジェクト参照の循環依存を解決する代表的な方法は、共通で使いたい処理を別プロジェクトに切り出すことです。

たとえば、ProjectAとProjectBがお互いを参照しているとします。

ProjectA → ProjectB
ProjectB → ProjectA

この原因が、両方のプロジェクトで共通のクラスを使いたいことにあるなら、その共通クラスをProjectCommonに移動します。

ProjectA → ProjectCommon
ProjectB → ProjectCommon

このようにすると、ProjectAとProjectBが直接参照し合う必要がなくなります。

共通プロジェクトには、DTO、定数、共通の例外クラス、共通のインターフェースなどを置くことがあります。ただし、何でも共通プロジェクトに入れてしまうと、今度は共通プロジェクトが肥大化します。あくまで複数のプロジェクトで本当に共有すべきものだけを置くことが大切です。

5-2. 参照方向を一方向に整理する

プロジェクト参照では、依存の方向を一方向に整理することが重要です。

たとえば、次のようなレイヤー構成を考えます。

Web → Application → Domain
Infrastructure → Application
Infrastructure → Domain

このように、上位レイヤーが下位レイヤーを使う、または外側のレイヤーが内側のレイヤーを使うように参照方向を決めます。

悪い例は、ApplicationがInfrastructureを参照し、InfrastructureもApplicationを参照するような状態です。

Application ↔ Infrastructure

これでは循環依存になります。

参照方向を整理するには、「どのプロジェクトが中心となるルールを持つのか」「どのプロジェクトが外部との接続を担当するのか」を明確にする必要があります。

Domainには業務ルール、Applicationにはユースケース、Infrastructureにはデータベースや外部APIへの接続、Webには画面やAPIの入り口を置く、というように役割を分けると整理しやすくなります。

5-3. インターフェース用のプロジェクトを作成する

プロジェクト参照の循環依存を解決する方法として、インターフェース用のプロジェクトを作成することもあります。

たとえば、ApplicationからInfrastructureの実装を直接参照すると、依存関係が複雑になりやすいです。その場合、Application側にインターフェースを定義し、Infrastructure側で実装します。

C#
public interface IUserRepository
{
User? FindById(int id);
}

ApplicationはIUserRepositoryに依存し、InfrastructureはIUserRepositoryを実装します。

Application → Domain
Infrastructure → Application

このようにすると、Applicationは具体的なデータベース処理を知る必要がありません。InfrastructureがApplicationのインターフェースに合わせて実装する形になります。

ただし、プロジェクト構成によっては、インターフェースだけを集めたAbstractionsプロジェクトを作ることもあります。

Application → Abstractions
Infrastructure → Abstractions

この方法を使うと、共通の契約を別プロジェクトに置けるため、循環依存を避けやすくなります。

5-4. レイヤードアーキテクチャで依存関係を整理する

循環参照を防ぐには、レイヤードアーキテクチャで依存関係を整理するのも効果的です。

レイヤードアーキテクチャでは、アプリケーションを役割ごとの層に分けます。

Presentation
Application
Domain
Infrastructure

Presentationは画面やAPIの入り口を担当します。Applicationはユースケースを担当します。Domainは業務ルールを担当します。Infrastructureはデータベースや外部サービスとの接続を担当します。

大切なのは、各レイヤーの依存方向を決めることです。たとえば、PresentationはApplicationを呼び出しますが、ApplicationがPresentationを呼び出すべきではありません。

依存関係の方向が決まっていないと、必要になるたびに参照を追加してしまい、結果として循環依存が発生します。

プロジェクトが増えてきたら、コードを書く前に参照関係を図にして確認するのがおすすめです。

6. DIで循環参照が起きたときの解決策

6-1. サービス同士の責務を見直す

DIで循環参照が起きた場合、まず行うべきなのはサービス同士の責務を見直すことです。

たとえば、UserServiceとOrderServiceが互いに依存しているとします。

C#
public class UserService
{
public UserService(OrderService orderService)
{
}
}

public class OrderService
{
public OrderService(UserService userService)
{
}
}

この場合、「本当にUserServiceがOrderServiceを知る必要があるのか」「OrderServiceがUserServiceを知る必要があるのか」を考えます。

多くの場合、どちらかのサービスが本来持つべきではない処理を持っています。たとえば、注文作成時にユーザー情報を確認したいだけなら、OrderServiceがUserService全体に依存するのではなく、ユーザー確認専用の小さなサービスに依存するほうがよいかもしれません。

C#
public interface IUserChecker
{
bool Exists(int userId);
}

このように、必要な機能だけを切り出すと、サービス同士の結びつきを弱めることができます。

6-2. 片方の依存をインターフェースに置き換える

循環依存を解決するために、片方の依存をインターフェースに置き換えることがあります。

ただし、注意が必要です。次のように、インターフェースを使っても依存の循環が残っていれば解決にはなりません。

C#
public class UserService : IUserService
{
public UserService(IOrderService orderService)
{
}
}

public class OrderService : IOrderService
{
public OrderService(IUserService userService)
{
}
}

このコードでは、具象クラスではなくインターフェースに依存していますが、依存関係はまだ循環しています。

IUserService → IOrderService → IUserService

インターフェースが有効なのは、依存の方向を整理できる場合です。

たとえば、OrderServiceがUserService全体を必要としているのではなく、「ユーザーが存在するか確認する機能」だけを必要としているなら、IUserCheckerのような小さなインターフェースに分けます。

C#
public interface IUserChecker
{
bool Exists(int userId);
}

public class OrderService
{
private readonly IUserChecker _userChecker;

public OrderService(IUserChecker userChecker)
{
_userChecker = userChecker;
}

public void CreateOrder(int userId)
{
if (!_userChecker.Exists(userId))
{
throw new InvalidOperationException("ユーザーが存在しません。");
}
}
}

このように、必要な機能だけに依存することで、循環参照を避けやすくなります。

6-3. 第三のサービスに共通処理を分離する

サービスAとサービスBが互いに依存している場合、共通処理を第三のサービスに分離することで解決できることがあります。

たとえば、UserServiceとOrderServiceの両方で通知処理を行っている場合、通知処理をNotificationServiceに切り出します。

C#
public class NotificationService
{
public void Send(string message)
{
// 通知処理
}
}

そして、UserServiceとOrderServiceはNotificationServiceに依存します。

C#
public class UserService
{
private readonly NotificationService _notificationService;

public UserService(NotificationService notificationService)
{
_notificationService = notificationService;
}
}

public class OrderService
{
private readonly NotificationService _notificationService;

public OrderService(NotificationService notificationService)
{
_notificationService = notificationService;
}
}

このようにすると、UserServiceとOrderServiceが互いに呼び合う必要がなくなります。

UserService → NotificationService
OrderService → NotificationService

共通処理をどこに置くか迷ったときは、「その処理はどの責務に属するのか」を考えることが大切です。どちらのサービスにも属さない処理なら、第三のサービスに分けることで設計がすっきりします。

6-4. LazyやFactoryを使う場合の注意点

DIの循環依存を回避するために、LazyやFactoryを使う方法が紹介されることがあります。

たとえば、必要になるまでインスタンス生成を遅らせる方法です。

C#
public class AService
{
private readonly Lazy<BService> _bService;

public AService(Lazy<BService> bService)
{
_bService = bService;
}
}

または、Funcを使って必要なタイミングで取得する方法もあります。

C#
public class AService
{
private readonly Func<BService> _bServiceFactory;

public AService(Func<BService> bServiceFactory)
{
_bServiceFactory = bServiceFactory;
}
}

ただし、これらは根本的な解決ではなく、一時的な回避策になることが多いです。依存関係が循環している事実は残っているため、コードの理解が難しくなったり、実行時に別の問題が発生したりする可能性があります。

LazyやFactoryを使う場合は、「本当に設計を変えられない理由があるのか」を確認しましょう。初心者の場合は、まず責務の分離や依存方向の整理を優先するのがおすすめです。

7. JSONやEntity Frameworkでの循環参照を解決する方法

7-1. JSONシリアライズで循環参照が起きる理由

JSONシリアライズで循環参照が起きる理由は、オブジェクト同士が互いに参照し合っているためです。

たとえば、UserがOrdersを持ち、OrderがUserを持っているとします。

C#
public class User
{
public int Id { get; set; }
public string Name { get; set; } = "";
public List<Order> Orders { get; set; } = new();
}

public class Order
{
public int Id { get; set; }
public decimal Amount { get; set; }
public User User { get; set; } = null!;
}

このUserをJSONに変換すると、次のように参照が続きます。

User
→ Orders
→ Order
→ User
→ Orders
→ Order
→ User

このように同じオブジェクトを何度もたどってしまうため、シリアライザーが循環参照としてエラーを出すことがあります。

特にASP.NET CoreのAPIでEntity Frameworkのエンティティをそのまま返していると、この問題に遭遇しやすくなります。

7-2. ReferenceHandler.IgnoreCyclesを使う方法

System.Text.Jsonでは、ReferenceHandler.IgnoreCyclesを使って循環参照を無視する設定ができます。

ASP.NET Coreでは、Program.csで次のように設定できます。

C#
using System.Text.Json.Serialization;

builder.Services.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
});

この設定を行うと、JSONシリアライズ時に循環参照が検出された場合、その参照を無視して処理できます。

ただし、IgnoreCyclesを使えばすべて解決というわけではありません。循環している部分が省略されるため、レスポンスに含まれるデータが期待と異なる場合があります。

たとえば、OrderのUserが省略されたり、UserのOrdersの一部が期待通りに出力されなかったりする可能性があります。

そのため、IgnoreCyclesは便利な回避策ですが、APIのレスポンス設計としてはDTOを使う方法のほうが安全です。

7-3. DTOを使って返却データを整理する方法

JSONの循環参照を防ぐ最もおすすめの方法は、DTOを使って返却データを整理することです。

DTOとは、Data Transfer Objectの略で、データを受け渡すための専用クラスです。

Entity FrameworkのエンティティをそのままAPIレスポンスに返すのではなく、必要な項目だけをDTOに詰め替えて返します。

C#
public class UserDto
{
public int Id { get; set; }
public string Name { get; set; } = "";
public List<OrderDto> Orders { get; set; } = new();
}

public class OrderDto
{
public int Id { get; set; }
public decimal Amount { get; set; }
}

このDTOでは、OrderDtoの中にUserDtoを持たせていません。そのため、UserDtoからOrderDtoへは参照しますが、OrderDtoからUserDtoへ戻る参照がありません。

C#
var userDto = new UserDto
{
Id = user.Id,
Name = user.Name,
Orders = user.Orders.Select(order => new OrderDto
{
Id = order.Id,
Amount = order.Amount
}).ToList()
};

このように、APIで返す形を明確に決めることで、循環参照を避けられます。

DTOを使うメリットは、循環参照を防げるだけではありません。不要なデータを隠せる、レスポンスの形式を安定させられる、エンティティの内部構造を外部に見せずに済む、という利点もあります。

7-4. Entity Frameworkのナビゲーションプロパティを見直す

Entity Frameworkでは、ナビゲーションプロパティを使ってテーブル同士の関連を表現します。

C#
public class User
{
public int Id { get; set; }
public List<Order> Orders { get; set; } = new();
}

public class Order
{
public int Id { get; set; }
public int UserId { get; set; }
public User User { get; set; } = null!;
}

このような双方向のナビゲーションプロパティは便利ですが、常に必要とは限りません。

もしOrderからUserへたどる必要がないのであれば、Order側のUserプロパティを削除し、UserIdだけを持つ設計にすることもできます。

C#
public class Order
{
public int Id { get; set; }
public int UserId { get; set; }
}

もちろん、業務上OrderからUserを参照する必要がある場合は、ナビゲーションプロパティを残してもかまいません。ただし、その場合でもAPIレスポンスではDTOを使って循環しない形に変換するのが安全です。

ナビゲーションプロパティは便利だから全部に付けるのではなく、「本当にその方向の参照が必要か」を考えて定義しましょう。

7-5. Includeや双方向関連を使うときの注意点

Entity Frameworkでは、Includeを使うことで関連データを一緒に取得できます。

C#
var users = dbContext.Users
.Include(user => user.Orders)
.ToList();

このコードでは、Userと関連するOrdersをまとめて取得します。

ただし、双方向のナビゲーションプロパティがある場合、取得したオブジェクトにはUserからOrders、OrderからUserという参照が存在することがあります。

この状態でエンティティをそのままJSONに変換すると、循環参照の問題が起きやすくなります。

Includeを使うときは、必要なデータだけを取得してDTOに変換するのがおすすめです。

C#
var users = dbContext.Users
.Select(user => new UserDto
{
Id = user.Id,
Name = user.Name,
Orders = user.Orders.Select(order => new OrderDto
{
Id = order.Id,
Amount = order.Amount
}).ToList()
})
.ToList();

このようにSelectでDTOに変換すれば、必要なデータだけを取得し、循環参照を避けることができます。

8. 循環参照を防ぐ設計のポイント

8-1. 依存関係は一方向にする

循環参照を防ぐための基本は、依存関係を一方向にすることです。

悪い例は、AがBを参照し、BがAを参照する状態です。

A ↔ B

よい例は、AがBを参照するだけ、またはBがAを参照するだけの状態です。

A → B

どちらがどちらを参照するべきかは、責務やレイヤーによって決めます。

たとえば、Web層はApplication層を呼び出しますが、Application層がWeb層を知る必要はありません。Application層はDomain層を使いますが、Domain層がApplication層を知る必要はありません。

依存方向を決めずに開発を進めると、必要なクラスを使うたびに参照を追加してしまい、循環依存が起きやすくなります。

8-2. クラスやプロジェクトの責務を明確にする

循環参照は、責務が曖昧なときに発生しやすくなります。

たとえば、UserServiceがユーザー処理だけでなく、注文処理、通知処理、ログ処理まで担当していると、さまざまなサービスに依存することになります。その結果、他のサービスからもUserServiceを呼び出したくなり、依存関係が複雑になります。

クラスやプロジェクトの責務を明確にすると、どこに何を書くべきか判断しやすくなります。

UserServiceはユーザー関連の処理、OrderServiceは注文関連の処理、NotificationServiceは通知処理、Repositoryはデータアクセス処理、Controllerはリクエストの受け口、というように役割を分けましょう。

責務が明確になれば、不要な相互依存を減らせます。

8-3. 共通処理を適切な場所に分離する

複数のクラスやプロジェクトで同じ処理が必要になると、循環参照が発生しやすくなります。

たとえば、ProjectAにある共通処理をProjectBでも使いたい場合、ProjectBからProjectAを参照します。その後、ProjectAでもProjectBの処理を使いたくなると、循環依存になります。

このような場合は、共通処理を別の場所に分離します。

ProjectA → Common
ProjectB → Common

共通処理の置き場所としては、Commonプロジェクト、Sharedプロジェクト、Abstractionsプロジェクトなどがあります。

ただし、共通化しすぎるとCommonプロジェクトが何でも置き場になってしまいます。共通処理として切り出すべきかどうかは、「複数の場所で本当に同じ意味で使われるか」を基準に判断しましょう。

8-4. インターフェースやDTOを活用する

循環参照を防ぐには、インターフェースやDTOをうまく活用することが重要です。

インターフェースを使うと、具体的な実装ではなく、必要な機能の契約に依存できます。

C#
public interface IUserRepository
{
User? FindById(int id);
}

これにより、Application層はInfrastructure層の具体的なクラスを知らなくても処理を書けます。

DTOは、APIレスポンスや画面表示に必要なデータだけを渡すために使います。

C#
public class UserResponse
{
public int Id { get; set; }
public string Name { get; set; } = "";
}

DTOを使えば、Entity Frameworkのエンティティをそのまま外部に返さずに済みます。その結果、JSONの循環参照を防ぎやすくなります。

インターフェースは依存関係の整理に、DTOはデータの受け渡しの整理に役立ちます。

8-5. 依存関係を図にして確認する

循環参照を防ぐには、依存関係を図にして確認するのも効果的です。

コードだけを見ていると、どのクラスがどのクラスに依存しているのか分かりにくくなることがあります。特にプロジェクト数やサービス数が増えると、頭の中だけで整理するのは難しくなります。

簡単なテキスト図でも十分です。

Web → Application → Domain
Infrastructure → Application
Infrastructure → Domain

このように書き出してみると、依存方向が整理されているか確認できます。

もし次のような形になっていたら注意が必要です。

Application → Infrastructure
Infrastructure → Application

このような相互参照が見つかったら、共通処理の切り出し、インターフェースの配置変更、レイヤー設計の見直しを検討しましょう。

9. C#初心者が循環参照でつまずきやすいポイント

9-1. とりあえず参照を追加してしまう

初心者が循環参照でつまずきやすい原因の一つが、「とりあえず参照を追加してしまう」ことです。

Visual Studioで別プロジェクトのクラスを使いたいとき、参照を追加すれば簡単に使えるようになります。しかし、参照を追加するたびにプロジェクト間の依存関係は増えていきます。

最初は問題なくても、後から逆方向の参照が必要になり、循環依存になることがあります。

参照を追加する前に、「このプロジェクトが本当に相手のプロジェクトを知るべきか」を考えましょう。共通の型が必要なだけなら、共通プロジェクトに切り出すほうがよい場合があります。

9-2. クラス同士を直接呼び合ってしまう

クラス同士を直接呼び合うことも、循環参照の原因になります。

たとえば、UserServiceからOrderServiceを呼び出し、OrderServiceからUserServiceを呼び出すような設計です。

このような構造は、最初は自然に見えるかもしれません。しかし、サービス同士が互いの処理に依存すると、変更に弱いコードになります。

直接呼び合うのではなく、必要な処理を第三のサービスに分けたり、必要な機能だけをインターフェースとして切り出したりすることを検討しましょう。

特にDIを使っている場合、コンストラクタで互いのサービスを受け取っていないか確認することが大切です。

9-3. EntityをそのままAPIレスポンスに返してしまう

ASP.NET CoreとEntity Frameworkを使っている初心者がよくやりがちなのが、EntityをそのままAPIレスポンスに返すことです。

C#
[HttpGet("{id}")]
public User Get(int id)
{
return _dbContext.Users
.Include(user => user.Orders)
.First(user => user.Id == id);
}

このようなコードは簡単に書けますが、UserとOrderが双方向に関連している場合、JSONシリアライズ時に循環参照が発生することがあります。

APIでは、Entityをそのまま返すのではなく、DTOに変換して返すのがおすすめです。

C#
[HttpGet("{id}")]
public UserDto Get(int id)
{
return _dbContext.Users
.Where(user => user.Id == id)
.Select(user => new UserDto
{
Id = user.Id,
Name = user.Name,
Orders = user.Orders.Select(order => new OrderDto
{
Id = order.Id,
Amount = order.Amount
}).ToList()
})
.First();
}

DTOを使うことで、必要なデータだけを安全に返せます。

9-4. エラー文の意味がわからず原因を見逃す

循環参照のエラーは、初心者にとって分かりにくいことがあります。

たとえば、DIのエラーでは、サービスを構築できない、循環依存が検出された、という内容のメッセージが表示されます。JSONのエラーでは、オブジェクトの循環が検出された、または最大深度を超えた、という内容が表示されることがあります。

エラー文を見たときは、次の点を確認しましょう。

まず、どのクラス名が出ているかを確認します。次に、そのクラスのコンストラクタを見ます。さらに、そこから呼ばれているサービスやプロパティを順番にたどります。

循環参照のエラーでは、エラーメッセージの中に依存関係の流れが表示されることがあります。たとえば、AService、BService、AServiceのように同じクラスが再び出てきたら、循環している可能性が高いです。

9-5. 設計変更を避けて一時的な回避策に頼ってしまう

循環参照が発生したとき、初心者は一時的な回避策に頼ってしまうことがあります。

たとえば、JSONのエラーが出たからIgnoreCyclesを設定する、DIのエラーが出たからLazyを使う、プロパティをnullにしてごまかす、といった対応です。

もちろん、これらの方法が有効な場面もあります。しかし、根本的な原因が設計にある場合、一時的な回避策だけでは問題が残ります。

循環参照が発生したら、「なぜこのクラス同士が互いに依存しているのか」「なぜこのプロジェクト同士が互いに参照しているのか」を考えることが大切です。

設計を少し見直すだけで、コード全体が分かりやすくなることがあります。

10. C#の循環参照に関するよくある質問

10-1. 循環参照は必ずエラーになりますか?

循環参照は必ずエラーになるわけではありません。

たとえば、クラスAがクラスBをプロパティとして持ち、クラスBもクラスAをプロパティとして持つだけなら、C#のコードとしてはビルドできる場合があります。

C#
public class A
{
public B? B { get; set; }
}

public class B
{
public A? A { get; set; }
}

ただし、ビルドできるから問題ないとは限りません。JSONに変換するときにエラーになったり、設計が複雑になったり、保守性が下がったりする可能性があります。

一方、プロジェクト参照の循環依存やDIの循環依存は、ビルド時や実行時にエラーになることが多いです。

10-2. 相互にプロパティを持つだけでも循環参照ですか?

相互にプロパティを持つだけでも、広い意味では循環参照と呼ばれることがあります。

たとえば、UserがOrdersを持ち、OrderがUserを持つ場合です。

C#
public class User
{
public List<Order> Orders { get; set; } = new();
}

public class Order
{
public User User { get; set; } = null!;
}

ただし、このような構造が常に悪いわけではありません。Entity Frameworkでは、双方向のナビゲーションプロパティとして自然に使われることがあります。

問題になるのは、その構造をそのままJSONに変換したり、ビジネスロジックが互いに強く依存したりする場合です。

10-3. インターフェースを使えば必ず解決できますか?

インターフェースを使えば必ず循環参照が解決できるわけではありません。

たとえば、AServiceがIBServiceに依存し、BServiceがIAServiceに依存している場合、型はインターフェースになっていても依存関係は循環しています。

AService → IBService
BService → IAService

この場合、DIコンテナでは依然として循環依存が発生する可能性があります。

インターフェースは、依存を弱めたり、実装を差し替えやすくしたりするために有効です。しかし、依存の向きそのものが間違っている場合は、責務の分離や設計の見直しが必要です。

10-4. JSONの循環参照はIgnoreCyclesで解決してよいですか?

ReferenceHandler.IgnoreCyclesを使うことで、JSONシリアライズ時の循環参照エラーを回避できる場合があります。

ただし、常におすすめできるわけではありません。IgnoreCyclesは循環している参照を無視するため、出力されるJSONから一部のデータが省略される可能性があります。

簡単な管理画面や内部ツールなどでは有効な場合もありますが、外部公開するAPIではDTOを使ってレスポンスの形を明確にするほうが安全です。

APIの利用者にとって必要なデータだけをDTOで返すようにすれば、循環参照を避けられるだけでなく、レスポンスの構造も分かりやすくなります。

10-5. 循環参照を見つけるにはどうすればよいですか?

循環参照を見つけるには、依存関係を順番にたどることが大切です。

DIの循環依存であれば、エラーメッセージに出ているサービス名を確認し、それぞれのコンストラクタを見ます。同じサービス名が依存関係の中で再び出てきたら、循環している可能性があります。

プロジェクト参照の循環依存であれば、各プロジェクトの参照設定を確認します。Visual Studioの依存関係やプロジェクト参照を見て、双方向になっている箇所がないか確認しましょう。

JSONやEntity Frameworkの循環参照であれば、エンティティのナビゲーションプロパティを確認します。親から子、子から親へ戻る参照がある場合、JSON変換で循環する可能性があります。

紙やテキストで次のように書き出すだけでも、原因を見つけやすくなります。

UserService → OrderService → UserService

このように、同じクラスやプロジェクトに戻ってくる流れがあれば、それが循環参照です。

まとめ

C#の循環参照とは、クラス、プロジェクト、サービス、オブジェクトなどが互いに参照し合い、依存関係がループしている状態のことです。

循環参照は、プロジェクト参照ではビルドエラー、DIでは循環依存エラー、JSONシリアライズでは循環参照エラー、コード上ではStackOverflowExceptionや保守性低下の原因になることがあります。

特に初心者は、便利だからといってクラス同士を直接呼び合ったり、プロジェクト参照を安易に追加したり、Entity FrameworkのエンティティをそのままAPIレスポンスに返したりしがちです。これらは循環参照の原因になりやすいため注意が必要です。

循環参照を防ぐには、依存関係を一方向にすること、クラスやプロジェクトの責務を明確にすること、共通処理を適切な場所に分離すること、インターフェースやDTOを活用することが大切です。

エラーが出たときだけ対処するのではなく、設計の段階で「このクラスは何を担当するのか」「このプロジェクトはどこを参照してよいのか」「APIではどのデータを返すべきか」を考えることで、C#の循環参照を防ぎやすくなります。