Why Backends Need Layers: Growing a Q&A Bot into a Complete Agent Project
In the first two posts, we gave a Q&A bot an HTTP entry point and defined the shape of its requests and responses. The first post explored how a Python function becomes an API. The second followed incoming data through validation before it reached the model call. By the end of that post, /sessions/{session_id}/chat also checked whether the session existed and belonged to the current caller.
We could follow all that code in a single main.py. But once the bot remembers earlier messages and can look up study notes, the route also has to load history, build context, call tools, and save messages. Add quotas, logging, and failure handling, and chat() quickly becomes a function that needs to know about everything. That coupling makes further development painful.
In this post, I want to put the project’s main structure in place. We will keep the same conversation endpoint and give the bot a little more agent capability: the model can decide whether to retrieve local notes, the program executes the tool, its result goes back to the model, and the final answer stays in the conversation. Let’s see how to organize the code for a system like this clearly.
A Conversation Endpoint Has Several Problems to Handle
Suppose a client sends session 42 a question: “How do Router and Service divide their responsibilities?” The backend needs to identify the caller, check session ownership, read the history, pass the question to the agent, and save the question and answer. Once the answer comes back, the client can continue asking questions in the same session.
These parts change for different reasons. Changing /sessions/42/chat to another URL is an endpoint-path decision. Allowing only the owner to send messages is a business rule. Moving history from memory to a database changes the storage implementation. Adjusting a prompt or replacing a retrieval tool changes the agent’s execution logic. When they all sit in one function, any of those changes touches the same entry point.
As requirements grow, separating responsibilities into layers makes the code easier to maintain. Router handles HTTP, Service organizes a business operation, and Repository provides data access. The agent keeps its own execution loop. For this project, the structure looks like this:
flowchart TD
request["HTTP request"] --> fastapi["FastAPI
Match routes, resolve dependencies, validate parameters"]
fastapi --> router["Router
Pass parameters to Service"]
router --> service["AgentService
Check session, load history, run agent, save the turn"]
service -->|Read and save| repository["ConversationRepository
In-memory storage in this example"]
repository -.->|Session and history| service
service -->|Run| agent["KnowledgeAgent
Model calls and retrieval tool loop"]
agent -.->|Answer and usage| service
service -->|ChatResult| response["Router
Construct ChatResponse"]
response --> output["HTTP response"]
These layers still run in the same Python process and work together through ordinary function and method calls. Separating them gives us a clearer file structure.
The Complete Example Project
Our agent is a backend study assistant with three local notes and a search_notes tool. Retrieval uses keyword matching, while model calls use the real Responses API. The model ID comes from CHAT_MODEL. Whether to call the tool depends on the question and the instructions; the code also permits the model to answer directly.
The project has three endpoints: POST /sessions creates a conversation, POST /sessions/{session_id}/chat starts a turn, and GET /sessions/{session_id}/messages reads its history. We keep the demonstration setup from the previous post: a valid API key identifies demo-client, session 42 belongs to that caller, and session 43 belongs to another caller. This lets us create new sessions and also check the 403 and 404 paths directly.
There are only a few files, so we will use a flat set of modules grouped by responsibility. With one router and one business workflow, each file does not need another folder around it:
1 | backend-layered-agent/ |
Here is the complete application code; # app/xxx.py marks each file boundary, so save the sections as separate files.
1 | # requirements.txt(单独保存;app/__init__.py 是空文件) |
domain.py uses dataclasses for internal records: ChatResult, for example, carries a business result, while ChatResponse in schemas.py defines the HTTP response shape. A Protocol only declares the methods required; ... means there is no implementation at that point, and the Repository and Agent below perform the actual work.
Follow One Request Through the Code
First, send a message to session 42. The client uses the same four locations we saw in the previous post: the ID in the path, the generation limit in the query string, the key in the header, and question in the request body.
1 | POST /sessions/42/chat?max_output_tokens=512 |
FastAPI matches chat() in router.py, parses the path and query parameters, validates the body as a ChatRequest, and resolves the declared dependencies. identify_client() checks the key and returns demo-client. The Service used by the route was already passed to create_router() when the application was assembled. Once those entry-point steps are complete, it can handle the message.
The route then passes session_id, client_id, request.question, and max_output_tokens to service.chat(). The Service finds the session, checks ownership, and calls the agent with the current history. The agent can make several model requests, execute the retrieval tool, and obtain a final answer. After success, the Service asks the Repository to save the complete turn and returns a ChatResult. The Router converts that internal result into a ChatResponse, and FastAPI sends JSON back to the client.
A successful response has the following illustrative shape:
1 | { |
Router Keeps the HTTP Contract
Looking back at router.py, the useful change is that chat() is short enough to read in one pass. It declares its parameters, calls the Service, and returns a ChatResponse. POST, the {session_id} path parameter, and the query parameter’s range of 16 to 1024 determine how clients use the endpoint, so they belong in the Router.
APIRouter also fits with the FastAPI() application object from the previous post. create_router(service) creates a Router, registers three endpoints, and returns it. app.include_router(...) in main.py registers them with the application. prefix="/sessions" supplies the shared path prefix.
Each endpoint needs to identify the caller, which is why its parameters include client_id: Client. Client is an alias for Annotated[str, Depends(identify_client)]: it tells FastAPI to identify the caller and pass that result to the parameter. create_session(), get_history(), and chat() are defined inside create_router(). Their closures retain a reference to the outer service, so they can still use the same object after registration.
When returning a result, asdict(result) converts the dataclass to a dictionary, and ** expands its fields into constructor arguments to create a ChatResponse.
The authentication dependency still raises the original 401 error. A missing session or an ownership failure becomes an internal exception raised by the Service. The Router catches it with try / except and uses to_http_error() to turn it into an HTTP error such as 404 or 403.
Service Organizes a Complete Business Operation
AgentService.chat() in service.py does four things in order: obtain a session the caller may access, run the agent, save the result, and return the business result. It does not receive a Request or need to know whether JSON fields came from a body or a query string. To the Service, client_id and question are already Python parameters.
Why put the ownership check here? “Only the owner may access their conversation” applies to chatting, reading history, and performing those operations through future entry points. get_session() checks both existence and ownership, and chatting and history lookup share it.
The Service also makes a choice that can easily hide in statement order: save the question and answer only after the agent succeeds. A model error, an empty answer, invalid tool arguments, or an exceeded execution limit therefore leaves no partial turn in history in this version. For our study assistant, this is a rule we can check directly.
Service complexity often comes from the relationships between steps. Later, quota checks need to happen before a model call, usage records after it, and the permitted knowledge-base scope depends on session access. Something has to organize those rules. This resembles an agent workflow, but here the concern is how the backend completes the business operation of sending a conversation message.
Repository Turns “Save a Turn” into Data Operations
The complete code has one InMemoryConversationRepository, which stores both session information and messages. This example treats a conversation as a whole and does not yet need to query individual messages, so the related operations stay together.
The Service uses create(), get(), and append_turn(). How the memory dictionary is organized, how IDs are allocated, and how conversation objects are replaced all stay inside the Repository. We deliberately give append_turn() a complete question-and-answer pair, making “save a turn” a single, explicit data operation. In this implementation, one assignment saves both messages together.
Conversation.messages is a tuple, and both Conversation and Message are frozen dataclasses. The value returned by get() is therefore a conversation snapshot that does not change with later saves. The Service can pass it to the agent as the history seen at the start of this run. When saving new messages, the Repository uses replace() to create a new conversation object. Exposing an internal mutable list would let other code change history without going through the Repository.
Later, with SQLAlchemy or another database implementation, the Service can continue expressing “find the conversation, run the agent, save a turn,” while query statements go into the new Repository. This is the central value of layering.
The Agent Has Its Own Execution Workflow
agent.py is the clearest difference between this project and an ordinary CRUD backend. KnowledgeAgent.run() receives the question, history, and generation limit, converts the history into model input, adds the current question, and enters a bounded loop. It does not check session ownership or write directly to conversation history.
Each model request has two possible outcomes. A text response gives us the final answer. A function_call causes the program to check the tool name and arguments, execute search_notes(), add its result to the input as a function_call_output, and request the model again. The model proposes the tool call; the Python function we wrote actually retrieves the notes.
The code preserves every item in response.output before adding tool results. Keeping only function names and arguments is insufficient. Some model outputs also contain reasoning items, which must be passed back too. The call_id matches a tool result to the call the model just proposed. With store=False, this run() passes the complete intermediate input itself rather than continuing server-side state through a previous response ID.
The tool schema describes the parameter shape, and the execution code still checks the tool name, argument type, and query length. This example allows at most four model requests. The first three permit tool calls; the fourth uses tool_choice="none" to request an answer. An incomplete result or an empty answer still counts as failure. Conversation history stores only user questions and final answers; tool calls and retrieval results remain within the current run(). The next turn retrieves again.
main.py Only Connects the Objects
The body of main.py, at the end of the complete code, has just six statements. It creates the model client, Repository, and Agent, passes the latter two to AgentService, then creates the FastAPI application and registers a Router that uses that Service.
AgentService(repository=repository, agent=agent) is an ordinary Python call. To the left of each equals sign is a constructor parameter name; to the right is an object created earlier. The constructor stores them as self._repository and self._agent. We supply the capabilities the Service needs from outside rather than creating another repository or agent inside it.
app.include_router(create_router(service)) contains an inner and an outer call. Python first executes create_router(service) to obtain an APIRouter whose endpoints are already registered, then passes that object to app.include_router(...).
Here, main is a module name, with no additional main() function. Loading app.main:app causes Uvicorn to execute these module-level statements and find the created app. OpenAI() creates the client here; an actual model request waits until KnowledgeAgent.run(). FastAPI() creates the application object. Once Uvicorn starts, it listens on a port and passes incoming requests to the application.
New Requirements Determine How the Project Grows
Splitting one file into these modules does not mean the project is finished. It can already create conversations, run an agent with a tool, continue conversations, and read history, with a place for each part of the main structure. If users, knowledge bases, and quota management come next, we can extend responsibilities first and decide whether to reorganize directories as the number of modules grows.
| New requirement | Main place to extend | What else to consider |
|---|---|---|
| Save conversations and messages in a database | Repository and application assembly | Connection lifetimes, transactions, and version checks |
| Replace the model or add tools | Agent and tool implementations | Execution limits, tool arguments, and context |
| Define who can use each knowledge base | Service and retrieval capabilities | How permission scope reaches the tool |
| Check quotas per caller | Service | Concurrent quota reservations, failures, and actual usage |
| Add streaming output | Router and the Agent’s return interface | Final persistence and interrupted connections |
| Introduce a queue for long-running tasks | Submission endpoint, task state, and Worker | Recovery, retries, and repeated execution |
Changes can still span several modules after layering. Moving from “return the complete answer when ready” to streaming, for example, affects how the Agent produces chunks, how the Router transmits them, and when the Service saves a completed state. At least we can discuss the impact in terms of these responsibilities instead of treating everything as a local patch to chat().
As routes grow, expanding router.py into routers/chat.py and routers/sessions.py is natural. More data-access code can go into repositories/, with database models in models/. If users, chat, and billing develop their own sets of code, we can organize them by feature as features/chat/ and features/billing/, with Router, Service, and Repository inside each module. Directories grouped by technical layer or by business feature can both express the responsibilities established here.
We can now understand a complete project and explain why a piece of code belongs where it does. Router and schemas describe how clients send requests. Service organizes the business steps of sending a message. Repository reads and saves conversations. The Agent handles how the model uses a tool and obtains an answer. main.py connects these objects into a running application.
When AI coding tools can quickly generate these files, this understanding is valuable: we can see why a project is structured this way and how to adjust that structure as business requirements become more complex.
- Title: Why Backends Need Layers: Growing a Q&A Bot into a Complete Agent Project
- Author: Hyacehila
- Created at : 2026-10-01 04:00:00
- Link: https://hyacehila.github.io//blog/2026/10/01/router-service-repository-agent-backend-architecture/
- License: This work is licensed under CC BY-NC-SA 4.0.