Appearance
Query Objects and Resource Classes
When to use
Use these patterns when building a data access layer in Node.js. Query objects namespace related SQL queries and Resource classes encapsulate CRUD operations behind static async methods. Together they provide a clean, discoverable API for database operations without requiring instantiation.
The pattern
The pattern has two layers:
- Query object — a plain object that maps operation names to SQL strings loaded from
.sqlfiles. - Resource class — a class with static async methods that execute queries, validate input, and transform results.
Consuming a Resource
Callers interact with the Resource class through static async methods. Each method name corresponds to a CRUD operation:
javascript
import AccountResource from "./models/account/index.js";
// List all accounts
app.get("/accounts", async (req, res) => {
const accounts = await AccountResource.list();
res.json(accounts);
});
// Get one account
app.get("/accounts/:id", async (req, res) => {
const account = await AccountResource.get(
req.params.id
);
res.json(account);
});The calling code knows nothing about SQL files or query objects — it sees only the Resource class and its methods.
Defining a query object
Group all SQL queries for a model into a single object. Each property holds the SQL string for one operation:
javascript
import { file } from "@chriscalo/file";
const AccountQuery = {
list: file("./queries/account-list.sql"),
create: file("./queries/account-create.sql"),
get: file("./queries/account-get.sql"),
update: file("./queries/account-update.sql"),
delete: file("./queries/account-delete.sql"),
};The query object acts as a namespace — autocomplete shows every available operation for the model, and IDE "find all references" works on each property.
Defining a Resource class
Wrap query execution in static methods on a class named after the model. Each method handles input validation, query execution, and result transformation:
javascript
class AccountResource {
static async list() {
const rows = await query(AccountQuery.list);
return rows.map(toRecord);
}
static async create(input) {
const { value } = await InputSchema.assert(input);
return await query(AccountQuery.create, {
...value,
include: true,
});
}
static async get(id) {
const [row] = await query(AccountQuery.get, { id });
return toRecord(row);
}
static async update(id, input) {
const { value } = await InputSchema.assert(input);
return await query(AccountQuery.update, {
...value,
id,
});
}
static async delete(id) {
if (!id) {
throw new TypeError("ID required.");
}
return await query(AccountQuery.delete, { id });
}
}
export default AccountResource;Notice that every method is static — there is no instance state. The class serves purely as a namespace with a predictable API surface.
Defining result transformation
Keep a private toRecord() function alongside the class to convert raw database rows into the shape your application expects:
javascript
function toRecord(row) {
const include = Boolean(row.include);
return { ...row, include };
}This keeps transformation logic centralized and out of the Resource methods themselves.
How the layers connect
The four layers stack cleanly — each one depends only on the layer below it, and callers interact only with the top:
┌───────────────────────────────────────┐
│ Route handler / Controller │
│ await AccountResource.list() │
├───────────────────────────────────────┤
│ AccountResource (static methods) │
│ validates, queries, transforms │
├───────────────────────────────────────┤
│ AccountQuery (query object) │
│ maps operation names to SQL strings │
├───────────────────────────────────────┤
│ .sql files │
│ one file per operation │
└───────────────────────────────────────┘Callers interact only with the Resource class. The query object and SQL files are implementation details hidden inside the model module.
Defining a complete model module
A complete model module brings together the query object, Resource class, and result transformation in a single file:
javascript
import { file } from "@chriscalo/file";
import { query } from "./db.js";
const ExpenseQuery = {
list: file("./queries/expense-list.sql"),
create: file("./queries/expense-create.sql"),
get: file("./queries/expense-get.sql"),
update: file("./queries/expense-update.sql"),
delete: file("./queries/expense-delete.sql"),
};
class ExpenseResource {
static async list() {
const expenses = await query(ExpenseQuery.list);
return expenses.map(toRecord);
}
static async create(input) {
return await query(ExpenseQuery.create, {
...input,
include: true,
});
}
static async get(id) {
const [expense] = await query(
ExpenseQuery.get, { id }
);
return toRecord(expense);
}
static async update(id, input) {
return await query(ExpenseQuery.update, {
id,
...input,
});
}
static async delete(id) {
if (!id) {
throw new TypeError("ID required.");
}
return await query(ExpenseQuery.delete, { id });
}
}
export default ExpenseResource;
function toRecord(row) {
return { ...row, include: Boolean(row.include) };
}Trade-offs
- Clean API surface. Callers use
AccountResource.list()without knowing about SQL files or query objects. IDE autocomplete shows all operations. - No instantiation needed. Static methods mean no constructor, no dependency injection, no lifecycle to manage. This keeps things simple when operations are stateless.
- Harder to test in isolation. Static methods cannot be easily replaced with mocks. Testing requires either a real database, a database stub, or dependency injection at the module level (e.g., passing
queryas a parameter). - One class per model. Each model gets its own file and Resource class. This is clean for small to medium applications but can feel boilerplate-heavy at scale.
- Query object is an implementation detail. The query object exists to organize SQL strings, not as a public API. Keep it unexported unless other modules need raw query access.
- Compared to the Repository pattern. A Repository typically receives its database connection via constructor injection, making it easier to swap implementations or mock in tests. The Resource class pattern trades that flexibility for simplicity — no constructor, no wiring, no interfaces to satisfy.