As software systems grow in complexity, organizing and accessing data effectively becomes paramount. Traditional Repository patterns, which abstract data access logic, are often sufficient but can become cumbersome when dealing with complex query logic. Enter the Query Object pattern, a design approach that separates query logic from repositories to enhance flexibility, readability, and maintainability.
In this blog post, we'll explore the concept of Query Objects, contrast them with repositories, and implement examples using Node.js and TypeScript.
What Are Query Objects?
Query Objects encapsulate database query logic into dedicated classes or functions. Instead of cluttering repositories with various methods to handle different queries, each Query Object is responsible for one specific query. This separation simplifies code, improves testability, and promotes single-responsibility principles.
Key Features of Query Objects
1️⃣ Single Responsibility:
Focus on a single query or data-fetching operation.
2️⃣ Reusability:
Queries can be reused across different services or modules.
3️⃣ Testability:
Easier to isolate and test queries independently.
4️⃣ Readability:
Keeps repositories clean and query logic focused.
Why Replace Repositories with Query Objects?
Repositories typically combine multiple responsibilities, including:
✓ CRUD operations.
✓ Complex filtering and sorting logic.
✓ Data transformation.
This can lead to bloated classes that are harder to maintain and extend. Query Objects provide a cleaner alternative by decoupling query logic and delegating it to specialized units.
Implementing Query Objects in Node.js with TypeScript
Scenario
We are building a blogging platform and need to implement queries for fetching published articles and articles by author.
Traditional Repository Example
// articleRepository.ts
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export class ArticleRepository {
async findPublished(): Promise<Article[]> {
return prisma.article.findMany({
where: { published: true },
});
}
async findByAuthor(authorId: string): Promise<Article[]> {
return prisma.article.findMany({
where: { authorId },
});
}
}
While this works, the repository can quickly become bloated with additional methods for other queries.
Refactored with Query Objects
Query Object for Published Articles
// queries/findPublishedArticles.ts
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export class FindPublishedArticles {
async execute(): Promise<Article[]> {
return prisma.article.findMany({
where: { published: true },
});
}
}
Query Object for Articles by Author
// queries/findArticlesByAuthor.ts
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export class FindArticlesByAuthor {
constructor(private authorId: string) {}
async execute(): Promise<Article[]> {
return prisma.article.findMany({
where: { authorId: this.authorId },
});
}
}
Using Query Objects
import { FindPublishedArticles } from './queries/findPublishedArticles';
import { FindArticlesByAuthor } from './queries/findArticlesByAuthor';
async function main() {
const findPublishedArticles = new FindPublishedArticles();
const publishedArticles = await findPublishedArticles.execute();
console.log('Published Articles:', publishedArticles);
const findArticlesByAuthor = new FindArticlesByAuthor('author-id-123');
const authorArticles = await findArticlesByAuthor.execute();
console.log('Author Articles:', authorArticles);
}
main();
Benefits of Query Objects
Benefit | Description |
---|---|
Single Responsibility | Focuses solely on query logic, ensuring each class has a clear and specific purpose. |
Code Reusability | Query Objects are modular and can be easily reused across different parts of the application. |
Scalability | Keeps the codebase manageable by preventing bloated repositories as queries increase. |
Testing | Simplifies unit testing by isolating query logic into independent, testable units. |
Maintainability | Improves readability and organization by separating concerns in data access logic. |
When to Use Query Objects
Query Objects are ideal when:
- Your application has complex or evolving query requirements.
- You need to separate concerns for better maintainability.
- You want highly testable query logic.
For simple CRUD operations, repositories may still be sufficient. However, as query complexity grows, adopting Query Objects can significantly improve your codebase.
Conclusion
The Query Object pattern offers a modern, modular approach to managing complex data-fetching logic. By encapsulating each query into its own dedicated class, you can achieve cleaner, more maintainable code while adhering to the single-responsibility principle.
Start using Query Objects in your Node.js and TypeScript projects to simplify query logic and unlock better scalability for your applications.
Top comments (1)
When I see the examples in this post the query objects are single method classes. If you really want this why not use functions instead? I don't see the benefit of the class.
If I go over the benefits;
Isn't that what a repository does? When there is other logic, isn't that a problem higher up the chain?
I'm going to repeat myself, repository?
When the repository had too many methods, most of the time it is a sign that the repository is not well defined. For example user instead of authentication, cart, notifications, or anything else the application needs.
With the right framing of the repositories you solve this problem
I guess it is in a family together with the Single Action Controller pattern.
At what point in the development will you start writing functions again?