Repositories, Services & Testing
Learning Objectives
- You can separate HTTP, use-case, and persistence responsibilities.
- You can substitute a repository to test service behavior without PostgreSQL.
- You can write tests that detect incorrect service decisions.
- You can explain what service tests establish and what requires database integration tests.
- You can translate service outcomes into HTTP responses.
A route becomes difficult to change when it handles HTTP, enforces business rules, and embeds SQL in one block. We will separate these responsibilities for the practice application’s room-suitability check.
Three responsibilities
Routes translate HTTP requests and responses. Services implement use cases and business rules. Repositories execute queries and return stored data; PostgreSQL enforces database constraints. We will test the service by supplying a controlled repository, then connect it to an HTTP handler.
Figure 1 follows a room-suitability request through those responsibilities. The arrows show calls toward persistence; the returned room or error travels back to the handler, which chooses the HTTP response.
The service should not need a Hono context to decide whether a room is suitable. The repository should not decide whether the UI should show a red banner or which HTTP status to return.
Put each responsibility in a predictable place
This chapter adds a service and a suitability route to the existing practice application. The room-related files will be:
api/src/├── app.ts├── app-run.ts├── database.ts├── routes/│ ├── room-routes.ts│ ├── room-proposal-routes.ts│ └── room-suitability-routes.ts├── services/│ └── room-service.ts├── repositories/│ └── room-repository.ts└── schemas/ ├── id-schema.ts └── room-schema.tsPlace the new business rule in services/room-service.ts and its HTTP handler in
routes/room-suitability-routes.ts. Reuse the repository and schemas from the
previous chapters. app.ts connects the routes; app-run.ts starts the listener.
Schema modules describe values without importing Hono or opening a database
connection.
A simple read could still call a repository directly; add a service when there is a use-case decision to express. These folders help us find those responsibilities.
Define the smallest useful repository contract
Create api/src/services/room-service.ts:
import type { Room } from "@src/repositories/room-repository.ts";
export type RoomRepository = { findById: (id: number) => Promise<Room | null>;};
export class RoomRuleError extends Error { code: "ROOM_NOT_FOUND" | "TOO_SMALL";
constructor(code: RoomRuleError["code"]) { super(code); this.code = code; }}
export const requireRoomForGroup = async ( repository: RoomRepository, id: number, groupSize: number,): Promise<Room> => { const room = await repository.findById(id); if (!room) { throw new RoomRuleError("ROOM_NOT_FOUND"); }
if (room.capacity < groupSize) { throw new RoomRuleError("TOO_SMALL"); }
return room;};The service receives the repository from its caller. This is dependency injection. When the repository is injected, a test can provide a controlled answer through that argument; the production caller supplies the actual repository module.
The Room import is type-only, so importing the service does not initialize the
database client. The production handler will supply the repository module that
implements findById from Chapter 3.4.
Callers validate the numeric inputs before entering this service. The service
checks the requested group against stored room data and reports known failures
using RoomRuleError. The HTTP handler will translate those error codes into
responses.
Separate eligibility from persistence
0 / 20 points
Files in the editor
The editor below contains all 2 files for this exercise, although only one
opens initially. Use the Files panel to expand api/src/ and its folders,
then select a filename to open it. If the panel is collapsed, choose
Show file tree in the editor toolbar.
Edit:
api/src/services/equipment-service.ts
These supporting files are also in the editor. You may read them; keep their supplied implementations unchanged:
api/src/schemas/equipment-schema.ts
Task
Complete api/src/services/equipment-service.ts. The quantity schema is supplied; use its safeParse result for the input check. A repository offers
findByCode(code): Promise<Equipment | null>. Each equipment record contains
code, name, nonnegative integer stock, and Boolean maintenance.
Requirements
requireAvailable(repository, code, quantity) is an exported function. It returns the selected equipment when usable;
it returns a Promise resolving to that record and does not reserve, decrement, or save anything. Codes reaching the service
have already been validated by a caller.
Apply checks in this order:
- Quantity must be an integer from 1 to 1000; otherwise throw
EligibilityError('INVALID_QUANTITY')without querying the repository. - An absent record gives
NOT_FOUND. - Equipment undergoing maintenance gives
MAINTENANCE, even when stock is also insufficient. - Insufficient stock gives
INSUFFICIENT_STOCK; exact available stock succeeds.
Export EligibilityError with its stable code. Let unexpected repository
failures propagate rather than disguising them as absent records. Do not import
Hono, open a pool, or modify the returned equipment object.
Check and submit
Choose Submit solution below the editor to run the grading checks and read their feedback. All 2 editor files are submitted together, including supporting files and files that are not open as tabs. This editor has no Run control.
The schema imports z from zod;
@src/ resolves to api/src/ in the grader. Five checks award 4 points each:
quantity precondition, missing/maintenance decisions, exact stock boundary,
selected-record identity, and propagation of unexpected errors. The tests use
fakes; they do not establish SQL behavior or reservation safety.
Test the service with a controlled repository
Create api/tests/room-service_test.ts:
import { assertEquals, assertRejects } from "@std/assert";import { requireRoomForGroup, RoomRuleError } from "@src/services/room-service.ts";import type { RoomRepository } from "@src/services/room-service.ts";
Deno.test("group size is checked against the selected room", async () => { const repository: RoomRepository = { async findById(id) { return id === 7 ? { id: 7, name: "Reading room", capacity: 6 } : null; }, };
const room = await requireRoomForGroup(repository, 7, 6); assertEquals(room.name, "Reading room");
const tooSmall = await assertRejects( () => requireRoomForGroup(repository, 7, 7), RoomRuleError, ); assertEquals(tooSmall.code, "TOO_SMALL");
const missing = await assertRejects( () => requireRoomForGroup(repository, 99, 2), RoomRuleError, ); assertEquals(missing.code, "ROOM_NOT_FOUND");});Run the small service test from practice/:
docker compose run --build --rm --no-deps api deno test --cached-only tests/room-service_test.tsThe controlled repository returns a known room for ID 7 and null for any other
ID. The test checks a group that fits, a group that is too large, and a missing
room. Create mutable test repositories inside each test so changes in one test
do not affect another test’s starting state.
This test runs without PostgreSQL. It establishes the service’s decisions, but cannot verify SQL queries or database constraints. Those checks require integration tests against a real database.
Add the missing persistence evidence
0 / 5 points
Fake-repository tests establish missing-item and maintenance decisions. A production query accidentally filters the wrong column. Which additional test can detect that mistake?
Check the service with your own test
A regression test should accept a correct implementation and reject a specific mistake. Choose inputs that distinguish the expected behavior from the defect, and assert the relevant result or error. A test that merely calls the service cannot establish that its decision was correct.
Write tests that catch a rule regression
0 / 20 points
Files in the editor
Edit eligibility_test.ts in the editor below.
This exercise provides one editable file.
The imported ./equipment-service.ts is provided by the grader and does not
appear in the editor. Keep that import: write tests against the contract below,
without adding a service implementation to your submission.
Task
Write direct Deno tests with fake repositories, not a database or a Hono app.
Supply each fake through requireAvailable(repository, code, quantity). Keep
any mutable fake state local to its test; importing the service does not reset
shared records. The tested boundary is the
service’s use of its repository, so module replacement is unnecessary.
Service contract supplied by the grader
type Equipment = { code: string; name: string; stock: number; maintenance: boolean };type EquipmentRepository = { findByCode(code: string): Promise<Equipment | null> };// requireAvailable(repository, code, quantity): Promise<Equipment>requireAvailable checks quantity first: only integers 1-1000 are accepted;
otherwise reject with EligibilityError whose code is INVALID_QUANTITY,
without querying. Query findByCode(code). Null gives NOT_FOUND; maintenance
gives MAINTENANCE before checking stock; stock below quantity gives
INSUFFICIENT_STOCK. Exact stock succeeds. Success returns the selected record
without changing it. An unexpected repository rejection propagates the original
error object. Codes passed to this function are already valid. EligibilityError
extends Error and exposes code; construct it with new EligibilityError(code).
A fake may be as small as { findByCode: async () => record }. Make a fresh
record/fake inside each test. Use assertions from @std/assert; the grader supplies
that import and the service module, so neither is part of your submission.
Requirements
Your tests must pass for a correct implementation and fail for each defect:
maintenance ignored; exact stock incorrectly rejected; a repository error
incorrectly converted to NOT_FOUND. Include the successful exact-stock case
as well as rejections. Await asynchronous checks. Do not read source files,
inspect the test environment, skip tests, or start an external process.
The reference implementation meets the contract above. Each faulty version changes one rule while retaining the same exports. Decide which observations would distinguish those versions; do not simply assert that code executes.
Check and submit
Choose Submit solution below the editor to run the grading checks and read
their feedback. The editor submits eligibility_test.ts.
This editor has no Run control.
Passing against the reference earns 5 points;
rejecting each of the three faulty versions earns another 5. Mutant credit is
conditional on the reference passing. A suite that fails everywhere is not a
successful regression suite.
Defects must be detected through assertion failures. For an operation that
should succeed, turn an unexpected rejection into an assertion failure, for
example with fail from @std/assert. Syntax or import errors, unrelated
exceptions, timeouts, and skipped tests do not earn defect-detection credit.
Connect the service to a handler
The handler maps ROOM_NOT_FOUND to 404 and TOO_SMALL to 409. Validation
middleware rejects invalid request values with 400. The service does not need to
know these HTTP choices or receive a Hono context.
The repository module and its database pool are shared across requests. Each request supplies its own parsed room ID and group size, and the service queries the current room data. The handler does not create or close a pool per request.
Move the misplaced decision
0 / 5 points
A service returns c.json({error: {code: "MAINTENANCE"}}, 409) when equipment is unavailable. A background operation needs the same eligibility rule without HTTP. Which refactor preserves the responsibility of each layer?
Create api/src/routes/room-suitability-routes.ts. It reuses the schema and validation pattern from Chapter 3.3. The group size comes from a query string;
the service receives its parsed number:
import { Hono } from "hono";import { sValidator } from "@hono/standard-validator";import { z } from "zod";import { IdParamsSchema } from "@src/schemas/id-schema.ts";import * as roomRepository from "@src/repositories/room-repository.ts";import { requireRoomForGroup, RoomRuleError } from "@src/services/room-service.ts";
const GroupSchema = z.object({ groupSize: z.coerce.number().int().min(1).max(500) });
export const app = new Hono();
app.get("/api/rooms/:id/suitability", sValidator("param", IdParamsSchema), sValidator("query", GroupSchema), async (c) => { const { id } = c.req.valid("param"); const { groupSize } = c.req.valid("query");
try { return c.json(await requireRoomForGroup(roomRepository, id, groupSize)); } catch (error) { if (error instanceof RoomRuleError) { return c.json({ error: { code: error.code } }, error.code === "ROOM_NOT_FOUND" ? 404 : 409); } throw error; } },);The default validator responds with 400 for invalid input. This new demonstration route uses its default validation body; it does not change the existing room routes’ error contract. The handler maps the two domain failures and leaves unexpected exceptions to Hono’s server-error handling.
In api/src/app.ts, add the import and mount alongside the existing routes:
import { app as roomSuitabilityApp } from "@src/routes/room-suitability-routes.ts";app.route("/", roomSuitabilityApp);With the practice API running and a room inserted through Chapter 3.4’s repository,
request /api/rooms/ID/suitability?groupSize=4, replacing ID with that room’s
actual ID. A group at capacity succeeds; a larger group receives 409. A missing
room receives 404. This operation reads lab_rooms and adds no table.
The fake tests establish the service decisions. Chapter 3.4’s integration test establishes that the real repository reads PostgreSQL.
Translate equipment eligibility into HTTP responses
0 / 10 points
Files in the editor
The editor contains four files, although only one opens initially. Use the
Files panel (or Show file tree) to expand api/src/ and open them.
Edit only the marked route callback in api/src/routes/equipment-routes.ts.
Keep its imports, exported app, route registration, validation middleware,
and app.onError handler unchanged.
These complete supporting files are visible in the editor; keep them unchanged:
api/src/services/equipment-service.ts:requireAvailableandEligibilityError.api/src/repositories/equipment-repository.ts: a small supplied repository.api/src/schemas/equipment-schema.ts: path and query schemas.
The grader supplies Hono, Zod, and @hono/standard-validator; @src/ maps to
api/src/. No earlier exercise solution or database setup is required.
Supplied route and service
GET /api/equipment/:code/availability?quantity=N checks availability without
reserving or changing anything. The supplied middleware accepts codes consisting
of two uppercase letters followed by two digits, and converts quantity into an
integer from 1 through 1000. Missing or invalid values receive 400 before the
handler runs. Do not implement these checks again.
Read the parsed values with c.req.valid("param") and c.req.valid("query").
Then call await requireAvailable(equipmentRepository, code, quantity) exactly
once, using the imported repository object and the numeric parsed quantity.
The completed service returns an equipment object with code, name, stock,
and maintenance, or throws EligibilityError with code NOT_FOUND,
MAINTENANCE, or INSUFFICIENT_STOCK. Its input values are already validated.
The service owns those decisions; the handler must not query the repository
directly, repeat the business rules, or change the returned equipment.
Complete the HTTP mapping
| Service outcome | HTTP response |
|---|---|
| Equipment returned | 200 with {equipment: <returned object>} |
EligibilityError("NOT_FOUND") | 404 with {error: {code: "NOT_FOUND"}} |
EligibilityError("MAINTENANCE") | 409 with {error: {code: "MAINTENANCE"}} |
EligibilityError("INSUFFICIENT_STOCK") | 409 with {error: {code: "INSUFFICIENT_STOCK"}} |
Use instanceof EligibilityError to identify known failures. Rethrow every
other error unchanged. The supplied app.onError handler returns 500 with
{error: {code: "INTERNAL_ERROR"}} for those failures. An ordinary Error
can also have a code property; that alone does not make it an eligibility
failure. Keep exception messages and other details out of responses.
For example, requesting /api/equipment/AA01/availability?quantity=2 from the
supplied repository succeeds with {equipment:{code:"AA01",name:"Camera",stock:3,maintenance:false}}.
The grader also controls service outcomes and uses other codes and quantities;
translate the supplied outcome instead of relying on these sample records.
Check and submit
Choose Submit solution to run the checks and read their feedback. All four editor files are submitted together, including supporting files that are not open. There is no separate Run control.
Delegation and the successful response earn 3 points; the three known-error mappings earn 3; forwarding unexpected failures earns 2; and preserving the supplied validation earns 2. Each group first checks successful delegation. The checks observe calls to the service, their arguments, and HTTP responses; duplicating the service inside the handler is not a solution.
Check Your Understanding
- A database outage becomes a 404 response. Which boundary is hiding the failure, and how would you test it?
- A service accepts a fake repository. What additional evidence is needed before trusting its production queries?
- How would you test that an invalid quantity is rejected before persistence is queried?