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 forasync, await, thread, sleep, lock. Hits mean concurrency is inside the core. Retries and timeouts (lesson 0021) are coordination — they belong in the adapters too.
| You see this | What it costs | The move |
|---|---|---|
| async def on a method with no await | Async spread by convention rather than by need. | Make it sync; the caller keeps its own loop. |
| time.sleep() inside a rule | The rule now owns a backoff policy it can’t explain. | Move the wait into the adapter. |
| threading.Lock in a domain object | The object was designed for one concurrency model. | Own the state (lesson 0020); lock at the edge. |
| a domain test calling asyncio.run | That 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
- Architecture Patterns with Python, Percival & Gregory — ch. 7–8: the sync domain + async edges shape, and the message bus that replaces scattered awaits.
- "What Do You Mean by Event-Driven?", Fowler — the vocabulary for why async belongs at the edges.
- Robust Python, Viafore — concurrency chapters: keeping the core free of threading primitives.
- Watch: ArjanCodes YouTube — search "asyncio" for the channel's async guidance.
- Next: lesson 0025 — event-driven architecture.
- Reference: Patterns — async boundary.
Show your agent-teacher your async domain and extract one function together.