refactor: query handlers now don't use repositories and retrieve models directly
This commit is contained in:
@@ -274,15 +274,15 @@ Read more:
|
||||
|
||||
### Queries
|
||||
|
||||
`Query` is similar to a `Command`. It signals user intent to find something and describes how to do it.
|
||||
`Query` is similar to a `Command`. It belongs to a read model and signals user intent to find something and describes how to do it.
|
||||
|
||||
`Query` is just a data retrieval operation and should not make any state changes (like writes to the database, files, third party APIs, etc.).
|
||||
`Query` is just a data retrieval operation and should not make any state changes (like writes to the database, files, third party APIs, etc.). For this reason, in read model we can bypass a domain and repository layers completely and query database directly from a query handler.
|
||||
|
||||
Similarly to Commands, Queries can use a `Query Bus` if needed. This way you can query anything from anywhere without importing repositories directly and avoid coupling.
|
||||
Similarly to Commands, Queries can use a `Query Bus` if needed. This way you can query anything from anywhere without importing classes directly and avoid coupling.
|
||||
|
||||
Example files:
|
||||
|
||||
- [find-users.query-handler.ts](src/modules/user/queries/find-users/find-users.query-handler.ts) - a query handler
|
||||
- [find-users.query-handler.ts](src/modules/user/queries/find-users/find-users.query-handler.ts) - a query handler. Notice how we query the database directly, without using domain objects or repositories (more info [here](https://codeopinion.com/should-you-use-the-repository-pattern-with-cqrs-yes-and-no/)).
|
||||
|
||||
---
|
||||
|
||||
@@ -1006,6 +1006,7 @@ This project contains abstract repository class that allows to make basic CRUD o
|
||||
Read more:
|
||||
|
||||
- [Design the infrastructure persistence layer](https://docs.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/infrastructure-persistence-layer-design)
|
||||
- [Should you use the Repository Pattern? With CQRS, Yes and No!](https://codeopinion.com/should-you-use-the-repository-pattern-with-cqrs-yes-and-no/) - in a read model / query handlers it is not required to use a repository pattern.
|
||||
|
||||
## Persistence models
|
||||
|
||||
|
||||
@@ -18,5 +18,5 @@ export abstract class PaginatedResponseDto<T> extends Paginated<T> {
|
||||
readonly page: number;
|
||||
|
||||
@ApiProperty({ isArray: true })
|
||||
abstract data: T[];
|
||||
abstract readonly data: readonly T[];
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export class Paginated<T> {
|
||||
readonly count: number;
|
||||
readonly limit: number;
|
||||
readonly page: number;
|
||||
readonly data: T[];
|
||||
readonly data: readonly T[];
|
||||
|
||||
constructor(props: Paginated<T>) {
|
||||
this.count = props.count;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Paginated, PaginatedQueryParams, RepositoryPort } from '@libs/ddd';
|
||||
import { PaginatedQueryParams, RepositoryPort } from '@libs/ddd';
|
||||
import { UserEntity } from '../domain/user.entity';
|
||||
|
||||
export interface FindUsersParams extends PaginatedQueryParams {
|
||||
@@ -9,5 +9,4 @@ export interface FindUsersParams extends PaginatedQueryParams {
|
||||
|
||||
export interface UserRepositoryPort extends RepositoryPort<UserEntity> {
|
||||
findOneByEmail(email: string): Promise<UserEntity | null>;
|
||||
findUsers(query: FindUsersParams): Promise<Paginated<UserEntity>>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { InjectPool } from 'nestjs-slonik';
|
||||
import { DatabasePool, sql } from 'slonik';
|
||||
import { FindUsersParams, UserRepositoryPort } from './user.repository.port';
|
||||
import { UserRepositoryPort } from './user.repository.port';
|
||||
import { z } from 'zod';
|
||||
import { UserMapper } from '../user.mapper';
|
||||
import { UserRoles } from '../domain/user.types';
|
||||
@@ -8,7 +8,6 @@ import { UserEntity } from '../domain/user.entity';
|
||||
import { SqlRepositoryBase } from '@src/libs/db/sql-repository.base';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { Paginated } from '@src/libs/ddd';
|
||||
|
||||
/**
|
||||
* Runtime validation of user object for extra safety (in case database schema changes).
|
||||
@@ -28,6 +27,9 @@ export const userSchema = z.object({
|
||||
|
||||
export type UserModel = z.TypeOf<typeof userSchema>;
|
||||
|
||||
/**
|
||||
* Repository is used for retrieving/saving domain entities
|
||||
* */
|
||||
@Injectable()
|
||||
export class UserRepository
|
||||
extends SqlRepositoryBase<UserEntity, UserModel>
|
||||
@@ -46,31 +48,6 @@ export class UserRepository
|
||||
super(pool, mapper, eventEmitter, new Logger(UserRepository.name));
|
||||
}
|
||||
|
||||
async findUsers(query: FindUsersParams): Promise<Paginated<UserEntity>> {
|
||||
/**
|
||||
* Constructing a query with Slonik.
|
||||
* More info: https://contra.com/p/AqZWWoUB-writing-composable-sql-using-java-script
|
||||
*/
|
||||
const statement = sql.type(userSchema)`
|
||||
SELECT *
|
||||
FROM users
|
||||
WHERE
|
||||
${query.country ? sql`country = ${query.country}` : true} AND
|
||||
${query.street ? sql`street = ${query.street}` : true} AND
|
||||
${query.postalCode ? sql`"postalCode" = ${query.postalCode}` : true}
|
||||
LIMIT ${query.limit}
|
||||
OFFSET ${query.offset}`;
|
||||
|
||||
const records = await this.pool.query(statement);
|
||||
|
||||
return new Paginated({
|
||||
data: records.rows.map(this.mapper.toDomain),
|
||||
count: records.rowCount,
|
||||
limit: query.limit,
|
||||
page: query.page,
|
||||
});
|
||||
}
|
||||
|
||||
async updateAddress(user: UserEntity): Promise<void> {
|
||||
const address = user.getPropsCopy().address;
|
||||
const statement = sql.type(userSchema)`
|
||||
|
||||
@@ -4,5 +4,5 @@ import { UserResponseDto } from './user.response.dto';
|
||||
|
||||
export class UserPaginatedResponseDto extends PaginatedResponseDto<UserResponseDto> {
|
||||
@ApiProperty({ type: UserResponseDto, isArray: true })
|
||||
data: UserResponseDto[];
|
||||
readonly data: readonly UserResponseDto[];
|
||||
}
|
||||
|
||||
@@ -4,12 +4,13 @@ import { QueryBus } from '@nestjs/cqrs';
|
||||
import { ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
import { Result } from 'oxide.ts';
|
||||
import { FindUsersRequestDto } from './find-users.request.dto';
|
||||
import { UserEntity } from '@modules/user/domain/user.entity';
|
||||
import { FindUsersQuery } from './find-users.query-handler';
|
||||
import { UserMapper } from '@modules/user/user.mapper';
|
||||
import { Paginated } from '@src/libs/ddd';
|
||||
import { UserPaginatedResponseDto } from '../../dtos/user.paginated.response.dto';
|
||||
import { PaginatedQueryRequestDto } from '@src/libs/api/paginated-query.request.dto';
|
||||
import { UserModel } from '../../database/user.repository';
|
||||
import { ResponseBase } from '@src/libs/api/response.base';
|
||||
|
||||
@Controller(routesV1.version)
|
||||
export class FindUsersHttpController {
|
||||
@@ -34,15 +35,22 @@ export class FindUsersHttpController {
|
||||
page: queryParams?.page,
|
||||
});
|
||||
const result: Result<
|
||||
Paginated<UserEntity>,
|
||||
Paginated<UserModel>,
|
||||
Error
|
||||
> = await this.queryBus.execute(query);
|
||||
|
||||
const users = result.unwrap();
|
||||
const paginated = result.unwrap();
|
||||
|
||||
// Whitelisting returned properties
|
||||
return new UserPaginatedResponseDto({
|
||||
...users,
|
||||
data: users.data.map(this.userMapper.toResponse),
|
||||
...paginated,
|
||||
data: paginated.data.map((user) => ({
|
||||
...new ResponseBase(user),
|
||||
email: user.email,
|
||||
country: user.country,
|
||||
street: user.street,
|
||||
postalCode: user.postalCode,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import { Ok, Result } from 'oxide.ts';
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { UserRepositoryPort } from '../../database/user.repository.port';
|
||||
import { USER_REPOSITORY } from '../../user.di-tokens';
|
||||
import { UserEntity } from '@modules/user/domain/user.entity';
|
||||
import { PaginatedParams, PaginatedQueryBase } from '@libs/ddd/query.base';
|
||||
import { Paginated } from '@src/libs/ddd';
|
||||
import { InjectPool } from 'nestjs-slonik';
|
||||
import { DatabasePool, sql } from 'slonik';
|
||||
import { UserModel, userSchema } from '../../database/user.repository';
|
||||
|
||||
export class FindUsersQuery extends PaginatedQueryBase {
|
||||
readonly country?: string;
|
||||
@@ -25,14 +24,42 @@ export class FindUsersQuery extends PaginatedQueryBase {
|
||||
@QueryHandler(FindUsersQuery)
|
||||
export class FindUsersQueryHandler implements IQueryHandler {
|
||||
constructor(
|
||||
@Inject(USER_REPOSITORY)
|
||||
private readonly userRepo: UserRepositoryPort,
|
||||
@InjectPool()
|
||||
private readonly pool: DatabasePool,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* In read model we don't need to execute
|
||||
* any business logic, so we can bypass
|
||||
* domain and repository layers completely
|
||||
* and execute query directly
|
||||
*/
|
||||
async execute(
|
||||
query: FindUsersQuery,
|
||||
): Promise<Result<Paginated<UserEntity>, Error>> {
|
||||
const users = await this.userRepo.findUsers(query);
|
||||
return Ok(users);
|
||||
): Promise<Result<Paginated<UserModel>, Error>> {
|
||||
/**
|
||||
* Constructing a query with Slonik.
|
||||
* More info: https://contra.com/p/AqZWWoUB-writing-composable-sql-using-java-script
|
||||
*/
|
||||
const statement = sql.type(userSchema)`
|
||||
SELECT *
|
||||
FROM users
|
||||
WHERE
|
||||
${query.country ? sql`country = ${query.country}` : true} AND
|
||||
${query.street ? sql`street = ${query.street}` : true} AND
|
||||
${query.postalCode ? sql`"postalCode" = ${query.postalCode}` : true}
|
||||
LIMIT ${query.limit}
|
||||
OFFSET ${query.offset}`;
|
||||
|
||||
const records = await this.pool.query(statement);
|
||||
|
||||
return Ok(
|
||||
new Paginated({
|
||||
data: records.rows,
|
||||
count: records.rowCount,
|
||||
limit: query.limit,
|
||||
page: query.page,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user