C#
async programming
abstract methods
method overrides
software development

Define an abstract async method even if not all overrides are async?

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

In C#, the right abstraction is usually to define an abstract method that returns Task or Task<T>, not an abstract method marked async. The async keyword is part of the implementation body, while the asynchronous contract is expressed by the return type, which means some overrides can use await and others can return a completed task directly.

async Is Not the Contract

This is the key idea: async is a compiler feature for a method body. It is not part of the polymorphic contract in the way the return type is.

That means an abstract declaration should usually look like this:

csharp
1public abstract class Worker
2{
3    public abstract Task ExecuteAsync();
4}

Not like this:

  • “abstract async method”

An abstract method has no body, so there is nowhere for await to live. The asynchronous shape of the API is already captured by Task.

Overrides Can Be Truly Async or Synchronously Completed

Some derived classes may need real asynchronous work:

csharp
1using System;
2using System.Threading.Tasks;
3
4public sealed class NetworkWorker : Worker
5{
6    public override async Task ExecuteAsync()
7    {
8        await Task.Delay(100);
9        Console.WriteLine("Finished network-style work.");
10    }
11}

Other derived classes may complete immediately and not need async at all:

csharp
1using System;
2using System.Threading.Tasks;
3
4public sealed class InMemoryWorker : Worker
5{
6    public override Task ExecuteAsync()
7    {
8        Console.WriteLine("Finished immediate work.");
9        return Task.CompletedTask;
10    }
11}

Both are valid overrides of the same abstract contract.

Why This Design Is Useful

Returning Task from the base contract gives callers one uniform usage pattern:

csharp
Worker worker = new InMemoryWorker();
await worker.ExecuteAsync();

The caller does not need to know whether the derived implementation:

  • actually awaited I/O
  • returned Task.CompletedTask
  • used Task.FromResult

That is exactly what a good abstraction should provide.

Use Task<T> for Asynchronous Results

If the abstract method produces a value, use Task<T>:

csharp
1using System.Threading.Tasks;
2
3public abstract class Loader
4{
5    public abstract Task<string> LoadAsync();
6}

Async override:

csharp
1using System.Threading.Tasks;
2
3public sealed class RemoteLoader : Loader
4{
5    public override async Task<string> LoadAsync()
6    {
7        await Task.Delay(100);
8        return "remote";
9    }
10}

Immediate override:

csharp
1using System.Threading.Tasks;
2
3public sealed class CachedLoader : Loader
4{
5    public override Task<string> LoadAsync()
6    {
7        return Task.FromResult("cached");
8    }
9}

Again, the return type defines the async-friendly contract. The async keyword only appears when an implementation needs it.

Do Not Add async Without await

If an override does not need to await anything, do not mark it async just for consistency. That adds an unnecessary state machine and can make the code noisier.

Bad habit:

csharp
1public override async Task ExecuteAsync()
2{
3    Console.WriteLine("Nothing asynchronous here.");
4}

Better:

csharp
1public override Task ExecuteAsync()
2{
3    Console.WriteLine("Nothing asynchronous here.");
4    return Task.CompletedTask;
5}

This keeps the implementation honest about what it is actually doing.

Interfaces Follow the Same Rule

The same design principle applies to interfaces:

csharp
1public interface IRepository
2{
3    Task SaveAsync();
4}

Implementations can choose whether they are internally asynchronous or immediately completed. The interface contract remains clean and consistent either way.

A Good Rule of Thumb

Use async-shaped contracts when callers should be able to await the operation, regardless of whether every implementation performs asynchronous work internally.

That is common when:

  • some implementations use I/O
  • some implementations use cached or in-memory results
  • you want one API shape for all derived types

This is one of the places where Task is valuable even when a specific implementation finishes synchronously.

Common Pitfalls

  • Thinking the abstract declaration itself should be marked async rather than just returning Task.
  • Marking overrides async even when they do not await anything.
  • Returning void from an abstract asynchronous contract instead of Task, except for event handlers.
  • Exposing both sync and async polymorphic methods without a clear need and making the API harder to use.
  • Forgetting that Task.CompletedTask and Task.FromResult are the normal tools for synchronously completed overrides.

Summary

  • Abstract async-style methods in C# should normally return Task or Task<T>.
  • The async keyword belongs in implementations that actually use await.
  • Some overrides can be truly asynchronous, while others can return completed tasks immediately.
  • This keeps the caller-facing contract uniform without forcing every implementation to be asynchronous internally.
  • Treat async as an implementation detail and Task as the API contract.

Course illustration
Course illustration

All Rights Reserved.