asynchronous programming
synchronous methods
interface design
software architecture
clean code

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

System Design practice on Codemia

Work through 120+ system design problems with detailed solutions, from rate limiters to multi-region storage.

Practice system design

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

Related reading
Course
Beginner
27 lessons
10 hours
System Design Fundamentals

Build a strong foundation in designing scalable, reliable distributed systems.

View the course
Track what you have practised

A free account saves your progress, solutions and study plan across every problem on Codemia.

System Design practice on Codemia

Work through 120+ system design problems with detailed solutions, from rate limiters to multi-region storage.

Practice system design

All Rights Reserved.