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.

Reading ≈ 8 minPractice ≈ 40 min

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)
The principle behind the principle

async is viral in a way ordinary dependencies are not. An await can only appear inside an async def, so one await in the core forces every ancestor in the call graph to become async — including the test, the cron job, and the caller that does not exist yet. This is a coupling that propagates upward automatically and cannot be contained locally. Contrast a database dependency: however deep it sits, one seam injects it away. There is no seam that un-colours a function.

The second half of the mechanism is what makes a rule reusable. A rule is a decision about data; a decision about data is a pure function; and a pure function can be called from a web request, a nightly backfill, a replay script, and a test without any of them agreeing about how I/O happens. Purity is not an aesthetic here — it is what lets one rule serve four callers.

Where it stops applying. When timing is the business rule. A scheduler, a rate limiter, a lease manager, a protocol state machine, a market gateway — in these the concurrency is the domain, and a "pure core" would be a core that lies about what it does. It also weakens when the next fetch depends on what the last fetch returned: pre-fetching everything is then wasteful, and the honest shape is a pure decide-what-to-fetch-next function driving an async loop at the edge, rather than a pure function pretending it had all the data up front.

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.

Worked example: three awaits inside one eligibility rule

A returns-resolution service decides whether a return is eligible. The rule needs three things: the order, the carrier's scan events, and the customer's prior return rate. The first draft is an async def that awaits all three.

What a senior designer checks first is not the keyword but the dependency between the fetches: does any of the three depend on the result of another? Here, no — they are three independent lookups keyed on the same order id. That single observation decides the shape, because independent fetches can be gathered at the edge and handed to the rule as data, whereas dependent ones cannot.

The alternative that gets proposed in review is to keep the rule async so it can fetch lazily — "we only pay for what the rule actually reads". It is rejected on two grounds. First, it makes cost invisible: a rule that reads three attributes now issues three sequential network calls, 3 × 120 ms instead of one gathered 130 ms, and nothing in the rule's text says so. Second, it hides an N+1 the moment the rule runs over a batch.

Put the batch number on it. The nightly job evaluates about 8,000 returns. Sequential in-rule fetching at ~360 ms each is roughly 48 minutes; gathered at the edge — and batched where the repository allows it — the same work finishes in under four. The rule's own test suite drops from seconds of event-loop setup to milliseconds, which is what makes anyone willing to add the fourteenth eligibility case.

# application/ — the edge gathers, because the edge owns I/O
@dataclass(frozen=True)
class EligibilityInput:
    order: Order
    scans: tuple[CarrierScan, ...]
    prior_return_rate: float

async def evaluate(order_id: str, orders, carrier, history, policy) -> Decision:
    order, scans, rate = await asyncio.gather(
        orders.get(order_id), carrier.scans(order_id), history.rate(order_id)
    )
    return is_eligible(EligibilityInput(order, tuple(scans), rate), policy)

# domain/ — sync, pure, and readable as a policy document
def is_eligible(data: EligibilityInput, policy: ReturnPolicy) -> Decision:
    if data.order.age_days > policy.window_days:
        return Decision.refuse("outside the return window")
    if data.prior_return_rate > policy.fraud_threshold:
        return Decision.review("prior return rate above threshold")
    if not any(scan.is_delivery for scan in data.scans):
        return Decision.refuse("no delivery scan on record")
    return Decision.approve()

Note what the split bought beyond speed. is_eligible is now readable as a policy document by someone who does not write Python — which is how the fraud threshold got questioned by the operations lead, and corrected, before launch. That conversation is not available about a function that also opens sockets.

† 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.
What people get wrong after reading this
"Async makes code faster."
Async makes waiting cheaper. A CPU-bound rule gains nothing, and a chain of sequential awaits gains nothing either. Concurrency comes from gathering, not from the keyword — which is why the fix above was gather, not async.
"A sync domain means a sync system."
The edges can be fully async, the workers can be threads, the batch can be a process pool. Purity is a property of the rule, not of the process it runs in — that is exactly why the rule survives all three.
"I'll inject an async port, that keeps it decoupled."
An async port still forces the rule to be async. Injection fixes who does the I/O, not when it happens. Pass the data, not an awaitable.
"A lock inside a domain object is just defensive."
A lock encodes an assumption about how many callers exist and in which process. It makes the object unusable in a single-threaded batch and meaningless in an async worker, and it hides the real question, which is who owns the state (lesson 0020).

When this is the wrong move

Don't extract a pure core when the concurrency is the domain: schedulers, rate limiters, lease and lock managers, protocol state machines, streaming operators. There, timing and ordering are the rules being modelled, and pushing them to the edge produces a core that describes something the system does not do. Don't do it either when the fetch plan depends on intermediate results — keep a pure "what next?" function and let an async driver walk it. And in a three-function service where the whole application is one async layer, the split buys a file and returns nothing.

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.

In the lab. The phase-2 checkpoint has the returns eligibility check awaiting the carrier client from inside the rule, once per return, in a loop. You gather the inputs at the edge, turn the rule into a pure function over a frozen input record, and watch the domain test file lose its 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.

Teach it back
  1. Explain to a junior, in two sentences, why one await in a rule spreads further than one database call in a rule.
  2. Find one async def in your own code this week that contains no await, or one rule that awaits. Name its cost: which caller can't use it, and which test nobody wrote?
  3. Predict what the call graph looks like in a year if the async keeps spreading upward: what will it take to run that rule from a backfill script?

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.