asynchronous programming
synchronous methods
interface design
software architecture
clean code

Interface with synchronous methods vs. asynchronous implementation clean way to solve?

Master System Design with Codemia

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

Introduction

When an interface defines synchronous method signatures but the implementation needs to perform asynchronous work (I/O, HTTP calls, database queries), you face a design tension. Blocking on async code inside a sync method causes deadlocks and thread pool starvation. The cleanest solutions are to change the interface to expose async methods, use the async-all-the-way pattern, or provide dual interfaces. In languages like C#, Java, and TypeScript, the strategies differ but the core principle is the same — avoid wrapping async in sync (Task.Result, .GetAwaiter().GetResult()) and instead propagate asynchrony through the call chain.

The Problem: Sync Interface, Async Implementation

csharp
1// Interface defines synchronous contract
2public interface IDataService
3{
4    List<Item> GetItems();
5    Item GetById(int id);
6}
7
8// But the implementation needs to call an async API
9public class ApiDataService : IDataService
10{
11    private readonly HttpClient _client;
12
13    public List<Item> GetItems()
14    {
15        // BAD: Blocking on async code — deadlocks in ASP.NET, WPF, etc.
16        var result = _client.GetFromJsonAsync<List<Item>>("/items").Result;
17        return result;
18    }
19
20    public Item GetById(int id)
21    {
22        // BAD: .GetAwaiter().GetResult() still blocks the thread
23        return _client.GetFromJsonAsync<Item>($"/items/{id}")
24            .GetAwaiter().GetResult();
25    }
26}

Calling .Result or .GetAwaiter().GetResult() on a Task blocks the calling thread. In environments with a synchronization context (ASP.NET pre-Core, WPF, WinForms), this causes deadlocks because the async continuation needs the same thread that is currently blocked.

Solution 1: Make the Interface Async

csharp
1// Change the interface to return Task<T>
2public interface IDataService
3{
4    Task<List<Item>> GetItemsAsync();
5    Task<Item> GetByIdAsync(int id);
6}
7
8// Clean async implementation
9public class ApiDataService : IDataService
10{
11    private readonly HttpClient _client;
12
13    public async Task<List<Item>> GetItemsAsync()
14    {
15        return await _client.GetFromJsonAsync<List<Item>>("/items");
16    }
17
18    public async Task<Item> GetByIdAsync(int id)
19    {
20        return await _client.GetFromJsonAsync<Item>($"/items/{id}");
21    }
22}
23
24// Synchronous implementation also works — just return completed tasks
25public class InMemoryDataService : IDataService
26{
27    private readonly List<Item> _items = new();
28
29    public Task<List<Item>> GetItemsAsync()
30    {
31        return Task.FromResult(_items);  // No allocation, no async state machine
32    }
33
34    public Task<Item> GetByIdAsync(int id)
35    {
36        return Task.FromResult(_items.FirstOrDefault(i => i.Id == id));
37    }
38}

An async interface accommodates both async and sync implementations. Sync implementations return Task.FromResult() with negligible overhead.

Solution 2: Dual Interfaces

csharp
1// Separate sync and async contracts
2public interface IDataService
3{
4    List<Item> GetItems();
5    Item GetById(int id);
6}
7
8public interface IDataServiceAsync
9{
10    Task<List<Item>> GetItemsAsync();
11    Task<Item> GetByIdAsync(int id);
12}
13
14// Implementation provides both
15public class ApiDataService : IDataService, IDataServiceAsync
16{
17    public async Task<List<Item>> GetItemsAsync()
18    {
19        return await _client.GetFromJsonAsync<List<Item>>("/items");
20    }
21
22    // Sync version delegates to async safely using a dedicated thread
23    public List<Item> GetItems()
24    {
25        return Task.Run(() => GetItemsAsync()).GetAwaiter().GetResult();
26    }
27}

Task.Run offloads work to the thread pool, avoiding synchronization context deadlocks. This is the "sync-over-async" pattern — acceptable when you genuinely need both interfaces but cannot change callers.

Solution 3: TypeScript / JavaScript Pattern

typescript
1// In TypeScript, interfaces can declare async methods directly
2interface DataService {
3  getItems(): Promise<Item[]>;
4  getById(id: number): Promise<Item>;
5}
6
7// Async implementation
8class ApiDataService implements DataService {
9  async getItems(): Promise<Item[]> {
10    const response = await fetch('/api/items');
11    return response.json();
12  }
13
14  async getById(id: number): Promise<Item> {
15    const response = await fetch(`/api/items/${id}`);
16    return response.json();
17  }
18}
19
20// Sync-like implementation (still returns Promise)
21class InMemoryDataService implements DataService {
22  private items: Item[] = [];
23
24  async getItems(): Promise<Item[]> {
25    return this.items;  // Resolved promise immediately
26  }
27
28  async getById(id: number): Promise<Item> {
29    return this.items.find(i => i.id === id)!;
30  }
31}

In JavaScript/TypeScript, async functions always return Promise, so there is no sync/async mismatch at the interface level.

Solution 4: Java with CompletableFuture

java
1public interface DataService {
2    CompletableFuture<List<Item>> getItemsAsync();
3    CompletableFuture<Item> getByIdAsync(int id);
4}
5
6// Async implementation
7public class ApiDataService implements DataService {
8    public CompletableFuture<List<Item>> getItemsAsync() {
9        return httpClient.sendAsync(request, BodyHandlers.ofString())
10            .thenApply(response -> parseItems(response.body()));
11    }
12}
13
14// Sync implementation wrapping synchronous logic
15public class InMemoryDataService implements DataService {
16    public CompletableFuture<List<Item>> getItemsAsync() {
17        return CompletableFuture.completedFuture(items);
18    }
19}

Common Pitfalls

  • Blocking on .Result or .Wait() in sync-context environments: This is the most common cause of deadlocks. The sync context needs the blocked thread to resume the async continuation, creating a circular wait. Use ConfigureAwait(false) in library code or switch to Task.Run wrapping.
  • Wrapping every sync method with Task.Run: Task.Run offloads work to the thread pool, but wrapping purely CPU-bound sync code in Task.Run just moves the work to another thread without any concurrency benefit. Only use Task.Run when you need to avoid sync-context deadlocks.
  • Mixing sync and async in the same call chain: Starting async mid-way through a sync call chain forces awkward blocking somewhere. Propagate async all the way from the top (controller, handler, entry point) down to the lowest-level I/O call.
  • Using async void instead of async Task: async void methods cannot be awaited and swallow exceptions silently. Always return Task or Task<T> from async methods except for event handlers.
  • Ignoring ConfigureAwait(false) in library code: Library methods should use ConfigureAwait(false) on every await to avoid capturing the synchronization context. This prevents deadlocks when library consumers call the method from sync code with .Result.

Summary

  • The cleanest solution is to make the interface async (Task<T> / Promise<T> / CompletableFuture<T>) — sync implementations can return completed futures with minimal overhead
  • Avoid blocking on async code (.Result, .Wait()) — it causes deadlocks in environments with synchronization contexts
  • If you need both sync and async, use dual interfaces or Task.Run wrapping as a last resort
  • Propagate async through the entire call chain — "async all the way" prevents hidden blocking
  • In JavaScript/TypeScript, Promise-based interfaces naturally support both sync and async implementations

Course illustration
Course illustration

All Rights Reserved.