4

In his book "Concurrency in C# Cookbook", Stephen Cleary writes:

If you can, try to organize your code along modern design guidelines, like Ports and Adapters (Hexagonal Architecture), which separate your business logic from side effects such as I/O. If you can get into that situation, then there's no need to expose both sync and async APIs for anything; your business logic would always be sync and the I/O would always be async.

However, I don't find that to be true in my design and I am wondering how it's supposed to be so I can have a sync domain and async infrastructure code.

For example, given the following:

public interface IContentRepository
{
  Content GetContent (Guid contentId);
}

public class MyDomainObject
{
  public void Foo (IContentRepository repo)
  {
    var content = GetContent(someId);
    ...
  }
}

public class ContentRepository : IContentRepository
{
  // Uh oh, either...
  // 1. implement it sync here or
  // 2. use sync-over-async or
  // 3. make IContentRepository return Task<Content> (and live with an async domain)
}

How's that supposed to be designed such that my domain can stay sync-only and can make use of async infrastructure code. Is that even possible? Have I misunderstood Stephen?

D.R.
  • 241

3 Answers3

4

I guess Stephen Cleary expects a ports and adapters architecture to do all I/O in the outer layers and to pass in the results of these operations to the domain objects. In your example, this would be simple: pass in the content to the domain object rather than injecting IContentRepository:

public class MyDomainObject
{
  public void Foo (Content content)
  {
    ...
  }
}

However, this becomes more difficult when you have business rules that involve side effects.

Example:

public class ContentDomainObject
{
  public async void FinalizeContent(IContentRepository repository)
  {
    if (RequiresRule276aContent)
    {
      var content = CreateFinalRule276aContent();
      var contentId = await repository.AddContent(content);
      _contentId = contentId;
      ReportDomainEvent(new ContentFinalizedEvent(contentId));
    }
    else
    {
      ...
    }

  }
}

You could keep the inner layer oblivious of this async side effect by moving some of this to the outer layer:

// Outer layer
var content = contentDomainObject.CreateFinalContent();
var contentId = await repository.AddContent(content);
contentDomainObject.ProcessFinalContent(contentId);

However, I'm not sure if this is really better - now the domain operation has been split into two parts, whose names maybe don't really make sense from a business perspective. So, I'd probably choose to be pragmatic and just accept the async domain object methods.

2

It is true that if you start using async-await it spreads as a zombie along application pipeline and you will be forced to use async everywhere.

Try to approach design from a bit different angle - domain logic care only about data.
So we can put all IO related code outside of domain layers as close to the top layers as possible.

1 Load required data asynchronously
2 Pass data to the business logic
3 Process data synchronously
4 Return possible results
5 Save processed results asynchronously

public class MyDomainObject
{
    public MyDomainObject(DomainContent content) => _content = content;

    public ProcessedContent DoSomething()
    {
        // Process given content
    }
}

// This implementation belongs to the "infrastructure" layer
public class MyDomainProcessor
{
    public MyDomainProcessor(IContentRepository repository)
    {
        _repository = repository;
    }

    public async Task ProcessAsync(Guid contentId)
    {
        var content = await _repository.GetContentBy(contentId);

        var processedContent = new MyDomainObject(content).DoSomething();

        await _repository.Save(processedContent);
    }
}
Fabio
  • 3,166
1

You need to make the adapter call to your domain.

Async code can call both sync and async code. However, when sync code calls async you run into trouble.

Simplest way to fix this is to pass a callback.


How does the flow of execution on your project go?

The code that does your dependency injection can be async, so it can call both sync and async code. See also composition root. Right? You have async code in the boundary.

The async code in the boundary can easily call into your domain… then your domains returns instances※ that tells it what operation to do (e.g. get the repository contents).

※: Once passed, the code in the boundary should not be able to mutate those instances. Simplest way to accomplish this is passing a value type, immutable objects also work, or simply not holding a reference to them. See also Value/reference type, object and semantics.

When the boundary code gets what your code returned, it can go perform the async operation, and call back again into your domain.

In fact, if you do that, you do not need to do dependency injection at all.

Theraot
  • 9,261