File Structure for Collections
To promote modularity, maintainability, and ease of testing, we follow a specific pattern for organizing collections of related code items, such as API route handlers or database queries for a specific resource.
The Problem: Monolithic Files
Files containing numerous related but distinct functions or classes (e.g., a single call-series-routes.ts
containing handlers for GET, POST, PUT, DELETE, etc.) can become large and unwieldy. This leads to:
- Difficult Navigation: Finding specific logic requires scrolling through potentially hundreds of lines.
- Complex Code Reviews: Diffs become large and harder to review thoroughly.
- Increased Merge Conflicts: Multiple developers working on different aspects of the same resource are more likely to conflict.
- Testing Challenges: Setting up tests for one specific handler might require mocking or dealing with unrelated code in the same file.
The Solution: One Item Per File + Index
Instead of monolithic files, break down collections into individual files within a dedicated directory, and use an index.ts
file to aggregate and export them.
Pattern:
- Create a Directory: For a collection of items related to a specific concept (e.g., API routes for
CallSeries
, database queries forUser
), create a dedicated directory (e.g.,routes/call-series/
,queries/user/
). - One File Per Item: Place the code for each distinct item (e.g., a single route handler, a specific database query function) into its own file within that directory (e.g.,
get-by-id.ts
,create.ts
,update.ts
). Name the file clearly based on its responsibility. - Aggregate in
index.ts
: Create anindex.ts
file within the directory. Import the items from their individual files and export them, often as a structured object or namespace, for consumers.
Example: API Routes (services/api/routes/call-series/
)
routes/
└── call-series/
├── create.ts # Handler for POST /call-series
├── delete.ts # Handler for DELETE /call-series/:id
├── get-all.ts # Handler for GET /call-series
├── get-by-id.ts # Handler for GET /call-series/:id
├── index.ts # Aggregates and exports handlers
└── update.ts # Handler for PUT /call-series/:id
└── create.test.ts # Tests for create.ts
└── ... # Other handlers and their tests
routes/call-series/index.ts
Example:
import { createHandler } from "./create.ts";
import { deleteHandler } from "./delete.ts";
import { getAllHandler } from "./get-all.ts";
import { getByIdHandler } from "./get-by-id.ts";
import { updateHandler } from "./update.ts";
// Export handlers, perhaps grouped
export const callSeriesHandlers = {
create: createHandler,
delete: deleteHandler,
getAll: getAllHandler,
getById: getByIdHandler,
update: updateHandler,
};
// Or configure an Express router here
import { Router } from "express";
const router = Router();
router.post("/", createHandler);
router.get("/", getAllHandler);
router.get("/:id", getByIdHandler);
router.put("/:id", updateHandler);
router.delete("/:id", deleteHandler);
export default router;
Example: Database Queries (dbs/main/src/queries/call-series/
)
queries/
└── call-series/
├── create.ts # Function to create a call series
├── delete.ts # Function to delete a call series
├── errors.ts # Custom errors specific to call series queries
├── get.ts # Function to get a call series by ID
├── index.ts # Aggregates and exports query functions
├── list.ts # Function to list call series
├── update.ts # Function to update a call series
└── create.test.ts # Tests for create.ts
└── ... # Other query functions and their tests
queries/call-series/index.ts
Example:
import type { DbType } from "../types"; // Assuming DbType is defined
import { createCallSeriesCreateQuery } from "./create";
import { createCallSeriesDeleteQuery } from "./delete";
import { createCallSeriesGetQuery } from "./get";
import { createCallSeriesListQuery } from "./list";
import { createCallSeriesUpdateQuery } from "./update";
// Export a factory function that takes the DB instance
// and returns an object with all query functions for this domain.
export function createCallSeriesQueries(db: DbType) {
return {
create: createCallSeriesCreateQuery(db),
delete: createCallSeriesDeleteQuery(db),
get: createCallSeriesGetQuery(db),
list: createCallSeriesListQuery(db),
update: createCallSeriesUpdateQuery(db),
};
}
// Also re-export errors
export * from "./errors";
Benefits
- Improved Readability: Files are shorter and focused on a single responsibility.
- Easier Navigation: File names clearly indicate their content.
- Simplified Reviews: Changesets are smaller and more focused.
- Reduced Conflicts: Less chance of multiple developers editing the same lines.
- Focused Testing: Unit tests can target individual files easily.
Adopting this pattern leads to a more organized, maintainable, and scalable codebase.