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?

2
  • 2
    I would just say that I cannot agree with what the quote says is correct. While I see it work in some limited case, you correctly point out that edge cases exist where it doesn't apply.
    – Euphoric
    Feb 9, 2020 at 19:29
  • 2
    Don’t pass that repository into your domain. Make a handler/service which gets data from the repository, converts it to domain objects and invokes methods on those objects. The domain methods you invoke can be synchronous.
    – Rik D
    Feb 9, 2020 at 23:04

3 Answers 3

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);
    }
}
7
  • 1
    "domain logic care only about data" But that is wrong!
    – Euphoric
    Feb 10, 2020 at 6:19
  • @Euphoric - I find the whole debate about data/behavior a bit misleading; OOP community likes to emphasize that objects are about behavior, about "what they do", but that's really related to encapsulation and interface design (how objects talk to each other). The Data Driven Design community likes to say it's all about data, and transformation of that data - but hey, "transformation of data" is another way to say "behavior", so perhaps the two attitudes aren't that different at their very core, although they differ in the approach. Feb 10, 2020 at 8:22
  • @FilipMilovanović That's a nice sentiment, but I disagree. Experience shows, that the two approaches lead to a significantly different design. For example an OO approach rarely leads to a Layered Architecture, or even to things like DTOs. This difference has a major impact on maintainability, which is where this divide hits reality. So if you have a "pure" or "clean" "domain" as the answer suggests, how many things do you have to change when something changes? Here is my analysis on Uncle Bob's Clean Architecture Repo. Feb 10, 2020 at 9:54
  • @Euphoric it doesn't have to be wrong. You can (and probably should) isolate your domain logic from side-effects and external dependencies instead of injecting them. This has lots of benefits.
    – Ant P
    Feb 10, 2020 at 11:22
  • @RobertBräutigam - I happened to stumble upon your analysis some time ago; it is deeply misguided. Feb 10, 2020 at 16:51
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.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.