Skip to content

Architecture and contracts

  • Lesson 6
  • 90 min
  • Module 2: Prepare data and architecture

What you will be able to do

Write down how the parts of your product talk to each other and what they store, before you build any of them. Parts built on one written agreement fit together, and the shop never gets lost between them.

Before you startWhat to know and to have readyAbout 90 minutes

What you need to know

What you need on your computer

  • Docker Desktop, running. The database image takes about 690 MB of disk, downloaded once.
  • Port 5432 free on your computer. If another PostgreSQL uses it, the guided practice shows what to change.

No account and no key. Nothing calls a model.

Tested with Python 3.12.13, Docker Engine 29.6.1 and PostgreSQL 18.6 with pgvector 0.8.7, on macOS (Apple silicon). The Windows (PowerShell) and Linux commands are written, not tested.

In a busy restaurant, the waiter and the cook rarely talk. The order slip talks for them. It has the table number, the dishes, and notes such as “no nuts”. The waiter knows what to write, and the cook knows what to read. Two new staff members can start on Monday without meeting, because the slip is the same for everyone.

If the slip has no table number, the cook still cooks, and the food is still good. It goes to the wrong table, and nobody notices until a guest complains.

Sam will build the product in pieces over the next three modules: the API, the page, the ingestion worker, the search and the answer step. Different pieces reach different reviewers on different days. Tomás wants to skip the design: “We will see what each part needs when we write it.”

Here is what happens without written agreements. These are failures that the earlier courses measured or that this pack makes certain:

  • The shop falls out of a key. Bramble’s gift-cards.md and Larkfield’s gift-cards.md share the document key gift-cards. The RAG course built its chunk IDs from the document key, the version and the position. That was right for one shop. With two shops, the same file uploaded by both would get the same chunk IDs. The second upload would collide with the first, or worse, replace it.
  • Two embedding models mixed. The RAG course searched its e5 index with question vectors from another model of the same size. Both had 384 numbers, so nothing failed. The hit rate fell to 0.068: almost every search returned the wrong passages, with no error message.
  • The wrong version. In the same course, without a date filter, 5 passages from an expired version reached the top 5.
  • The page and the API disagree. The page waits for an answer to an upload. The API returns at once, and indexes later. The page shows “done” for a document that then fails.

None of these is a hard bug to fix. Each one is expensive to find later, because nothing crashes.

A contract is a written agreement at the boundary between two parts. It says what one part sends, what the other part returns, which errors can happen, and who may ask. It is the order slip. Each side can be built and tested alone against it.

Sam’s design has four contracts, one for each place where parts meet:

  1. The API contract (between the page and the API): methods, paths, request and response shapes, error codes, and the role that may call each one.
  2. The database schema (between the code and its data): tables, keys, and the rules that the database itself enforces.
  3. The ingestion states (between the upload and the worker): which states a document can be in, and what moves it from one to the next.
  4. The retrieval metadata (between the index and the search): the facts that every chunk carries, and the filters that use them.

A contract is not code. It is a page in the design pack, docs/design-pack/architecture.md, that reviewers read before any code exists. Kwame, the solution architect, reviews the parts and the API. Mei, the data engineer, reviews ingestion and deletion.

Start with the smallest contract: the ID of one chunk. The first section of Bramble’s gift-cards.md is Amounts. After parsing, it runs from character 12 to character 79 of the document’s text. Suppose that both shops upload the same file. The project’s later chunker builds the ID from these values, in this order:

Shop The text that is hashed First 12 characters of its SHA-256
(none, the RAG course’s rule) gift-cards|1|12|79 987c93cee1ea, for both shops
Bramble Books bramble|gift-cards|1|12|79 a1e046fa0990
Larkfield larkfield|gift-cards|1|12|79 eaef0328d7a2

You can check one row yourself:

Terminal window
python -c "import hashlib; print(hashlib.sha256(b'larkfield|gift-cards|1|12|79').hexdigest()[:12])"
eaef0328d7a2

Without the shop, both uploads get 987c93cee1ea. With it, the IDs differ, and a chunk can never be mistaken for the other shop’s. The schema then repeats the rule: the primary key of a chunk is (tenant_id, chunk_id). And the key that points from a chunk to its document includes the shop as well:

FOREIGN KEY (tenant_id, document_id) REFERENCES documents (tenant_id, document_id) ON DELETE CASCADE

So the database refuses a Bramble chunk that points at a Larkfield document, even if the code has a bug. One of the new tests proves it. The contract is three lines of the design, and it removes a whole class of failure.

Sam keeps the architecture as small as the acceptance criteria allow. One API process does everything: it serves the page, answers questions, and runs the ingestion worker in a background thread. One PostgreSQL database with the pgvector extension holds every shop’s documents, files, chunks, vectors and answers. The model provider is behind one adapter, and it replays recorded answers by default.

On the left, the browser with one plain page for upload, status, question and evidence. An arrow labelled POST /documents 202, GET /documents/{id}, POST /answers 200 goes into the API process (FastAPI), on your computer. Inside the API process: routes, which check the request, the caller and the membership; the answer pipeline, which retrieves, assembles, generates, checks and stores; the ingestion worker, a thread that parses, chunks and embeds; and the local models on the CPU, e5-small embeddings. A dashed arrow from routes to the worker: a documents row is the job. The answer pipeline searches PostgreSQL with shop, access, date and model. The worker claims a job and saves chunks. PostgreSQL with pgvector holds Larkfield rows and Bramble Books rows, separated by a line: tenant_id on every row, row-level security. The answer pipeline sends only the question and the passages to the model provider, outside your computer, which gives recorded answers by default or uses a live endpoint. The practice identity provider, from Module 4, gives the routes its public keys.
Sam's parts and the contracts between them. One process, one database with a lane per shop, and a model provider that only ever sees the question and the passages.
Text description of the diagram

The browser shows one plain page. It talks only to the API: it uploads a document (202 Accepted), asks for its status, and posts a question (200 with the answer). Inside the API process, the routes check the request, the caller and the membership, then call the answer pipeline or create a document row. That row is the ingestion job. The worker thread claims it, parses, chunks and embeds the document with the local e5 model, and saves the chunks. The answer pipeline searches PostgreSQL with four filters: the shop, the access level, the date and the embedding model. Every row in PostgreSQL has a tenant_id, and row-level security, added in Module 4, separates the shops a second time. Only the question and the passages go to the model provider. The practice identity provider arrives in Module 4.

Each part has one job, and one thing that it must never do. The second column is the part of the contract that reviewers check most:

Part Its one job It must never
Page let a member upload, follow the status, ask, read the evidence put server text on the page as HTML; keep a token
Routes check the request, the caller and their membership; call one service trust a shop or a role sent by the browser
Answer pipeline turn one question into a stored, cited answer search another shop; send anything but the question and the passages to the model
Ingestion worker index uploaded documents, one at a time lose a document: a failure ends as failed with a reason
PostgreSQL keep every shop’s data, separated by tenant_id let a query of one shop see another shop’s rows
Model provider write an answer from the passages receive a document that the caller may not read

“Why not a message queue for the jobs, and a vector database for the search?” asks Tomás. Kwame asks the question back: which acceptance criterion needs them? Two shops upload a few documents a week. A documents row with a status is a complete queue. The worker claims it with FOR UPDATE SKIP LOCKED, as in the scaling course. pgvector searches the 245 passages of both shops in about 9 ms, as Module 3 measures. Each extra system is one more thing to start, secure, back up and explain in a demo. Also, a second store would need its own copy of the tenant rule. Sam writes the decision down with its reason, and the limit at which it would change.

Every path with shop data starts with /v1/tenants/{tenant}. The shop in the path is only a request: the caller’s membership decides. Every error has one shape, {"code", "message", "request_id"}, as in the API course. Here are four rows of Sam’s contract table. The full table is in docs/design-pack/architecture.md.

Method and path Who may call it Success Errors
POST /v1/tenants/{t}/documents owner, staff 202 {document, duplicate: false} and a Location header 401, 403, 404, 409 conflict, 413 file_too_large, 415 unsupported_file, 422
GET /v1/tenants/{t}/documents/{id} every member 200 {document} with status: uploaded, parsing, indexed or failed (and error) 401, 404
POST /v1/tenants/{t}/answers every member 200 {answer} with claims, citations, cost and time 401, 404, 422, 504 provider_timeout, and others
GET /v1/tenants/{t}/chunks/{chunk_id} every member, at their access level 200 {chunk}: the evidence behind a citation 401, 404

Two choices in this table matter later. An upload answers 202 Accepted, not 200: the API has saved the file, and the indexing has not started. So the page must ask for the status, and show “indexing” until the document is indexed or failed. And the API answers 404 for another shop’s data, never 403, so a stranger cannot learn whether a document exists.

The schema is migrations/001_core.sql: seven tables. Every table with shop data has tenant_id, and every key that points at another row includes it. The documents table carries the facts from the data pack’s inventory: doc_key, version, access, effective_from and effective_to. It also stores sha256, the fingerprint of the file, and embedder, the name of the model that made its vectors. UNIQUE (tenant_id, doc_key, version) allows returns-policy version 1 in both shops, and refuses a second one in the same shop.

The database also checks the values that the contract allows. A status other than the four states, or an access level other than public and staff, is refused by a CHECK constraint. So a typo in the code cannot invent a fifth state.

The documents row is also the ingestion job. There is no separate jobs table. Its status moves through four states:

A state diagram. Start: upload accepted, 202, goes to uploaded. Uploaded goes to parsing when the worker claims it, with a lease of 5 minutes. Parsing goes to indexed when the document is parsed, chunked, embedded and saved in one transaction. Parsing goes to failed at once when the file cannot be parsed, with the reason. Parsing goes back to uploaded after another error, tried again, at most 3 attempts. Parsing goes back to parsing when the lease ended because a worker died: claimed again. Indexed and failed both end when the document is deleted.
The ingestion states of one document. A file that cannot be parsed fails at once; other errors are tried again, at most three times.
Text description of the diagram

An upload creates the document in the state uploaded and answers 202. The worker claims it, which moves it to parsing and gives the worker a lease of 5 minutes: a claim that ends by itself, as in the scaling course’s job lifecycle. If the parse, the chunking, the embedding and the saving all work, one transaction moves it to indexed. If the file cannot be parsed, it moves to failed at once, with the reason, because trying again cannot help. Any other error, such as a lost database connection, moves it back to uploaded for another attempt, at most 3. If a worker dies, its lease ends and another claim takes the document again. Deleting an indexed or failed document removes it with its file, chunks and vectors.

The contract also says what the person sees. uploaded and parsing show as “indexing”, and the page asks again every 1.5 seconds. indexed shows the number of passages. failed shows the reason, for example “This file is not a readable PDF”. Mei checks one rule above all: no path ends with a document stuck in parsing forever.

Each chunk is one section of one document version, and its text starts with the document title. The search filters before it takes the top 5 passages:

  • the shop: tenant_id;
  • the access level: a read-only member searches public documents only;
  • the date: effective_from <= as_of <= effective_to, so that an expired version never competes;
  • the embedding model: the document’s embedder must match the model that embedded the question.

Each of these four filters answers one failure from the problem section. Each passage given to the model also gets a header with its ID, title, type, version and dates. The model can then cite it, and see which version it reads.

Guided practice: the schema in a real database

Section titled “Guided practice: the schema in a real database”

You copy this lesson’s files, start PostgreSQL in Docker, apply the schema and run the new tests.

  1. Copy the files of this lesson from the snapshot end-of-m02:

    Terminal window
    S=../nextia-learning-labs/C27/snapshots/end-of-m02
    mkdir -p migrations docs/design-pack
    cp "$S/doc_assistant/config.py" "$S/doc_assistant/migrate.py" doc_assistant/
    cp "$S/migrations/001_core.sql" migrations/
    cp "$S/tests/conftest.py" "$S/tests/test_schema.py" tests/
    cp "$S/docs/design-pack/architecture.md" docs/design-pack/
    cp "$S/compose.yaml" "$S/.env.example" .
  2. Make your .env file from the example. It holds the database password, so Git ignores it.

    Terminal window
    cp .env.example .env
  3. Open .env in VS Code. Replace change-me-on-your-computer with a password of your own, in all three places. If port 5432 is in use on your computer, add the line DB_PORT=5433, and change 5432 to 5433 in both URLs.

  4. Start the database. --wait returns when PostgreSQL is ready to accept connections:

    Terminal window
    docker compose up -d --wait db

    The first time, Docker downloads the image (about 690 MB on disk). Docker creates a network, the volume pgdata and the container, and waits. The last line says that the database is ready:

    Container doc-assistant-db-1 Healthy

    Without --wait, the command returns as soon as the container starts. A migration run at that moment can fail, because PostgreSQL is still starting. In the author’s run, it stopped with “This probably means the server terminated abnormally before or while processing the request.” Run it again a few seconds later, or use --wait.

  5. Apply the schema:

    Terminal window
    python -m doc_assistant.migrate
    applied 001_core
    1 migration(s) applied.
  6. Run the command again. A migration runs only once:

    Terminal window
    python -m doc_assistant.migrate
    python -m doc_assistant.migrate --status
    The database is up to date.
    001_core applied
  7. Run the tests:

    Terminal window
    python -m pytest
    ....................... [100%]
    23 passed in 0.25s

    The five new tests run in a separate database, assistant_test, which they make new each time. They check four things. The tables exist. A Bramble chunk cannot point at a Larkfield document. The same document key may exist in two shops. And the database refuses a made-up state or access level.

To see the tables, ask PostgreSQL inside its container:

Terminal window
docker compose exec db psql -U assistant -d assistant -c '\dt'
List of tables
Schema | Name | Type | Owner
--------+-------------------+-------+-----------
public | answers | table | assistant
public | chunks | table | assistant
public | document_files | table | assistant
public | documents | table | assistant
public | memberships | table | assistant
public | schema_migrations | table | assistant
public | tenants | table | assistant
public | users | table | assistant
(8 rows)

schema_migrations is the runner’s own record: the name and the SHA-256 of each applied file.

When to write contracts, and what they cost

Section titled “When to write contracts, and what they cost”

Write them after the data pack and before the first integration. Written before the code, a contract lets you build the page and the worker against it, on different days. Write only what a reviewer or a test can check: paths, shapes, codes, keys, states and filters.

The cost is an afternoon of writing, and the contracts must stay true: when Module 4 adds sign-in, the table changes first, then the code. The tenant in every key costs little speed: the authentication course measured about 0.1 ms per request for row-level security. The simplification has a limit too. At hundreds of uploads a minute, a separate worker process would be the first change; python -m doc_assistant.worker exists for that from Module 3.

Common mistake

The tests run before the database

Symptom: the new tests stop with five errors:

E psycopg.OperationalError: connection failed: connection to server at "127.0.0.1", port 5432 failed: could not receive data from server: Connection refused
...
ERROR tests/test_schema.py::test_the_core_tables_exist - psycopg.OperationalE...
ERROR tests/test_schema.py::test_a_chunk_cannot_point_at_another_shops_document
ERROR tests/test_schema.py::test_the_same_document_key_may_exist_in_two_shops
ERROR tests/test_schema.py::test_states_and_access_levels_are_checked[status-done]
ERROR tests/test_schema.py::test_states_and_access_levels_are_checked[access-secret]
18 passed, 5 errors in 0.23s

Cause: .env sets TEST_DATABASE_URL, so the tests try to connect, and no PostgreSQL is listening. Docker Desktop is closed, or the container is stopped.

Fix: start Docker Desktop, then docker compose up -d --wait db, and run the tests again. To run only the tests without a database, remove TEST_DATABASE_URL from .env: the five tests are then skipped, with a reason.

Common mistake

An applied migration is edited

Symptom: Tomás adds a line to 001_core.sql after it was applied. --status and the runner both show it, and the runner applies nothing (exit code 1):

001_core CHANGED after it was applied
Stopped: 001_core changed after it was applied. Write a new migration.

Cause: your database already has the old 001_core. Changing the file does not change the database. The runner compares the SHA-256 of each applied file with its record, as in the databases course, and stops at the first difference.

Fix: restore the file (git checkout migrations/001_core.sql). A change to an applied schema is a new file: 002_...sql. The contract in architecture.md lists the migrations in order, so a reviewer sees each change.

On both tracks, write docs/design-pack/architecture.md for your product, from docs/templates/architecture.md. On the standard track, start from Sam’s, and change what your product does differently.

  1. Draw your parts. Start from the Mermaid block in Sam’s file, and remove each part that no acceptance criterion needs. For each part, write its one job and the one thing it must never do.
  2. Write the contract table: one row per endpoint, with who may call it, the success response and every error code.
  3. Write the data model. Mark the column that says which tenant each row belongs to, and every key that must include it.
  4. Draw the ingestion states. For each state, write what moves a document out of it, and what the person sees.
  5. List the retrieval metadata, and the filter that uses each field.
  6. Write migrations/001_core.sql from your data model. Apply it, and run python -m doc_assistant.migrate --status.
  7. Ask a reviewer, or reread it the next day, against your acceptance criteria. Remove one part or one field that no criterion needs.

Evidence to keep: architecture.md, the output of python -m doc_assistant.migrate --status, and the test output.

Checklist

  • Every part has one job and one “must never”.
  • Every endpoint has its callers, its success response and its errors, in one error shape.
  • Every table with tenant data has a tenant column, and every key between such tables includes it.
  • The ingestion states have no dead end: every document ends indexed, failed or deleted.
  • The search filters by tenant, access, date and model before it takes the top results.
  • I removed at least one part or field that no acceptance criterion needs, and wrote down why.

Change one condition. Ines asks for a new feature: a member can download the original file of a document, to check the full text. Write the contract row for it. Then answer: which table holds the bytes, which key finds them, and what does a read-only member get for a staff-only document?

Show a possible answer
Method and path Who may call it Success Errors
GET /v1/tenants/{t}/documents/{id}/file every member, at their access level 200 with the file’s bytes and its content_type; the file name in a Content-Disposition header 401, 404

The bytes are in document_files, found by (tenant_id, document_id): the shop is part of the key, so a Larkfield path cannot reach a Bramble file. A read-only member who asks for a staff-only document gets 404, the same body as for a document that does not exist. A 403 would tell them that the document exists. A failed document has its file too, so the person can see what they uploaded.

Check your understanding

Why does an upload answer 202 Accepted instead of 200 OK, and what must the page do because of it?

Show the answer

202 means “saved, not finished”: the API keeps the file and creates the document row, and the worker indexes it later. Indexing can take seconds, and it can fail. So the page must ask for the document’s status with GET /v1/tenants/{t}/documents/{id}. It shows “indexing” while the status is uploaded or parsing. Then it shows the number of passages, or the reason for failed. A 200 would make the page claim success too early.

Check your understanding

Predict: Ines uploads Bramble's returns policy version 1 a second time, as a new file with a small change. The schema has UNIQUE (tenant_id, doc_key, version). What happens, and what should she do instead?

Show the answer

The database refuses the second row with the same shop, key and version, and the API answers 409 conflict. That is the right result: two different texts must not both be “version 1”. She should upload the changed text as a new version, with its own effective_from. Larkfield can still have its own returns-policy, because the shop is part of the unique key.

Check your understanding

Practical check: show that your schema refuses a chunk that points at another tenant's document. Which constraint does it, and which test proves it?

Show the answer

The foreign key (tenant_id, document_id) REFERENCES documents (tenant_id, document_id) does it: a chunk with tenant_id = 'bramble' and a Larkfield document’s ID has no matching row. In Sam’s project, tests/test_schema.py::test_a_chunk_cannot_point_at_another_shops_document inserts one and expects ForeignKeyViolation. Your answer should name your own constraint and test, and show the test passing.

Recap

  • A contract is a written agreement at a boundary: what is sent, what comes back, which errors, and who may ask. Write it before the code.
  • Sam has four: the API contract, the database schema, the ingestion states and the retrieval metadata, all in docs/design-pack/architecture.md.
  • The shop is in every key: chunk IDs, primary keys and foreign keys. The database refuses a chunk that points at another shop’s document.
  • An upload answers 202. The documents row is the job: uploaded, parsing, then indexed or failed with a reason.
  • The search filters by shop, access, date and embedding model before the top 5.
  • One process and one database are enough for two small shops. Each extra part needs a criterion that asks for it.
  • python -m doc_assistant.migrate applies 001_core. The tests print 23 passed.

Next, you look at the same design as an attacker would, and you set a budget.

Next: Threat and cost review