Cortex, Day Two: The Shape Every Service Will Follow
Build 01, Lesson 02 — Python async, decorators & type hints: Cortex’s service layer Builds on: build-01-lesson-01. This lesson’s tag: build-01-lesson-02.
The Hook
Today’s lesson doesn’t add an endpoint, a database, or anything a user will ever touch directly. It adds something less visible and more important: the shape every future piece of Cortex will be built in.
By the end of this lesson, Cortex will have:
A
DocumentServiceclass that stores and retrieves documents asynchronously, safely, under real concurrency — no database yet, that’s next weekA
@log_calldecorator that times and logs every service call without touching a single line of business logicType-checked data — a
Documentthat can’t silently drift out of shape as the codebase grows to 75 lessons’ worth of features
Nothing here is throwaway. The pattern you write today for a boring in-memory store is the exact pattern Build 09 uses to trace LLM calls in production, and the exact pattern Build 07’s agent uses to call tools safely.
Why This Matters
An AI system is, underneath the model calls, a collection of services calling each other — the retriever calls the vector store, the agent calls the retriever, the API calls the agent. If those services are inconsistent — some blocking, some async, some untyped, some silently swallowing errors — every layer above them inherits that inconsistency as a bug you’ll debug at 2 a.m. in production. Get the shape right once, early, and every lesson after this one gets to reuse it instead of reinventing it.
The Concept
Async isn’t about speed — it’s about not blocking on things that aren’t CPU work. When DocumentService.create_document runs, most of its time (once we add Postgres in Lesson 05, and especially once we add LLM calls in Build 04) is spent waiting — for a database round-trip, for a network response from an API. A synchronous function blocks the entire process during that wait. An async function hands control back so something else — another request, another document upload — can run in the meantime. This is the difference between a service that handles one request at a time and one that handles thousands concurrently on the same hardware.
Decorators are how you add behavior without touching behavior. Think of a decorator the way you’d think of a security guard standing at a building’s entrance: nobody inside the building changes what they do, but everyone who enters gets checked on the way in. @log_call wraps create_document, get_document, and list_documents with identical timing and logging, and none of those three methods has a single line of code dedicated to timing or logging. When Build 09 needs to add real distributed tracing, it modifies the guard at the door — not every room in the building.
Type hints are a contract, not decoration. Document is a frozen dataclass with four fields, each with a declared type. That looks like ceremony on a four-field class — it stops looking like ceremony the moment an LLM in Build 04 is calling this service via function calling, and a malformed argument needs to fail loudly at the boundary instead of corrupting a document three services downstream. The type hints you write today are what makes a static type checker — and, later, a model choosing which function to call — able to reason about this code without running it.
Immutability is a scaling decision disguised as a style choice. Document is frozen — you can’t mutate a field after construction, only replace the whole object. That matters far sooner than it looks like it should: Build 09’s caching layer needs to hash documents and trust that a cached value never silently changes underneath it. Deciding that now, on a four-field class, costs nothing. Retrofitting it onto a mutable object graph six builds from now costs a rewrite.
In production, this exact pattern — async service, decorator-based observability, strict typing at the boundary — is what separates a demo that works on a laptop from a service that survives being called by five other services it’s never met, which is precisely the situation Cortex’s agent layer will put this class in by Build 07.
The Diagram
Diagram 1 shows today’s concept in isolation: a call passing through the @log_call decorator on its way to create_document, timed and logged before it ever touches the in-memory store.
Diagram 2 zooms out: this same async-service-plus-decorator shape is what the Classifier (Build 02), RAG (Build 05), and Agent (Build 07) will each be built on. You’re not learning a pattern for today’s lesson — you’re establishing the one Cortex uses for the rest of the series.
The Implementation
We build this in three small, testable steps.
1. Define the contract first.
@dataclass(frozen=True, slots=True)
class Document:
id: str
title: str
body: str
owner_id: str
slots=True isn’t required to make this work — it’s there because it’s the kind of small production habit worth building now: it stops accidental attribute typos from silently creating new fields instead of raising an error.
2. Write the decorator once, generically.
def log_call(fn: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T]]:
@functools.wraps(fn)
async def wrapper(*args, **kwargs) -> T:
start = time.perf_counter()
result = await fn(*args, **kwargs)
...
return result
return wrapper
Note functools.wraps — without it, every decorated method would report its name as wrapper in stack traces and logs, which is exactly the kind of small omission that turns a 10-minute production debugging session into an hour.
3. Apply it to the service, and prove it’s safe under concurrency, not just correct in isolation — a single await asyncio.gather(...) call creating twenty documents at once is the test that would catch a race condition a naive implementation would hide until real traffic found it.
We’re deliberately not wiring this to Postgres yet, and not exposing it over HTTP yet — both of those are next week’s problems, and bolting them on today would mean debugging three new concepts at once instead of one.
Github Link:
https://github.com/sysdr/cortex/tree/main/build-01-lesson-02-code
The Code
This lesson’s full package — lesson_code.py, tests, setup script, and README — is in build-01-lesson-02-code.zip, tagged as build-01-lesson-02. It’s buildable standalone on top of last lesson’s tag; running ./setup.sh && pytest test_lesson.py -v gives you seven passing tests before you write a line of your own.
Working Code Demo:
Try It Yourself
Add a fourth method, delete_document(document_id: str) -> bool, decorated with @log_call, returning True if a document existed and was removed, False otherwise. Write one test proving a deleted document no longer appears in list_documents.
You’ll know it worked when your test passes and you can explain, out loud, why delete_document needed the same async with self._lock pattern as create_document — that’s this lesson’s actual aha moment: concurrency bugs don’t announce themselves, the lock is what prevents them from existing in the first place.
Next Time
Lesson 3 stays inside this same file’s neighborhood — Linux CLI & bash scripting — building setup.sh and seed.sh into real dev tooling for the repo, so that by Lesson 4, docker compose up and a single seed command are all anyone needs to get Cortex running from a cold clone.


