C#のinterfaceでasyncを使う方法|戻り値Task・実装例・設計時の注意点まで解説
はじめに
C#で非同期処理を設計していると、「interfaceにasyncメソッドは定義できるのか?」という疑問がよく出てきます。
結論からいうと、C#のinterfaceにはasync修飾子そのものを契約として書くのではなく、TaskやTask<T>などの戻り値で非同期処理を表現します。
たとえば、次のようなinterfaceを定義します。
C#public interface IUserService
{
Task<User> GetUserAsync(int userId);
}
このように、interface側では「このメソッドは非同期的に結果を返す」という契約だけを定義します。実際にasyncやawaitを使うかどうかは、実装クラス側で決めます。
この記事では、C#のinterfaceでasyncを扱う基本ルールから、Task・Task<T>・ValueTask・IAsyncEnumerable<T>の使い分け、実装例、設計時の注意点、よくあるエラーまで詳しく解説します。
1. C#のinterfaceでasyncは使える?基本ルールを理解する
C#のinterfaceでasyncを扱うときに重要なのは、interfaceは処理内容ではなく契約を定義するものだという点です。
asyncは「このメソッドの内部でawaitを使えるようにするための実装修飾子」です。一方、interfaceは「どのようなメソッドを持つべきか」を定義するためのものです。
そのため、通常のinterface定義では、asyncではなく戻り値の型によって非同期メソッドであることを表します。
1-1. interfaceにasync修飾子は書けない
通常、interfaceの抽象メソッド宣言にはasync修飾子を書けません。
次のようなコードは不適切です。
C#public interface IUserService
{
async Task<User> GetUserAsync(int userId);
}
asyncはメソッド本体の中でawaitを使うためのキーワードです。しかし、interfaceのメソッド宣言には通常メソッド本体がありません。
そのため、interfaceでは次のように書きます。
C#public interface IUserService
{
Task<User> GetUserAsync(int userId);
}
ここで重要なのは、asyncであることをinterfaceに書くのではなく、Taskを返すことで非同期処理の契約を表すという点です。
1-2. interfaceでは「非同期処理を表す戻り値」を定義する
interfaceでは、メソッドが非同期処理を行うことを戻り値で表現します。
代表的な戻り値は次のとおりです。
C#Task
Task<T>
ValueTask
ValueTask<T>
IAsyncEnumerable<T>
たとえば、戻り値がない非同期処理ならTaskを使います。
C#public interface IEmailService
{
Task SendEmailAsync(string to, string subject, string body);
}
結果を返す非同期処理ならTask<T>を使います。
C#public interface IUserRepository
{
Task<User?> FindByIdAsync(int id);
}
このように、interfaceでは「非同期に完了する処理」「非同期に値を返す処理」を型で表現します。
1-3. asyncは実装側メソッドに付ける
async修飾子を付けるのは、interfaceではなく実装クラス側です。
C#public class UserService : IUserService
{
public async Task<User> GetUserAsync(int userId)
{
await Task.Delay(100);
return new User
{
Id = userId,
Name = "Taro"
};
}
}
このように、interfaceではTask<User>を返すことだけを定義し、実装側でasyncとawaitを使います。
ただし、実装側でも必ずasyncを付けなければならないわけではありません。すでにTaskを返すメソッドをそのまま返せる場合は、asyncを省略できます。
C#public class UserService : IUserService
{
private readonly HttpClient _httpClient;
public UserService(HttpClient httpClient)
{
_httpClient = httpClient;
}
public Task<User> GetUserAsync(int userId)
{
return _httpClient.GetFromJsonAsync<User>($"users/{userId}")!;
}
}
この実装も、interfaceの契約を満たしています。
1-4. 戻り値はTask・Task<T>・ValueTaskを使う
C#のasync interfaceでは、基本的に次の戻り値を使います。
C#Task
Task<T>
ValueTask
ValueTask<T>
Taskは、値を返さない非同期処理で使います。
C#Task SaveAsync(User user);
Task<T>は、値を返す非同期処理で使います。
C#Task<User?> GetByIdAsync(int id);
ValueTaskやValueTask<T>は、同期的に完了する可能性が高い処理でパフォーマンスを意識する場合に使います。
C#ValueTask<User?> GetCachedUserAsync(int id);
ただし、多くのアプリケーションでは、まずTaskまたはTask<T>を使う設計で十分です。ValueTaskは扱いに注意が必要なため、明確な理由がある場合に限定して使うのが安全です。
2. interfaceでasyncメソッドを定義する基本構文
C#のinterfaceでasyncメソッドを定義するときは、戻り値にTaskやTask<T>を指定します。
ここでは、戻り値ごとの基本構文を見ていきます。
2-1. Taskを返すinterfaceメソッドの書き方
処理の完了だけを待ちたい場合は、Taskを返します。
C#public interface INotificationService
{
Task SendAsync(string message);
}
このinterfaceは、「通知を送信する非同期処理」を表します。戻り値として具体的な値は返しませんが、呼び出し側はawaitできます。
C#await notificationService.SendAsync("登録が完了しました。");
実装例は次のとおりです。
C#public class EmailNotificationService : INotificationService
{
public async Task SendAsync(string message)
{
await Task.Delay(100);
Console.WriteLine($"メール通知: {message}");
}
}
Taskを返すことで、呼び出し側は処理完了を待てます。また、例外が発生した場合もawaitした箇所で捕捉できます。
2-2. Task<T>を返すinterfaceメソッドの書き方
非同期処理の結果として値を返す場合は、Task<T>を使います。
C#public interface IUserService
{
Task<User?> GetUserAsync(int userId);
}
この場合、GetUserAsyncは非同期的にUser?を返します。
実装例は次のとおりです。
C#public class UserService : IUserService
{
public async Task<User?> GetUserAsync(int userId)
{
await Task.Delay(100);
if (userId <= 0)
{
return null;
}
return new User
{
Id = userId,
Name = "Taro"
};
}
}
呼び出し側では次のようにawaitして結果を受け取ります。
C#User? user = await userService.GetUserAsync(1);
if (user is not null)
{
Console.WriteLine(user.Name);
}
API通信、データベース検索、ファイル読み込みなど、結果を返す非同期処理ではTask<T>がよく使われます。
2-3. ValueTaskを使う場合の書き方
ValueTaskは、処理が同期的に完了する可能性が高い場合に使える戻り値です。
C#public interface ICacheService
{
ValueTask<string?> GetAsync(string key);
}
たとえば、キャッシュに値があれば即座に返し、なければ非同期で取得するようなケースです。
C#public class CacheService : ICacheService
{
private readonly Dictionary<string, string> _cache = new();
public ValueTask<string?> GetAsync(string key)
{
if (_cache.TryGetValue(key, out var value))
{
return ValueTask.FromResult<string?>(value);
}
return new ValueTask<string?>(
LoadFromExternalSourceAsync(key)
);
}
private async Task<string?> LoadFromExternalSourceAsync(string key)
{
await Task.Delay(100);
return null;
}
}
ValueTaskはTaskよりも割り当てを減らせる場合がありますが、扱いには注意が必要です。たとえば、同じValueTaskを複数回awaitするような使い方は避けるべきです。
通常の業務アプリケーションでは、まずTaskやTask<T>を使い、パフォーマンス上の理由が明確な場合だけValueTaskを検討するとよいでしょう。
2-4. voidを返すasyncメソッドをinterfaceに定義してはいけない理由
C#ではasync voidメソッドを作ることもできますが、interfaceの非同期メソッドとしてvoidを返す設計は避けるべきです。
悪い例は次のとおりです。
C#public interface ILogService
{
void WriteAsync(string message);
}
この定義では、名前はAsyncでも戻り値がvoidのため、呼び出し側は処理完了を待てません。
C#logService.WriteAsync("ログを書き込みます");
この場合、呼び出し側はawaitできず、例外処理も難しくなります。
正しくはTaskを返します。
C#public interface ILogService
{
Task WriteAsync(string message);
}
実装側も次のようにします。
C#public class LogService : ILogService
{
public async Task WriteAsync(string message)
{
await File.AppendAllTextAsync("app.log", message);
}
}
async voidはイベントハンドラーなど一部の用途を除き、通常の非同期処理では使わないのが基本です。
3. C#のinterfaceでasyncを実装するサンプルコード
ここからは、C#のinterfaceでasyncを使う具体的な実装例を見ていきます。
シンプルなサービスから、DI、リポジトリパターンまで確認しましょう。
3-1. interface側の定義例
まず、ユーザー情報を取得するinterfaceを定義します。
C#public interface IUserService
{
Task<User?> GetByIdAsync(int id);
Task<IEnumerable<User>> GetAllAsync();
Task CreateAsync(User user);
}
このinterfaceでは、すべてのメソッドが非同期処理を表す戻り値を持っています。
C#public class User
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
}
GetByIdAsyncはユーザーを1件取得し、GetAllAsyncは複数件を取得し、CreateAsyncはユーザーを作成します。
値を返すものはTask<T>、値を返さないものはTaskを使っています。
3-2. 実装クラスでasync/awaitを使う例
次に、interfaceを実装するクラスを作ります。
C#public class UserService : IUserService
{
private readonly List<User> _users = new()
{
new User { Id = 1, Name = "Taro", Email = "taro@example.com" },
new User { Id = 2, Name = "Hanako", Email = "hanako@example.com" }
};
public async Task<User?> GetByIdAsync(int id)
{
await Task.Delay(100);
return _users.FirstOrDefault(user => user.Id == id);
}
public async Task<IEnumerable<User>> GetAllAsync()
{
await Task.Delay(100);
return _users;
}
public async Task CreateAsync(User user)
{
await Task.Delay(100);
_users.Add(user);
}
}
ここではサンプルとしてTask.Delayを使っていますが、実務ではデータベースアクセス、HTTP通信、ファイル操作などが入ることが多いです。
重要なのは、interface側ではTaskやTask<T>を返す契約を定義し、実装側でasyncとawaitを使っている点です。
3-3. 呼び出し側でawaitする例
呼び出し側では、interface型を通してメソッドを呼び出し、awaitします。
C#public class UserController
{
private readonly IUserService _userService;
public UserController(IUserService userService)
{
_userService = userService;
}
public async Task ShowUserAsync(int id)
{
User? user = await _userService.GetByIdAsync(id);
if (user is null)
{
Console.WriteLine("ユーザーが見つかりませんでした。");
return;
}
Console.WriteLine($"ユーザー名: {user.Name}");
}
}
呼び出し側は、実装クラスがどのように非同期処理を行っているかを知る必要がありません。
IUserServiceというinterfaceを通じて、「ユーザーを非同期に取得できる」という契約だけに依存します。
これにより、実装の差し替えやテストがしやすくなります。
3-4. DI・サービス層で使う実装例
ASP.NET Coreなどでは、interfaceとasyncはDIと組み合わせてよく使われます。
C#public interface IOrderService
{
Task<Order?> GetOrderAsync(int orderId);
Task PlaceOrderAsync(Order order);
}
実装クラスは次のようになります。
C#public class OrderService : IOrderService
{
private readonly IOrderRepository _orderRepository;
public OrderService(IOrderRepository orderRepository)
{
_orderRepository = orderRepository;
}
public async Task<Order?> GetOrderAsync(int orderId)
{
return await _orderRepository.FindByIdAsync(orderId);
}
public async Task PlaceOrderAsync(Order order)
{
order.CreatedAt = DateTime.UtcNow;
await _orderRepository.AddAsync(order);
}
}
DIコンテナには次のように登録します。
C#builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
ControllerやMinimal API側では、IOrderServiceに依存します。
C#app.MapGet("/orders/{id}", async (int id, IOrderService orderService) =>
{
var order = await orderService.GetOrderAsync(id);
return order is null
? Results.NotFound()
: Results.Ok(order);
});
このように、async interfaceはサービス層の抽象化に非常に向いています。
3-5. リポジトリパターンで使う実装例
データベースアクセスを抽象化するリポジトリパターンでも、async interfaceはよく使われます。
C#public interface IUserRepository
{
Task<User?> FindByIdAsync(int id);
Task<List<User>> FindAllAsync();
Task AddAsync(User user);
Task SaveChangesAsync();
}
Entity Framework Coreを使う場合の実装例は次のとおりです。
C#public class UserRepository : IUserRepository
{
private readonly AppDbContext _context;
public UserRepository(AppDbContext context)
{
_context = context;
}
public async Task<User?> FindByIdAsync(int id)
{
return await _context.Users.FindAsync(id);
}
public async Task<List<User>> FindAllAsync()
{
return await _context.Users.ToListAsync();
}
public async Task AddAsync(User user)
{
await _context.Users.AddAsync(user);
}
public async Task SaveChangesAsync()
{
await _context.SaveChangesAsync();
}
}
リポジトリをinterfaceにしておくことで、ユニットテスト時にモックへ差し替えやすくなります。
C#public class UserApplicationService
{
private readonly IUserRepository _userRepository;
public UserApplicationService(IUserRepository userRepository)
{
_userRepository = userRepository;
}
public async Task RegisterAsync(User user)
{
await _userRepository.AddAsync(user);
await _userRepository.SaveChangesAsync();
}
}
データベースアクセスはI/O処理であるため、async interfaceとの相性がよい代表例です。
4. 戻り値別に見るasync interfaceの使い分け
async interfaceを設計するときは、戻り値の選び方が重要です。
Task、Task<T>、ValueTask、IAsyncEnumerable<T>にはそれぞれ向いている用途があります。
4-1. Taskを使うケース
Taskは、非同期処理の完了だけを表したい場合に使います。
代表的な例は次のとおりです。
C#public interface IFileWriter
{
Task WriteAsync(string path, string content);
}
このメソッドはファイルに内容を書き込みますが、結果として値は返しません。
ほかにも、次のような処理に向いています。
C#Task SendEmailAsync(string to, string body);
Task DeleteAsync(int id);
Task SaveChangesAsync();
Task UploadAsync(Stream stream);
Taskを返すことで、呼び出し側は処理完了を待つことができます。
C#await fileWriter.WriteAsync("sample.txt", "Hello");
また、例外が発生した場合はawaitした箇所で捕捉できます。
C#try
{
await fileWriter.WriteAsync("sample.txt", "Hello");
}
catch (IOException ex)
{
Console.WriteLine(ex.Message);
}
値を返す必要がない非同期処理では、基本的にTaskを使うと覚えておくとよいでしょう。
4-2. Task<T>を使うケース
Task<T>は、非同期処理の結果として値を返したい場合に使います。
C#public interface IUserQueryService
{
Task<UserDto?> GetUserAsync(int id);
}
戻り値のTask<UserDto?>は、「非同期処理が完了したらUserDto?を返す」という意味です。
ほかにも、次のようなメソッドで使います。
C#Task<string> ReadTextAsync(string path);
Task<Product?> FindProductAsync(int id);
Task<IReadOnlyList<Order>> GetOrdersAsync(int userId);
Task<bool> ExistsAsync(string email);
呼び出し側では、awaitによって中身の型を取得できます。
C#UserDto? user = await userQueryService.GetUserAsync(1);
非同期で何らかの値を取得する場合は、Task<T>を使うのが基本です。
4-3. ValueTaskを使うケース
ValueTaskまたはValueTask<T>は、同期的に完了することが多い処理で使うことがあります。
たとえば、キャッシュから値を取得するinterfaceです。
C#public interface IUserCache
{
ValueTask<User?> GetAsync(int id);
}
キャッシュに存在すれば即座に返し、存在しなければ外部ストレージから非同期で取得するようなケースで使えます。
C#public class UserCache : IUserCache
{
private readonly Dictionary<int, User> _cache = new();
public ValueTask<User?> GetAsync(int id)
{
if (_cache.TryGetValue(id, out var user))
{
return ValueTask.FromResult<User?>(user);
}
return new ValueTask<User?>(LoadAsync(id));
}
private async Task<User?> LoadAsync(int id)
{
await Task.Delay(100);
return null;
}
}
ただし、ValueTaskは常にTaskより優れているわけではありません。
ValueTaskは使い方を誤るとコードが複雑になりやすく、メリットが小さい場面も多いです。特に、通常のWebアプリケーションや業務システムではTask<T>で十分なことがほとんどです。
ValueTaskを使うのは、パフォーマンス上の理由が明確で、同期完了する頻度が高い場合に限定するとよいでしょう。
4-4. IAsyncEnumerable<T>を使うケース
複数のデータを非同期に順次取得したい場合は、IAsyncEnumerable<T>を使います。
C#public interface IUserStreamService
{
IAsyncEnumerable<User> GetUsersAsync();
}
これは、すべてのデータを一度にList<T>として返すのではなく、非同期ストリームとして順番に返す設計です。
実装例は次のとおりです。
C#public class UserStreamService : IUserStreamService
{
public async IAsyncEnumerable<User> GetUsersAsync()
{
for (int i = 1; i <= 5; i++)
{
await Task.Delay(100);
yield return new User
{
Id = i,
Name = $"User {i}"
};
}
}
}
呼び出し側ではawait foreachを使います。
C#await foreach (var user in userStreamService.GetUsersAsync())
{
Console.WriteLine(user.Name);
}
IAsyncEnumerable<T>は、大量データの読み込み、ページング、ストリーミングAPI、ログの逐次取得などに向いています。
すべてのデータをまとめて返す必要がない場合は、Task<List<T>>ではなくIAsyncEnumerable<T>を検討できます。
4-5. 戻り値の選び方の判断基準
async interfaceの戻り値は、次の基準で選ぶとわかりやすくなります。
値を返さず、完了だけを待つならTaskを使います。
C#Task SaveAsync();
値を1つ返すならTask<T>を使います。
C#Task<User?> GetUserAsync(int id);
同期的に完了する可能性が高く、パフォーマンス上の理由があるならValueTask<T>を検討します。
C#ValueTask<User?> GetCachedUserAsync(int id);
複数の値を非同期に順次返すならIAsyncEnumerable<T>を使います。
C#IAsyncEnumerable<User> GetUsersAsync();
基本方針としては、まずTaskまたはTask<T>を選ぶのが安全です。特殊な理由がある場合だけ、ValueTaskやIAsyncEnumerable<T>を検討するとよいでしょう。
5. interfaceのasync実装でよくあるエラーと解決方法
C#のinterfaceでasyncを使うときには、いくつかのよくあるミスがあります。
ここでは、代表的なエラーと解決方法を解説します。
5-1. interfaceメソッドにasyncを付けてコンパイルエラーになる
よくあるミスのひとつが、interfaceのメソッド宣言にasyncを付けてしまうことです。
C#public interface IUserService
{
async Task<User> GetUserAsync(int id);
}
通常のinterfaceメソッド宣言では、メソッド本体がありません。そのため、asyncを付ける意味がありません。
正しくは次のように書きます。
C#public interface IUserService
{
Task<User> GetUserAsync(int id);
}
実装クラス側でasyncを付けます。
C#public class UserService : IUserService
{
public async Task<User> GetUserAsync(int id)
{
await Task.Delay(100);
return new User
{
Id = id,
Name = "Taro"
};
}
}
interfaceは「Taskを返す」という契約だけを表し、実装側が非同期処理の中身を担当します。
5-2. 実装クラスの戻り値がinterface定義と一致しない
interfaceで定義した戻り値と、実装クラスの戻り値が一致しないとコンパイルエラーになります。
たとえば、interfaceではTask<User>を返すように定義しているとします。
C#public interface IUserService
{
Task<User> GetUserAsync(int id);
}
しかし、実装側でUserを直接返すとエラーになります。
C#public class UserService : IUserService
{
public User GetUserAsync(int id)
{
return new User
{
Id = id,
Name = "Taro"
};
}
}
正しくは、Task<User>を返します。
C#public class UserService : IUserService
{
public Task<User> GetUserAsync(int id)
{
var user = new User
{
Id = id,
Name = "Taro"
};
return Task.FromResult(user);
}
}
または、asyncを使って次のように書きます。
C#public class UserService : IUserService
{
public async Task<User> GetUserAsync(int id)
{
await Task.Delay(100);
return new User
{
Id = id,
Name = "Taro"
};
}
}
interfaceの戻り値と実装クラスの戻り値は必ず一致させましょう。
5-3. awaitできない戻り値を定義してしまう
メソッド名にAsyncを付けているのに、戻り値がTaskではないケースもよくあります。
C#public interface IUserService
{
User GetUserAsync(int id);
}
この定義では、呼び出し側はawaitできません。
C#User user = await userService.GetUserAsync(1);
これはエラーになります。awaitできるのは、基本的にTask、Task<T>、ValueTask、ValueTask<T>などのawait可能な型です。
正しくは次のようにします。
C#public interface IUserService
{
Task<User> GetUserAsync(int id);
}
非同期メソッドとして扱いたいなら、メソッド名だけでなく戻り値も非同期処理を表す型にする必要があります。
5-4. async voidを使って例外処理が難しくなる
async voidは、呼び出し側でawaitできないため、例外処理が難しくなります。
悪い例は次のとおりです。
C#public interface IReportService
{
void GenerateAsync();
}
実装側で次のようにしてしまうと問題が起きやすくなります。
C#public class ReportService : IReportService
{
public async void GenerateAsync()
{
await Task.Delay(100);
throw new InvalidOperationException("レポート生成に失敗しました。");
}
}
呼び出し側ではawaitできないため、次のような例外処理ができません。
C#try
{
await reportService.GenerateAsync();
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
正しくはTaskを返します。
C#public interface IReportService
{
Task GenerateAsync();
}
実装側も次のようにします。
C#public class ReportService : IReportService
{
public async Task GenerateAsync()
{
await Task.Delay(100);
throw new InvalidOperationException("レポート生成に失敗しました。");
}
}
呼び出し側では、awaitとtry-catchで例外を扱えます。
C#try
{
await reportService.GenerateAsync();
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
通常の非同期処理では、async voidではなくasync Taskを使いましょう。
5-5. 同期メソッドを無理にTaskで包んでしまう
非同期処理ではないのに、すべてのメソッドを無理にTaskで包む設計にも注意が必要です。
たとえば、単純なメモリ上の計算しか行わない処理を無理に非同期化する必要はありません。
C#public interface ICalculator
{
Task<int> AddAsync(int x, int y);
}
実装は次のようになります。
C#public class Calculator : ICalculator
{
public Task<int> AddAsync(int x, int y)
{
return Task.FromResult(x + y);
}
}
このような処理はI/O待ちがなく、単純に計算しているだけなので、非同期化するメリットはほとんどありません。
C#public interface ICalculator
{
int Add(int x, int y);
}
一方で、interfaceの実装が将来的にデータベースや外部APIへアクセスする可能性がある場合は、最初からTask<T>を返す設計にすることもあります。
大切なのは、非同期化する理由を明確にすることです。ファイル、ネットワーク、データベース、外部サービスなどI/O待ちが発生する境界ではasync interfaceが有効です。
6. async interface設計時の注意点
async interfaceは便利ですが、設計を誤ると読みづらく、テストしにくいコードになります。
ここでは、実務で意識したい設計上の注意点を解説します。
6-1. 非同期であることをメソッド名にAsyncで示す
C#では、非同期メソッドの名前にAsyncを付けるのが一般的です。
C#public interface IUserService
{
Task<User?> GetUserAsync(int id);
Task CreateUserAsync(User user);
}
Asyncを付けることで、呼び出し側はそのメソッドが非同期処理であることをすぐに理解できます。
悪い例は次のとおりです。
C#public interface IUserService
{
Task<User?> GetUser(int id);
}
戻り値を見れば非同期だとわかりますが、メソッド名から判断しにくくなります。
可読性を高めるためにも、TaskやTask<T>を返すメソッドにはAsyncサフィックスを付けるとよいでしょう。
C#Task SaveAsync();
Task DeleteAsync(int id);
Task<Order?> GetOrderAsync(int id);
ただし、すでにフレームワークやライブラリの命名規則がある場合は、それに合わせることも大切です。
6-2. CancellationTokenを引数に含める
時間がかかる非同期処理では、CancellationTokenを引数に含める設計が有効です。
C#public interface IUserService
{
Task<User?> GetUserAsync(
int id,
CancellationToken cancellationToken = default);
}
実装側では、キャンセル要求を非同期メソッドに渡します。
C#public class UserService : IUserService
{
private readonly HttpClient _httpClient;
public UserService(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<User?> GetUserAsync(
int id,
CancellationToken cancellationToken = default)
{
return await _httpClient.GetFromJsonAsync<User>(
$"users/{id}",
cancellationToken);
}
}
呼び出し側では、処理をキャンセルできます。
C#using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
User? user = await userService.GetUserAsync(1, cts.Token);
Web API、データベース、ファイル処理、外部サービス連携など、処理時間が読みにくいものにはCancellationTokenを用意しておくと安全です。
6-3. 例外処理の方針を決める
async interfaceを設計するときは、例外をどのように扱うかも重要です。
たとえば、ユーザーが見つからない場合にnullを返すのか、例外を投げるのかを決めておく必要があります。
C#public interface IUserService
{
Task<User?> FindUserAsync(int id);
Task<User> GetUserAsync(int id);
}
このように、メソッド名で方針を分けることもできます。
C#public async Task<User?> FindUserAsync(int id)
{
return await _repository.FindByIdAsync(id);
}
public async Task<User> GetUserAsync(int id)
{
var user = await _repository.FindByIdAsync(id);
if (user is null)
{
throw new InvalidOperationException("ユーザーが見つかりません。");
}
return user;
}
非同期メソッドで発生した例外は、awaitしたタイミングで呼び出し側に伝わります。
C#try
{
var user = await userService.GetUserAsync(1);
}
catch (InvalidOperationException ex)
{
Console.WriteLine(ex.Message);
}
interface設計時には、例外を投げる条件、戻り値で表現する条件、ログ出力の責務などを整理しておきましょう。
6-4. ConfigureAwaitの扱いを整理する
ライブラリや共通部品を作る場合、ConfigureAwait(false)を使うかどうかをチームで統一しておくとよいでしょう。
C#public async Task<string> ReadAsync(string path)
{
return await File.ReadAllTextAsync(path)
.ConfigureAwait(false);
}
ConfigureAwait(false)は、await後に元のコンテキストへ戻る必要がない場合に使われます。
ただし、ASP.NET Coreでは同期コンテキストの扱いが従来のUIアプリとは異なるため、常に必要というわけではありません。
重要なのは、interfaceにConfigureAwaitを書くのではなく、実装側の方針として扱うことです。
C#public interface IFileReader
{
Task<string> ReadAsync(string path);
}
interfaceはあくまで契約を定義し、ConfigureAwaitの有無は実装側の内部事情として整理します。
6-5. 同期版メソッドと非同期版メソッドを併用するか判断する
場合によっては、同期版と非同期版の両方を用意するか検討することがあります。
C#public interface IUserRepository
{
User? FindById(int id);
Task<User?> FindByIdAsync(int id);
}
ただし、安易に同期版と非同期版を両方用意すると、実装が複雑になります。
特に、データベースや外部APIアクセスのように本質的に非同期が向いている処理では、非同期版だけにする方がシンプルです。
C#public interface IUserRepository
{
Task<User?> FindByIdAsync(int id);
}
一方で、メモリ上の処理や計算処理など、同期実行が自然な場合は同期メソッドで十分です。
C#public interface IPriceCalculator
{
decimal Calculate(Order order);
}
非同期版を用意するかどうかは、「I/O待ちがあるか」「呼び出し側がawaitしたいか」「実装差し替え時に非同期処理が必要になるか」で判断しましょう。
6-6. テストしやすいinterface設計にする
async interfaceは、ユニットテストやモックとの相性がよい設計にできます。
たとえば、次のようなinterfaceがあるとします。
C#public interface IUserRepository
{
Task<User?> FindByIdAsync(int id);
}
テスト用の実装を簡単に作れます。
C#public class FakeUserRepository : IUserRepository
{
public Task<User?> FindByIdAsync(int id)
{
User? user = id == 1
? new User { Id = 1, Name = "Test User" }
: null;
return Task.FromResult(user);
}
}
テストコードでは、実際のデータベースにアクセスせずに動作確認できます。
C#[Fact]
public async Task FindByIdAsync_ReturnsUser_WhenUserExists()
{
IUserRepository repository = new FakeUserRepository();
User? user = await repository.FindByIdAsync(1);
Assert.NotNull(user);
Assert.Equal("Test User", user.Name);
}
interfaceのメソッドが大きすぎたり、責務が多すぎたりするとテストが難しくなります。
テストしやすいasync interfaceにするには、責務を小さくし、戻り値と例外の方針を明確にすることが大切です。
7. async interfaceとdefault interface methodsの関係
C# 8.0以降では、interfaceにデフォルト実装を書けるようになりました。
これにより、interface内にメソッド本体を持たせることができます。ただし、通常のinterface設計とは考え方が異なるため、使いどころには注意が必要です。
7-1. interfaceにデフォルト実装を書く場合の考え方
通常のinterfaceは、次のようにメソッドの契約だけを定義します。
C#public interface IUserService
{
Task<User?> GetUserAsync(int id);
}
一方、default interface methodsを使うと、interfaceに実装を持たせられます。
C#public interface IUserService
{
Task<User?> GetUserAsync(int id);
Task<bool> ExistsAsync(int id)
{
return CheckExistsAsync(id);
}
private async Task<bool> CheckExistsAsync(int id)
{
var user = await GetUserAsync(id);
return user is not null;
}
}
このように、既存のinterfaceに新しいメソッドを追加したい場合などに、デフォルト実装が役立つことがあります。
ただし、interfaceに処理を持たせすぎると責務があいまいになります。基本的には、interfaceは契約を定義する場所として使うのがわかりやすい設計です。
7-2. デフォルト実装でTaskを返す例
default interface methodsを使って、Taskを返すデフォルト実装を書くこともできます。
C#public interface INotificationService
{
Task SendAsync(string message);
Task SendWelcomeMessageAsync(string userName)
{
return SendAsync($"{userName}さん、ようこそ!");
}
}
この例では、SendWelcomeMessageAsyncのデフォルト実装がSendAsyncを呼び出しています。
実装クラスは、最低限SendAsyncだけ実装すればよいです。
C#public class EmailNotificationService : INotificationService
{
public async Task SendAsync(string message)
{
await Task.Delay(100);
Console.WriteLine($"メール送信: {message}");
}
}
呼び出し側では、デフォルト実装のメソッドも利用できます。
C#INotificationService service = new EmailNotificationService();
await service.SendWelcomeMessageAsync("Taro");
デフォルト実装でも、非同期処理を表すにはTaskやTask<T>を返します。
7-3. デフォルト実装を使うべきケース
default interface methodsは、次のようなケースで役立ちます。
既存のinterfaceに新しいメソッドを追加したいが、すべての実装クラスをすぐに修正したくない場合です。
C#public interface IUserService
{
Task<User?> GetUserAsync(int id);
async Task<string> GetUserNameAsync(int id)
{
var user = await GetUserAsync(id);
return user?.Name ?? string.Empty;
}
}
このように、既存のGetUserAsyncを利用して新しいメソッドを提供できます。
また、複数の実装で共通する簡単な処理をinterface側に置くこともできます。
ただし、ビジネスロジックや外部依存を伴う処理をinterfaceに書きすぎるのは避けた方がよいです。
interfaceに実装が増えすぎると、抽象化の役割が弱くなり、テストや保守が難しくなることがあります。
7-4. 通常のinterface定義との使い分け
通常のinterface定義では、処理内容を持たずに契約だけを書きます。
C#public interface IOrderService
{
Task<Order?> GetOrderAsync(int id);
Task CreateOrderAsync(Order order);
}
一方、default interface methodsでは、interfaceに共通処理を持たせることができます。
C#public interface IOrderService
{
Task<Order?> GetOrderAsync(int id);
async Task<bool> ExistsAsync(int id)
{
var order = await GetOrderAsync(id);
return order is not null;
}
}
基本的な使い分けは次のように考えるとよいです。
契約だけを表したい場合は、通常のinterface定義を使います。
既存interfaceの互換性を保ちながらメソッドを追加したい場合や、非常に小さな共通処理を提供したい場合は、default interface methodsを検討します。
ただし、実務では通常のinterface定義だけで十分な場面が多いです。default interface methodsは便利ですが、使いすぎると設計が複雑になるため、必要な場面に絞って使いましょう。
8. async interfaceを使う実務的な設計例
ここでは、実務でよくあるasync interfaceの設計例を紹介します。
APIクライアント、データベースアクセス、ファイル操作、外部サービス連携、テストを前提にした設計を見ていきます。
8-1. APIクライアントのinterface設計
外部APIを呼び出す処理は、ネットワークI/Oが発生するため非同期処理に向いています。
C#public interface IWeatherApiClient
{
Task<WeatherResponse?> GetWeatherAsync(
string city,
CancellationToken cancellationToken = default);
}
実装例は次のとおりです。
C#public class WeatherApiClient : IWeatherApiClient
{
private readonly HttpClient _httpClient;
public WeatherApiClient(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<WeatherResponse?> GetWeatherAsync(
string city,
CancellationToken cancellationToken = default)
{
return await _httpClient.GetFromJsonAsync<WeatherResponse>(
$"weather?city={city}",
cancellationToken);
}
}
APIクライアントでは、CancellationTokenを引数に含めると、タイムアウトやリクエスト中断に対応しやすくなります。
また、APIのレスポンスが取得できない場合にnullを返すのか、例外を投げるのかを明確にしておくことも重要です。
C#public class WeatherResponse
{
public string City { get; set; } = string.Empty;
public double Temperature { get; set; }
}
外部API連携では、interfaceを切っておくことで、テスト時に本物のAPIを呼び出さずに済みます。
8-2. データベースアクセスのinterface設計
データベースアクセスは、async interfaceの代表的な利用シーンです。
C#public interface IProductRepository
{
Task<Product?> FindByIdAsync(
int id,
CancellationToken cancellationToken = default);
Task<List<Product>> FindAllAsync(
CancellationToken cancellationToken = default);
Task AddAsync(
Product product,
CancellationToken cancellationToken = default);
}
Entity Framework Coreを使った実装例です。
C#public class ProductRepository : IProductRepository
{
private readonly AppDbContext _context;
public ProductRepository(AppDbContext context)
{
_context = context;
}
public async Task<Product?> FindByIdAsync(
int id,
CancellationToken cancellationToken = default)
{
return await _context.Products
.FirstOrDefaultAsync(product => product.Id == id, cancellationToken);
}
public async Task<List<Product>> FindAllAsync(
CancellationToken cancellationToken = default)
{
return await _context.Products
.ToListAsync(cancellationToken);
}
public async Task AddAsync(
Product product,
CancellationToken cancellationToken = default)
{
await _context.Products.AddAsync(product, cancellationToken);
}
}
データベースアクセスでは、検索、登録、更新、削除の多くがI/O待ちを伴います。そのため、TaskやTask<T>を返すinterfaceにすることで、アプリケーション全体を非同期で扱いやすくなります。
8-3. ファイル操作のinterface設計
ファイル読み書きも非同期処理に向いています。
C#public interface IFileStorage
{
Task<string> ReadTextAsync(
string path,
CancellationToken cancellationToken = default);
Task WriteTextAsync(
string path,
string content,
CancellationToken cancellationToken = default);
}
実装例です。
C#public class LocalFileStorage : IFileStorage
{
public async Task<string> ReadTextAsync(
string path,
CancellationToken cancellationToken = default)
{
return await File.ReadAllTextAsync(path, cancellationToken);
}
public async Task WriteTextAsync(
string path,
string content,
CancellationToken cancellationToken = default)
{
await File.WriteAllTextAsync(path, content, cancellationToken);
}
}
interfaceを用意しておけば、将来的に保存先をローカルファイルからクラウドストレージへ変更する場合も、呼び出し側への影響を小さくできます。
C#public class CloudFileStorage : IFileStorage
{
public async Task<string> ReadTextAsync(
string path,
CancellationToken cancellationToken = default)
{
await Task.Delay(100, cancellationToken);
return "cloud file content";
}
public async Task WriteTextAsync(
string path,
string content,
CancellationToken cancellationToken = default)
{
await Task.Delay(100, cancellationToken);
}
}
保存先が変わっても、呼び出し側はIFileStorageに依存しているため、実装差し替えがしやすくなります。
8-4. 外部サービス連携のinterface設計
メール送信、決済、通知、メッセージキューなどの外部サービス連携でもasync interfaceは有効です。
C#public interface IPaymentGateway
{
Task<PaymentResult> ChargeAsync(
PaymentRequest request,
CancellationToken cancellationToken = default);
}
戻り値として、成功・失敗を表す専用の型を返すと扱いやすくなります。
C#public class PaymentRequest
{
public string CustomerId { get; set; } = string.Empty;
public decimal Amount { get; set; }
}
public class PaymentResult
{
public bool Succeeded { get; set; }
public string? ErrorMessage { get; set; }
}
実装例です。
C#public class PaymentGateway : IPaymentGateway
{
public async Task<PaymentResult> ChargeAsync(
PaymentRequest request,
CancellationToken cancellationToken = default)
{
await Task.Delay(300, cancellationToken);
if (request.Amount <= 0)
{
return new PaymentResult
{
Succeeded = false,
ErrorMessage = "金額が不正です。"
};
}
return new PaymentResult
{
Succeeded = true
};
}
}
外部サービスは失敗する可能性があります。ネットワークエラー、タイムアウト、認証エラー、入力不正などをどう扱うかをinterface設計時に考えておくことが大切です。
8-5. ユニットテスト・モックを前提にした設計
async interfaceを使うと、テスト時にモックやフェイク実装へ差し替えやすくなります。
たとえば、次のような注文サービスがあるとします。
C#public interface IOrderRepository
{
Task<Order?> FindByIdAsync(int id);
Task SaveAsync(Order order);
}
テスト用のフェイク実装を作れます。
C#public class FakeOrderRepository : IOrderRepository
{
private readonly Dictionary<int, Order> _orders = new();
public Task<Order?> FindByIdAsync(int id)
{
_orders.TryGetValue(id, out var order);
return Task.FromResult(order);
}
public Task SaveAsync(Order order)
{
_orders[order.Id] = order;
return Task.CompletedTask;
}
}
テスト対象のサービスは、interfaceに依存します。
C#public class OrderService
{
private readonly IOrderRepository _orderRepository;
public OrderService(IOrderRepository orderRepository)
{
_orderRepository = orderRepository;
}
public async Task CancelAsync(int orderId)
{
var order = await _orderRepository.FindByIdAsync(orderId);
if (order is null)
{
throw new InvalidOperationException("注文が見つかりません。");
}
order.Status = "Canceled";
await _orderRepository.SaveAsync(order);
}
}
このように設計しておけば、ユニットテストで本物のデータベースを使わずにロジックを検証できます。
async interfaceは、DI、モック、ユニットテストを前提にした設計と非常に相性がよいです。
9. async interfaceのベストプラクティス
最後に、C#のinterfaceでasyncを扱うときのベストプラクティスを整理します。
9-1. interfaceには処理内容ではなく契約を書く
interfaceの役割は、処理内容を書くことではなく、外部から見た契約を定義することです。
C#public interface IUserService
{
Task<User?> GetUserAsync(int id);
}
このinterfaceが表しているのは、「IDを指定すると、非同期にユーザーを取得できる」という契約です。
実際にデータベースから取得するのか、APIから取得するのか、キャッシュから取得するのかは実装クラスの責務です。
C#public class DatabaseUserService : IUserService
{
public async Task<User?> GetUserAsync(int id)
{
await Task.Delay(100);
return new User { Id = id, Name = "Database User" };
}
}
public class ApiUserService : IUserService
{
public async Task<User?> GetUserAsync(int id)
{
await Task.Delay(100);
return new User { Id = id, Name = "API User" };
}
}
interfaceには「何ができるか」を書き、「どう実行するか」は実装側に任せましょう。
9-2. 非同期処理が必要な境界だけTaskを返す
すべてのメソッドをTaskにすればよいわけではありません。
非同期処理が有効なのは、主にI/O待ちが発生する境界です。
たとえば、次のような処理です。
C#Task<User?> GetUserAsync(int id);
Task<string> ReadFileAsync(string path);
Task SendEmailAsync(string to, string body);
Task SaveChangesAsync();
一方で、単純な計算処理やメモリ上だけで完結する処理は、同期メソッドで十分な場合があります。
C#public interface ITaxCalculator
{
decimal Calculate(decimal price);
}
非同期にする必要がない処理までTaskで包むと、コードが複雑になり、かえって読みづらくなることがあります。
interface設計では、非同期処理が必要な境界を見極めることが重要です。
9-3. asyncを使わない実装でもTaskを返せるようにする
interfaceがTask<T>を返す場合でも、実装側で必ずasyncを使う必要はありません。
たとえば、テスト用のフェイク実装では、すぐに結果を返すことがあります。
C#public class FakeUserService : IUserService
{
public Task<User?> GetUserAsync(int id)
{
User? user = new User
{
Id = id,
Name = "Fake User"
};
return Task.FromResult(user);
}
}
戻り値のない処理なら、Task.CompletedTaskを返せます。
C#public class FakeEmailService : IEmailService
{
public Task SendEmailAsync(string to, string subject, string body)
{
return Task.CompletedTask;
}
}
このように、interfaceが非同期の契約を持っていても、実装側は状況に応じてasyncを使うかどうかを選べます。
asyncを付ける必要がない場合は、無理に付けずにTask.FromResultやTask.CompletedTaskを使うとシンプルです。
9-4. ConfigureAwaitや例外処理を実装側で統一する
ConfigureAwaitや例外処理は、interfaceではなく実装側の設計方針として統一します。
C#public interface IExternalApiClient
{
Task<ApiResponse> GetAsync(
string path,
CancellationToken cancellationToken = default);
}
実装側では、例外をそのまま投げるのか、独自例外に変換するのか、結果型で返すのかを決めます。
C#public class ExternalApiClient : IExternalApiClient
{
private readonly HttpClient _httpClient;
public ExternalApiClient(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<ApiResponse> GetAsync(
string path,
CancellationToken cancellationToken = default)
{
try
{
var response = await _httpClient.GetFromJsonAsync<ApiResponse>(
path,
cancellationToken);
return response ?? new ApiResponse
{
Succeeded = false,
ErrorMessage = "レスポンスが空です。"
};
}
catch (HttpRequestException ex)
{
return new ApiResponse
{
Succeeded = false,
ErrorMessage = ex.Message
};
}
}
}
interfaceだけでは、内部の例外処理方針までは表現しきれません。そのため、ドキュメント、命名、戻り値の型、テストによって補完することが大切です。
9-5. 可読性と保守性を重視して命名する
async interfaceでは、メソッド名や戻り値の型から意図が伝わることが重要です。
たとえば、次のような名前は意図がわかりやすいです。
C#Task<User?> FindUserAsync(int id);
Task<User> GetUserAsync(int id);
Task<IReadOnlyList<Order>> GetOrdersAsync(int userId);
Task<bool> ExistsAsync(string email);
Task DeleteAsync(int id);
Findは見つからない可能性がある、Getは取得できることを期待する、Existsは存在確認をする、といった意味を持たせると、呼び出し側が理解しやすくなります。
反対に、次のような名前は避けた方がよいです。
C#Task<User> DoAsync(int id);
Task ProcessAsync();
Task<object> ExecuteAsync(object input);
何をするメソッドなのか、戻り値が何を意味するのかがわかりにくくなります。
interfaceは多くのクラスから参照されるため、命名の影響が大きいです。可読性と保守性を重視して、具体的で一貫性のある名前を付けましょう。
まとめ
C#のinterfaceでasyncを使う場合、重要なのはinterfaceにasync修飾子を書くのではなく、TaskやTask<T>などの戻り値で非同期処理を表すことです。
基本的な書き方は次のようになります。
C#public interface IUserService
{
Task<User?> GetUserAsync(int id);
Task CreateUserAsync(User user);
}
実装側では、必要に応じてasyncとawaitを使います。
C#public class UserService : IUserService
{
public async Task<User?> GetUserAsync(int id)
{
await Task.Delay(100);
return new User
{
Id = id,
Name = "Taro"
};
}
public async Task CreateUserAsync(User user)
{
await Task.Delay(100);
}
}
async interfaceを設計するときは、次のポイントを意識しましょう。
Taskは値を返さない非同期処理に使います。
C#Task SaveAsync();
Task<T>は値を返す非同期処理に使います。
C#Task<User?> GetUserAsync(int id);
ValueTaskは、同期的に完了する可能性が高く、パフォーマンス上の理由がある場合に検討します。
C#ValueTask<User?> GetCachedUserAsync(int id);
複数の値を非同期に順次返したい場合は、IAsyncEnumerable<T>を使います。
C#IAsyncEnumerable<User> GetUsersAsync();
また、async voidは通常の非同期処理では避け、Taskを返すようにします。
C#public interface ILogService
{
Task WriteAsync(string message);
}
実務では、APIクライアント、データベースアクセス、ファイル操作、外部サービス連携、リポジトリパターン、DI、ユニットテストなどでasync interfaceがよく使われます。
interfaceには処理内容ではなく契約を書き、実装側でasyncやawait、例外処理、ConfigureAwaitなどを統一することが大切です。
C#で保守性の高い非同期設計を行うには、interface、async、Taskの役割を正しく理解し、適切な戻り値と命名で設計することが重要です。

