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
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
An async interface accommodates both async and sync implementations. Sync implementations return Task.FromResult() with negligible overhead.
Solution 2: Dual Interfaces
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
In JavaScript/TypeScript, async functions always return Promise, so there is no sync/async mismatch at the interface level.
Solution 4: Java with CompletableFuture
Common Pitfalls
- Blocking on
.Resultor.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. UseConfigureAwait(false)in library code or switch toTask.Runwrapping. - Wrapping every sync method with
Task.Run:Task.Runoffloads work to the thread pool, but wrapping purely CPU-bound sync code inTask.Runjust moves the work to another thread without any concurrency benefit. Only useTask.Runwhen 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 voidinstead ofasync Task:async voidmethods cannot be awaited and swallow exceptions silently. Always returnTaskorTask<T>from async methods except for event handlers. - Ignoring
ConfigureAwait(false)in library code: Library methods should useConfigureAwait(false)on everyawaitto 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.Runwrapping 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

