Development
Local development guide for the RawStack API component.
Running the API locally
The API is built primarily with NestJS and runs locally on Node.js. For local development, the application itself runs on your host machine, while PostgreSQL and Redis run in Docker containers via docker compose.
If you are new to the backend architecture or want a clearer picture of modules, providers, controllers, and dependency injection, the official NestJS documentation is well worth reading alongside this guide.
To get the API running locally, follow these steps:
cd apps/api
npm install
docker compose up -d
cp .env.dist .env
npm run devThe API starts on http://localhost:3001
Database
Prisma is used for database access, schema definition, and migrations. The Prisma schema is the source of truth for the database structure, and the generated Prisma client is used by the API to interact with PostgreSQL. See the Prisma documentation for more detail.
When the schema changes, create a new migration and apply it to your local database. In deployed environments, migrations should be applied as part of the release process to keep the database schema in sync with the application code.
# Generate a new migration from schema changes
npx prisma migrate dev --name <migration-name>
# Apply migrations (production)
npx prisma migrate deploy
# Open Prisma Studio (GUI)
npx prisma studioOpenAPI documentation
The API also includes an OpenAPI specification, which is used to generate the TypeScript client consumed by the frontend applications. When the API contract changes, regenerate the client and distribute the updated package to any applications that depend on it.
./scripts/generate-openapi-client.shRequest to Response Flow
To illustrate how the code is organised and how a request flows through the system, consider the example of updating a user. This use case touches both the command side and the query side of the application.
The endpoint
This action lives in the user domain, in the infrastructure layer, under user/infrastructure/controller/user.
@Controller('user/users')
export class UpdateUserAction {
constructor(
private commandBus: CommandBus,
private queryBus: QueryBus,
) {}
@UseGuards(JwtGuard, UserUserGuard({ action: 'UPDATE_USER' }))
@Patch(':userId')
async invoke(@Param('userId') id: string, @Body() requestDto: UpdateUserRequest): Promise<UserResponseDto> {
const command = UpdateUserCommand.fromRequest(id, requestDto);
await this.commandBus.execute(command);
const query = new GetUserQuery(id);
return await this.queryBus.execute(query);
}
}This action is effectively split into two parts:
- Command side (updates or writes)
- Query side (reads)
We’ll walk through each side in detail.
Command Side
- The controller receives the user’s request and creates a CQRS command (
UpdateUserCommand). - This command is executed via the command bus.
- The command bus looks for a matching command handler (
UpdateUserCommandHandler). If it does not find one, an error is thrown.
Command Handler
Command handlers are responsible for orchestrating domain services. They may coordinate services from different aggregates within the same domain, but they should never cross domain boundaries.
The command is handled by UpdateUserCommandHandler, which lives in the application layer under user/application/command/user:
@CommandHandler(UpdateUserCommand)
export class UpdateUserCommandHandler implements ICommandHandler<UpdateUserCommand> {
constructor(
private readonly service: UpdateUserService,
private readonly actorProvider: LoggedInUserProvider,
) {}
async execute(command: UpdateUserCommand): Promise<void> {
const { id, password, email, roles } = command;
const actor = this.actorProvider.getLoggedInUser();
if (!actor) {
throw new UnauthorizedException('Unauthorized');
}
await this.service.invoke(actor, new Id(id), email ? new Email(email) : undefined, password, roles);
}
}The command handler takes the command and coordinates the domain services needed to complete the use case.
Domain Service
Domain services contain the core business logic. They are injected into the command handler and encapsulate the behaviour of the system.
Take UpdateUserService as an example.
Because this service lives in the domain layer, it should not depend on NestJS or any other framework. Its dependencies should come from the domain itself, such as repositories, models, value objects, and other domain services.
export class UpdateUserService {
constructor(private repository: UserRepositoryInterface) {}
async invoke(actor: LoggedInUser, id: Id, email?: Email, password?: string, roles?: UserRoles[]): Promise<void> {
if (!actor.id.equals(id) && !actor.roles.includes(UserRoles.Admin)) {
throw new ForbiddenException(`actor ${actor.id} is not allowed to update user ${id.toString()}`);
}
if (!email && !password && !roles) {
throw new ValidationException('at least one value (email, password, roles) must be provided to update user');
}
const user = await this.repository.findById(id);
const dateNow = dayjs();
if (email && !email.equals(user.email)) {
const existing = await this.repository.existsByEmail(email);
if (existing) {
throw new ConflictException(`email ${email.toString()} already exists`);
}
user.setUnverifiedEmail(dateNow, email);
} else if (email && user.unverifiedEmail && email.equals(user.unverifiedEmail)) {
user.setUnverifiedEmail(dateNow, email);
}
if (roles) {
if (!actor.roles.includes(UserRoles.Admin)) {
throw new ForbiddenException('only admins can update users roles');
}
if (actor.id === user.id) {
throw new ForbiddenException('users cannot update there own roles');
}
user.updateRoles(dateNow, roles);
}
if (password) {
const hashedPassword = await argon.hash(password);
user.updatePassword(dateNow, hashedPassword);
}
await this.repository.persist(user);
}
}The only dependency here is UserRepositoryInterface.
Note that the service depends on the interface, not the implementation. This is a key part of dependency injection and makes it easier to swap implementations when needed.
The interface is defined in the user domain layer, while the implementation lives in the infrastructure layer.
Domain Model
This is the core domain object in the flow. The model represents the business entity and its behaviour, and it should encapsulate the rules and state transitions related to that entity.
UserModel extends DomainAbstractRoot, which provides the base functionality for recording domain events. It includes properties such as email, roles, and timestamps, along with methods that update state and apply domain events when changes occur.
export class UserModel extends DomainAbstractRoot {
protected _password?: string;
constructor(
protected readonly _internalId: number,
protected readonly _id: Id,
protected _email: Email,
protected _roles: Array<UserRoles>,
protected readonly _createdAt: Dayjs = dayjs(),
protected _updatedAt: Dayjs = dayjs(),
protected _unverifiedEmail: Email | null = null,
protected _deletedAt?: Dayjs,
) {
super();
}
public get id(): Id {
return this._id;
}
public get email(): Email {
return this._email;
}
public get roles(): Array<UserRoles> {
return this._roles;
}
public get createdAt(): Dayjs {
return this._createdAt;
}
public get internalId(): number {
return this._internalId;
}
public get updatedAt(): Dayjs {
return this._updatedAt;
}
public get unverifiedEmail(): Email | null {
return this._unverifiedEmail;
}
public get deletedAt(): Dayjs | undefined {
return this._deletedAt;
}
public get password(): string | undefined {
return this._password;
}
static create(createdAt: Dayjs, id: Id, email: Email, password: string, roles: UserRoles[] = []): UserModel {
const user = new UserModel(0, id, email, roles, createdAt);
user._unverifiedEmail = email;
user._password = password;
user._updatedAt = createdAt;
user.apply(new UserWasCreated(id.toString(), createdAt.toDate(), user.getDto(), email.toString(), roles));
return user;
}
setUnverifiedEmail(updatedAt: Dayjs, email: Email): UserModel {
this._unverifiedEmail = email;
this._updatedAt = updatedAt;
this.apply(new UserUnverifiedEmailWasSet(this.id.toString(), updatedAt.toDate(), this.dto, email.toString()));
return this;
}
verifyEmail(updatedAt: Dayjs, email: Email): UserModel {
if (!this._unverifiedEmail || !this._unverifiedEmail.equals(email)) {
throw new ValidationException('Email does not match the unverified email');
}
this._email = email;
this._unverifiedEmail = null;
this._updatedAt = updatedAt;
this._roles.push(UserRoles.VerifiedUser);
this.apply(new UserEmailWasVerified(this.id.toString(), updatedAt.toDate(), this.dto, email.toString()));
return this;
}
updatePassword(updatedAt: Dayjs, password: string): UserModel {
this._password = password;
this._updatedAt = updatedAt;
this.apply(new UserPasswordWasUpdated(this.id.toString(), updatedAt.toDate(), this.dto));
return this;
}
updateRoles(updatedAt: Dayjs, roles: UserRoles[]): UserModel {
const rolesAreEqual = this.roles.length === roles.length && this.roles.every((role) => roles.includes(role));
if (rolesAreEqual) {
return this;
}
const validRoles = Object.values(UserRoles);
const invalidRoles = roles.filter((role) => !validRoles.includes(role));
if (invalidRoles.length > 0) {
throw new ValidationException(`Invalid roles: ${invalidRoles.join(', ')}`);
}
this._roles = roles;
this._updatedAt = updatedAt;
this.apply(new UserRolesWereUpdated(this.id.toString(), updatedAt.toDate(), this.dto, roles));
return this;
}
delete(deletedAt: Dayjs): UserModel {
this._deletedAt = deletedAt;
this.apply(new UserWasDeleted(this.id.toString(), deletedAt.toDate(), this.dto));
return this;
}
public get dto(): UserDto {
return new UserDto(
this._id.toString(),
this._email.toString(),
this._roles,
this._createdAt.toDate(),
this._updatedAt.toDate(),
this._unverifiedEmail ? this._unverifiedEmail.toString() : undefined,
);
}
getDto(): DtoInterface {
return this.dto;
}
isDeleted(): boolean {
return !!this._deletedAt;
}
}These methods update model state and then record a domain event. Domain events notify other parts of the system that something significant has happened. They can be consumed internally by other parts of the application, and they can also be dispatched to EventBridge so systems outside the API can react to them. That is how behaviour such as email sending is triggered.
Looking back at UpdateUserService, once the model has been updated it is persisted through the repository.
await this.repository.persist(user);Repository
Here is the repository implementation using prisma. The repository is responsible for persisting the model, publishing domain events, and updating the cache.
@Injectable()
export class UserRepositoryPrisma extends BaseRepositoryPrisma implements UserRepositoryInterface {
async persist(user: UserModel): Promise<UserModel> {
return await this.saveAndPublish<UserModel>(user, async (user) => {
try {
// update
if (user.internalId) {
const data: Prisma.UserUpdateInput = {
id: user.id.toString(),
email: user.email.toString(),
unverifiedEmail: user.unverifiedEmail?.toString() || null,
roles: user.roles as Roles[],
createdAt: user.createdAt.toDate(),
updatedAt: user.updatedAt.toDate(),
deletedAt: user.deletedAt?.toDate(),
hash: user.password,
};
await this.prisma.user.update({
where: { id: user.id.toString() },
data,
});
}
// create
else {
if (!user.password) {
throw new Error('User password is required for creation'); // @todo: INfrastructureException ??
}
const data: Prisma.UserCreateInput = {
id: user.id.toString(),
email: user.email.toString(),
unverifiedEmail: user.unverifiedEmail?.toString() || null,
hash: user.password,
roles: user.roles as Roles[],
createdAt: user.createdAt.toDate(),
updatedAt: user.updatedAt.toDate(),
};
const prismaUser = await this.prisma.user.create({
data,
});
this.modelFromPrisma(prismaUser, user);
}
return user;
} catch (error: unknown) {
if (error instanceof PrismaClientKnownRequestError) {
if (error.code === 'P2002') {
throw new ConflictException(`a user with the email "${user.email.toString()}" already exists`);
}
}
throw error;
}
});
}
async findById(id: Id): Promise<UserModel> {
const user = await this.prisma.user.findUnique({
where: { id: id.toString() },
});
if (!user || user.deletedAt) {
throw new EntityNotFoundException(`User with id ${id.toString()} not found`);
}
return this.modelFromPrisma(user);
}
async findByIds(ids: Id[]): Promise<UserModel[]> {
const idStrings = ids.map((id) => id.toString());
const rows = await this.prisma.user.findMany({ where: { id: { in: idStrings } } });
if (rows.length !== ids.length) {
const foundIds = rows.map((user) => user.id);
const missingIds = idStrings.filter((id) => !foundIds.includes(id));
throw new EntityNotFoundException(`Users with ids "${missingIds.join(', ')}" not found`);
}
return rows.map((row) => this.modelFromPrisma(row));
}
async listIds(
page: number,
perPage: number,
q?: string,
role?: UserRoles,
orderBy?: string,
order?: string,
): Promise<string[]> {
const where = this.listQueryWhereClause(q, role);
const users = await this.prisma.user.findMany({
skip: (page - 1) * perPage,
take: perPage,
where,
select: {
id: true,
},
orderBy: orderBy
? {
[orderBy]: order?.toLowerCase() === 'asc' ? 'asc' : 'desc',
}
: { createdAt: 'desc' },
});
return users.map((user) => user.id);
}
async count(q?: string, role?: UserRoles): Promise<number> {
const where = this.listQueryWhereClause(q, role);
return this.prisma.user.count({
where,
});
}
private listQueryWhereClause(q?: string, role?: UserRoles): Prisma.UserWhereInput {
let where: Prisma.UserWhereInput = { deletedAt: null };
if (q) {
where = {
deletedAt: null,
email: {
contains: q,
mode: 'insensitive',
},
};
}
if (role) {
where = { ...where, roles: { has: role } };
}
return where;
}
async existsByEmail(email: Email): Promise<boolean> {
return this.prisma.user
.count({
where: { email: email.toString() },
})
.then((count) => count > 0);
}
protected modelFromPrisma(prismaUser: User, existing?: UserModel): UserModel {
const user = new UserModel(
prismaUser.internalId,
new Id(prismaUser.id),
new Email(prismaUser.email),
prismaUser.roles as UserRoles[],
dayjs(prismaUser.createdAt),
dayjs(prismaUser.updatedAt),
prismaUser.unverifiedEmail ? new Email(prismaUser.unverifiedEmail) : null,
prismaUser.deletedAt ? dayjs(prismaUser.deletedAt) : undefined,
);
if (existing) {
existing.merge(user);
return existing;
}
return user;
}
}This repository extends BaseRepositoryPrisma, which provides the saveAndPublish method. Persisting models this way ensures that domain events are published and that the model DTO is cached.
Domain events can first be picked up internally by sagas. Sagas are effectively event listeners that trigger commands in response to those events. This allows cross-domain communication without creating direct dependencies between domains.
Those same domain events can then be dispatched to EventBridge, allowing systems outside the API to react to changes in the domain.
Query Side
Going back to UpdateUserAction, once the command has executed, the controller runs the query side to retrieve the updated user:
// Inside UpdateUserAction
const query = new GetUserQuery(id);
return await this.queryBus.execute(query);After the command completes, the controller creates a CQRS query and executes it via the query bus. The query bus then dispatches the query to the corresponding query handler.
Query Handler
Executing GetUserQuery dispatches the query to GetUserQueryHandler.
If GetUserQueryHandler is not registered, the query fails.
Other routes that return a single user resource can follow the same pattern.
Query handlers are usually much simpler than command handlers. They typically retrieve data from cache or persistence and pass it into a response builder. Cached data is usually exposed through DTO providers, and responses are assembled by response builders.
The corresponding query handler looks like this:
@QueryHandler(GetUserQuery)
export class GetUserQueryHandler {
constructor(private builder: UserResponseBuilder) {}
async execute(command: GetUserQuery): Promise<UserResponseDto> {
const { id } = command;
return await this.builder.build(id);
}
}Response Builder
The response builder looks like this:
@Injectable()
export class UserResponseBuilder {
constructor(private dtoProvider: UserDtoProvider) {}
async build(id: string): Promise<UserResponseDto> {
const dto = await this.dtoProvider.getById(id);
return {
item: dto,
};
}
}In more complex scenarios, a response builder may pull data from multiple sources or construct richer responses, for example by adding metadata. In this case, it simply returns a single DTO.
DTO Provider
The user DTO provider is responsible for retrieving UserDto objects, which are cached representations of the UserModel. The provider first checks the cache for the DTO. If it is not found, it loads the model from the repository, converts it to a DTO, and stores it in the cache for future requests.
@Injectable()
export class UserDtoProvider {
private readonly logger = new Logger('UserDtoProvider');
constructor(
@Inject('UserRepositoryInterface') private repository: UserRepositoryInterface,
@Inject('CacheHandlerInterface') private cache: CacheHandlerInterface,
) {}
async getById(id: string): Promise<UserDto> {
let dto: UserDto | null = null;
try {
dto = await this.cache.get<UserDto>(id, UserDto.name, UserDto.version, objectToUserDto);
} catch (e: unknown) {
if (e instanceof DeserializationException) {
this.logger.error(e);
} else {
throw e;
}
}
if (!dto) {
const model = await this.repository.findById(new Id(id));
dto = model.dto;
await this.cache.set(dto);
}
return dto;
}
async getByIds(ids: string[]): Promise<UserDto[]> {
if (!ids.length) {
return [];
}
const dtos = await this.cache.getMany<UserDto>(ids, UserDto.name, UserDto.version, objectToUserDto);
const uncachedIds = ids.filter((_id, index) => !dtos[index]);
if (uncachedIds.length) {
const uncachedUsers = await this.repository.findByIds(uncachedIds.map((item) => new Id(item)));
for (const uncachedUser of uncachedUsers) {
const dto = uncachedUser.dto;
await this.cache.set(dto);
const dtoIndex = ids.indexOf(dto.id);
dtos[dtoIndex] = dto;
}
}
return dtos as UserDto[];
}
async list(
page: number,
perPage: number,
q?: string,
role?: UserRoles,
orderBy?: string,
orderDirection?: string,
): Promise<UserDto[]> {
const ids = await this.repository.listIds(page, perPage, q, role, orderBy, orderDirection);
return await this.getByIds(ids);
}
}UserDtoProvider checks the cache for a UserDto. If it does not find one, it loads the model from the repository, converts it to a DTO, and stores it in the cache. The objectToUserDto function validates and reconstructs the DTO when reading from cache.
Summary
- Controller (command side): Receives the request, creates a command, and executes it.
- Command handler: Orchestrates domain services, checks permissions, and coordinates the use case.
- Domain services: Implement business logic and interact with repositories.
- Domain model: Applies state changes and raises domain events.
- Repository: Persists changes, publishes events, and updates the cache.
- Sagas: Listen for published events and trigger commands in other domains.
- Controller (query side): Issues a query to retrieve the updated user.
- Query handler: Fetches data through providers and builders.
- DTO providers and builders: Reconstruct cached data and shape the final response.
The command side of the API flows like this:
controller --> command --> command handler --> domain service --> repository --> eventsThe query side of the API flows like this:
controller --> query --> query handler --> response builder --> dto provider --> responseThis flow keeps read and write operations separate through CQRS, which helps keep the system modular, testable, and easier to extend.