Software Architecture
Synchronous Programming
Asynchronous Patterns
Repository Design
Software Development

Synchronous architecture with asynchronous repository

Master System Design with Codemia

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

Introduction

A synchronous application layer can call an asynchronous repository, but the design only stays clean if the asynchronous boundary is handled deliberately. In most cases, once persistence is asynchronous, the surrounding application services and request handlers should also become asynchronous up to the nearest natural boundary.

Why Repositories Become Asynchronous

Repositories are often asynchronous because data access is I/O-bound. Database queries, network calls, and storage APIs spend most of their time waiting, so an asynchronous API lets the runtime use threads more efficiently.

A repository interface in C# might look like this:

csharp
1using System.Threading;
2using System.Threading.Tasks;
3
4public interface IUserRepository
5{
6    Task<User?> GetByIdAsync(int id, CancellationToken cancellationToken);
7    Task SaveAsync(User user, CancellationToken cancellationToken);
8}

That interface reflects the real nature of the work: the result is not available immediately.

What Happens if the Upper Layer Stays Synchronous

A purely synchronous service sitting on top of this repository has only a few options:

  • block on the asynchronous call
  • restructure itself to become asynchronous
  • move the async work behind a queue or background boundary

Blocking is the easiest to write and usually the worst long-term choice. It can waste threads, increase latency under load, and create deadlock risks in some environments.

This is the anti-pattern:

csharp
1public User GetUser(int id)
2{
3    return _repository.GetByIdAsync(id, CancellationToken.None).GetAwaiter().GetResult();
4}

The code compiles, but it throws away most of the benefit of having an asynchronous repository in the first place.

A Better Split: Synchronous Domain, Asynchronous Application Boundary

The clean compromise is to keep pure domain logic synchronous while making the application service asynchronous.

csharp
1using System.Threading;
2using System.Threading.Tasks;
3
4public class UserService
5{
6    private readonly IUserRepository _repository;
7
8    public UserService(IUserRepository repository)
9    {
10        _repository = repository;
11    }
12
13    public async Task<UserProfileDto?> GetProfileAsync(int id, CancellationToken cancellationToken)
14    {
15        var user = await _repository.GetByIdAsync(id, cancellationToken);
16        if (user is null)
17        {
18            return null;
19        }
20
21        return new UserProfileDto(user.Id, user.Name, user.Email);
22    }
23}

The repository remains async because it does I/O. The domain mapping stays ordinary and synchronous inside the method body.

This separation works well because only the I/O boundary is async. The core business rules do not need fake asynchrony.

Propagate Async to the Request Boundary

Once the service becomes async, the controller or handler should usually be async too.

csharp
1using Microsoft.AspNetCore.Mvc;
2using System.Threading;
3using System.Threading.Tasks;
4
5[ApiController]
6[Route("users")]
7public class UsersController : ControllerBase
8{
9    private readonly UserService _service;
10
11    public UsersController(UserService service)
12    {
13        _service = service;
14    }
15
16    [HttpGet("{id}")]
17    public async Task<ActionResult<UserProfileDto>> Get(int id, CancellationToken cancellationToken)
18    {
19        var profile = await _service.GetProfileAsync(id, cancellationToken);
20        if (profile is null)
21        {
22            return NotFound();
23        }
24
25        return Ok(profile);
26    }
27}

This is usually the right architectural shape for web applications, APIs, and services that already run on async-friendly frameworks.

When a Synchronous Upper Layer Can Still Make Sense

There are cases where the top layer stays synchronous for legitimate reasons:

  • a command-line tool with simple sequential flow
  • a legacy application framework with synchronous extension points
  • a boundary that intentionally delegates async work to a background queue

In those cases, you may block once at the outer edge, but it should be a conscious boundary decision, not something repeated throughout the codebase.

The key question is whether the synchronous layer is a true boundary or just accidental inertia.

Design the Whole Call Chain Consistently

Mixed sync and async code becomes painful when the design is inconsistent. A good rule is:

  • asynchronous for I/O and workflows that depend on I/O
  • synchronous for pure computation and domain rules

That keeps the code honest. Methods are async because they wait, not because the team standardized on a suffix.

Common Pitfalls

A common mistake is blocking on every repository call with .Result or .GetAwaiter().GetResult(). That turns async code back into sync code with extra risk.

Another mistake is making every domain method asynchronous even when it performs no waiting. That adds ceremony without architectural benefit.

Teams also underestimate how far async often needs to propagate. If persistence is async in a request-driven system, controllers and handlers usually need to be async too.

Finally, avoid hiding asynchronous work behind a synchronous facade unless that facade is a deliberate, well-understood boundary.

Summary

  • An asynchronous repository usually implies asynchronous application services and handlers.
  • Keep pure business logic synchronous inside those async methods.
  • Blocking on async repository calls should be a rare boundary decision, not a normal pattern.
  • Mixed sync and async architecture is workable only when the boundaries are explicit.
  • The right design is usually “async for I/O, sync for pure computation.”

Course illustration
Course illustration

All Rights Reserved.