Software Design Reference System Designer · Designing Systems That Scale

Lesson 0024 · System Designer · Module 3

Keeping Concurrency and Async Out of Domain Logic

Business rules don't care about event loops. When async bleeds into the domain, every rule becomes untestable and every I/O becomes a hidden dependency.

Mission tie-in: "keep concurrency and async execution out of domain logic" — the discipline that keeps the core pure and the edges fast.

Knowledge: pure core, async edges

An async function is a statement about how code runs. Domain rules are statements about what is true. When the two mix, the domain's tests need an event loop, and every call site inherits the async decision — whether it wanted it or not.

# the spread: domain logic fused to I/O style
async def apply_refund(order: Order, gateway: AsyncPaymentGateway) -> Refund:
    if order.status == Status.REFUNDED:          # domain rule
        raise AlreadyRefunded()
    result = await gateway.refund(order.total)   # I/O, inside the rule
    order.mark_refunded(result.refund_id)
    return Refund(result.refund_id)

Test that function and you must build an event loop and mock gateway.refund — and if the rule ever needs a synchronous caller (a cron job, a script), the async spreads further.

The clean split:

# domain: sync, pure, no I/O
def apply_refund(order: Order, refund_id: str) -> Refund:
    if order.status == Status.REFUNDED:
        raise AlreadyRefunded()
    order.mark_refunded(refund_id)
    return Refund(refund_id)

# application/service layer: async at the edge
async def refund_order(order: Order, gateway: AsyncPaymentGateway) -> Refund:
    result = await gateway.refund(order.total)
    return apply_refund(order, result.refund_id)     # sync rule, called once

The rule is now testable with plain function calls; the async lives only where I/O lives. Same for threads: domain code never spawns, never locks, never sleeps — it receives decisions and returns results; concurrency is an edge concern.

Leak test: grep the domain folder for async, await, thread, sleep, lock. Hits mean concurrency is inside the core. Retries and timeouts (lesson 0021) are coordination — they belong in the adapters too.
Field notes · what it looks like in real code
You see thisWhat it costsThe move
async def on a method with no awaitAsync spread by convention rather than by need.Make it sync; the caller keeps its own loop.
time.sleep() inside a ruleThe rule now owns a backoff policy it can’t explain.Move the wait into the adapter.
threading.Lock in a domain objectThe object was designed for one concurrency model.Own the state (lesson 0020); lock at the edge.
a domain test calling asyncio.runThat test is exercising orchestration, not a rule.Split it: fetch outside, decide inside.

Skill: what's where?

An await gateway.refund() inside a domain rule is a problem because:

The correct home for await is:

A domain rule that needs the result of an API call should:

Practice on your own code

Grep your domain folder for async, await, thread, sleep, lock. For each hit, move the concurrency out: the service layer awaits, the domain decides. Re-run your domain tests without an event loop.

Reveal: a worked extraction

A RebalanceService had async on every method because one of them called an async broker API. Extraction: the async fetch lives in the service's async def wrapper; the rebalancing math is a pure sync function taking the fetched positions. The math's tests now run without asyncio.

Your win

You can run the async leak test on your domain, extract concurrency to the service layer, and keep the core's tests plain function calls.

Read and watch deeper

Show your agent-teacher your async domain and extract one function together.