I will be applying Domain-Driven Design (DDD) tactical design patterns in this article.
I'm sorry. I'm not good at English.
It is divided into the following sections:
- Domain Layer
- Application Layer
- Presentation/Infrastructure Layer (this article)
To keep the description of source code in the article compact, I will be implementing it from a bottom-up approach.
GitHub Repository
https://github.com/minericefield/ddd-onion-lit
Please refer to the beginning of Part 1 for other details regarding the architecture and themes.
Presentation / Infrastructure Layer
Due to the nature of these layers, implementations primarily focus on framework-specific details. However, in DDD, specifics about framework functionalities are not crucial.
Let's just roughly go through and confirm these layers.
Presentation Layer (User Interface Layer)
We decided to expose an HTTP REST-like interface.
Exception Filters
NestJS provides Exception filters to handle exceptions and respond to users appropriately. Exception filters fall under the responsibility of the presentation layer. While there may be cases where exceptions received from application services are handled individually, we will define some common behaviors using these filters.
@Catch(ValidationDomainException)
export class ValidationDomainExceptionFilter implements ExceptionFilter {
catch(exception: ValidationDomainException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const statusCode = HttpStatus.BAD_REQUEST;
response
.status(statusCode)
.json({ statusCode, message: exception.message });
}
}
@Catch(UnexpectedDomainException)
export class UnexpectedDomainExceptionFilter implements ExceptionFilter {
catch(exception: UnexpectedDomainException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const statusCode = HttpStatus.INTERNAL_SERVER_ERROR;
Logger.error(exception.message, exception.stack, exception.cause);
response
.status(statusCode)
.json({ statusCode, message: 'An unexpected error ocurred.' });
}
}
In these exception filters, we first map the exceptions to HttpStatus. If we receive ValidationDomainException
as is, we decide it's appropriate to map it to BAD_REQUEST (400)
in the HTTP world. Additionally, we determine that exposing the error message as is is acceptable and include it in the response.
There might be cases where you want to assign different status codes depending on specific endpoints or exceptions or where you don't want to show the error message as is (e.g., adding a message prompting the end user to re-enter). Generally, domain layer exceptions only provide error messages necessary for domain layer representation and do not consider specific end-user details. In such cases, you would catch exceptions inline in the implementation of each endpoint or prepare specific filters to create responses.
In the UnexpectedDomainExceptionFilter
, which handles unexpected exceptions, we log an error, and for end-users, we conceal the details. In case internal information that could pose a vulnerability is included, we hide the details.
@Catch()
export class AllExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const statusCode = HttpStatus.INTERNAL_SERVER_ERROR;
Logger.error(exception);
response
.status(statusCode)
.json({ statusCode, message: 'An unexpected error occurred.' });
}
}
This filter handles all other exceptions that couldn't be captured by the specific filters mentioned earlier. Similar to the UnexpectedDomainException
, it assigns INTERNAL_SERVER_ERROR (500)
and logs an error
(this filter rather, becomes a filter for truly unexpected exceptions).
Maintaining Sessions Using Cookies
export class UserSessionCookie {
private static readonly COOKIE_NAME = 'ddd-onion-lit_usid';
private static readonly COOKIE_MAX_AGE = 1000 * 60 * 60 * 24 * 30;
constructor(private readonly configService: ConfigService) {}
get(request: Request): SessionId | undefined {
return request.cookies[UserSessionCookie.COOKIE_NAME];
}
set(response: Response, sessionId: SessionId) {
response.cookie(UserSessionCookie.COOKIE_NAME, sessionId, {
httpOnly: true,
secure: this.configService.get('NODE_ENV') === 'production',
maxAge: UserSessionCookie.COOKIE_MAX_AGE,
});
}
}
We decided to use cookies for exchanging sessions with the HTTP client. As long as you can handle get
and set
for cookies, it should be fine.
Authorization Guard
export class AuthGuard implements CanActivate {
constructor(
private readonly userSessionCookie: UserSessionCookie,
private readonly availableUserSessionProvider: AvailableUserSessionProvider,
) {}
async canActivate(context: ExecutionContext) {
const httpContext = context.switchToHttp();
const req = httpContext.getRequest<Request>();
const sessionId = this.userSessionCookie.get(req);
if (!sessionId) throw new UnauthorizedException('Authentication required.');
const userSession =
await this.availableUserSessionProvider.handle(sessionId);
if (!userSession)
throw new UnauthorizedException('Authentication required.');
req.userSession = userSession;
return true;
}
}
NestJS provides Guards to protect routes. In the context of DDD, it might be more accurate to say that it protects application services rather than individual routes, but actually it is very useful.
- Get the session ID from the cookie.
- Obtain available user session information from the session ID.
- Attach user session information to the HTTP request context.
If any of these steps fail, an UnauthorizedException
will be thrown.
Login Controller
Now that the necessary shared resources are available, we'll create controllers on a path-by-path basis.
@UseFilters(...filters)
@Controller('login')
export class LoginController {
constructor(
private readonly loginUseCase: LoginUseCase,
private readonly userSessionCookie: UserSessionCookie,
) {}
@ApiOkResponse()
@Post()
@HttpCode(200)
async login(
@Body()
request: LoginRequest,
@Res()
response: Response,
) {
const { sessionId } = await this.loginUseCase.handle({
emailAddress: request.emailAddress,
});
this.userSessionCookie.set(response, sessionId);
response.send();
}
}
Upon a successful login use case, we store the session ID in the cookie.
Task Controller
@UseFilters(...filters)
@UseGuards(AuthGuard)
@Controller('tasks')
export class TaskController {
constructor(
private readonly findTasksUseCase: FindTasksUseCase,
private readonly findTaskUseCase: FindTaskUseCase,
private readonly createTaskUseCase: CreateTaskUseCase,
private readonly addCommentUseCase: AddCommentUseCase,
private readonly assignUserUseCase: AssignUserUseCase,
) {}
@ApiOkResponse({ type: [TaskListItem] })
@Get()
async find(): Promise<TaskListItem[]> {
const { tasks } = await this.findTasksUseCase.handle();
return tasks;
}
@ApiOkResponse({ type: TaskDetails })
@Get(':id')
async findOne(@Param('id') id: string): Promise<TaskDetails> {
const { task } = await this.findTaskUseCase.handle({ id });
return {
...task,
comments: task.comments.map((comment) => ({
...comment,
postedAt: new Date(
comment.postedAt.year,
comment.postedAt.month - 1,
comment.postedAt.date,
comment.postedAt.hours,
comment.postedAt.minutes,
).toLocaleString(),
})),
};
}
@ApiCreatedResponse({ type: TaskCreatedId })
@Post()
async create(
@Body()
request: CreateTaskRequest,
): Promise<TaskCreatedId> {
const { id } = await this.createTaskUseCase.handle({
taskName: request.name,
});
return {
id,
};
}
@ApiNoContentResponse()
@Put(':id/comment')
@HttpCode(204)
async addComment(
@Param('id') id: string,
@Body()
request: AddCommentRequest,
@Req()
{ userSession }: Request,
) {
await this.addCommentUseCase.handle({
taskId: id,
userSession: userSession,
comment: request.comment,
});
}
@ApiNoContentResponse()
@Put(':id/assign')
@HttpCode(204)
async assignUser(
@Param('id') id: string,
@Body()
request: AssignUserRequest,
) {
await this.assignUserUseCase.handle({
taskId: id,
userId: request.userId,
});
}
}
All endpoints under /tasks
are protected by the AuthGuard
.
This controller executes use cases based on the path and HTTP method, returning appropriate responses to the user.
There are some user interface validation implemented.
export class CreateTaskRequest {
@IsString()
@MinLength(1)
@ApiProperty()
readonly name!: string;
}
This is the request body when creating a new task. The IsString
decorator provided by class-validator rejects all types other than strings, including undefined or null.
The task name value object does not consider the possibility of values other than strings.
export class TaskName {
constructor(value: string) {
if (value.length > TaskName.TASK_NAME_CHARACTERS_LIMIT) {
throw new TaskNameCharactersExceededException(
TaskName.TASK_NAME_CHARACTERS_LIMIT,
);
}
this._value = value;
}
}
Implementing extremely defensive validations as shown below not only makes the domain object unnecessarily complex but also makes the actual business rules less clear.
constructor(value: string) {
if (value === undefined) {
throw new UnexpectedDomainException();
}
if (value === null) {
throw new UnexpectedDomainException();
}
if (typeof value !== 'string') {
throw new UnexpectedDomainException();
}
if (value.length === 0) {
throw new UnexpectedDomainException();
}
if (value.length > TaskName.MAX_TASK_NAME_LENGTH) {
throw new TaskNameCharactersExceededException(
TaskName.TASK_NAME_CHARACTERS_LIMIT,
);
}
this._value = value;
}
In the presentation layer, we perform coarse-grained validation, protecting the domain and application layers.
Note that introducing business rule validation in the presentation layer has both pros and cons, but it is not recommended in IDDD.
The kinds of validation found in the User Interface are not the kinds that belong in the domain model (only). As discussed in Entities (5), we still want to limit coarse-grained validations that express deep business knowledge only to the model.
(Implementing Domain-Driven Design)
User Creation Commander
The authority and method for creating users have not been decided yet. However, for now:
- The application runs on a private server accessible only to some administrators.
- Taking advantage of this, administrators log in to the server and create users via the command line using Nest Commander.
@Command({
name: 'CreateUser',
description: 'Create user by name and email address.',
})
export class CreateUserCommander extends CommandRunner {
constructor(private readonly createUserUseCase: CreateUserUseCase) {
super();
}
async run(nameAndEmailAddress: string[]) {
const [name, emailAddress] = nameAndEmailAddress;
const { id } = await this.createUserUseCase.handle({
name,
emailAddress,
});
Logger.log(`User successfully created. id: ${id}`);
}
}
It passes the parameters received from the input stream to the create user use case.
(Command Example)
yarn start:commander CreateUser Michael test@example.com
Application services are designed to be independent of a specific user interface, so they can be flexibly adapted to meet your needs.
Infrastructure Layer
In this layer, we will create the concrete implementations requested by the Domain and Application layers.
ID Factories
export class TaskIdUuidV4Factory implements TaskIdFactory {
handle() {
return new TaskId(v4());
}
}
export class UserIdUuidV4Factory implements UserIdFactory {
handle() {
return new UserId(v4());
}
}
export class CommentIdUuidV4Factory implements CommentIdFactory {
handle() {
return new CommentId(v4());
}
}
It has been decided to use uuid Version 4 for all of them. Noteworthy is the CommentId
, which serves as the identifier for entities within the boundary, meaning local identifiers. For this case, there is no necessity to use uuid.
Evans used positions such as front-left, rear-left, front- right, and rear-right to identify the wheels of a car. However, unlike wheels, no straightforward, real-world, understandable means of identification for CommentId came to my mind, so the uuid framework was adopted as a convenient and easy generation method.
User Session In-Memory Storage
export class UserSessionInMemoryStorage implements UserSessionStorage {
private readonly value: Map<SessionId, UserSession> = new Map();
async get(sessionId: SessionId) {
const userSession = this.value.get(sessionId);
return userSession;
}
async set(userSession: UserSession) {
const sessionId = Math.random().toString();
this.value.set(sessionId, userSession);
return sessionId;
}
}
This is the implementation class for user session storage. At the current stage, the actual storage to be used has not been determined. For now, an in-memory storage is defined for debugging and testing purposes. The session ID is generated as a simple random number for now.
Data Model & OR Mapper
We decided to use MySQL for a database. and TypeOrm for ORM. The ER diagram is provided below.
For example, the task_assignments
table holds information about user assignments to tasks. While in DDD, there is a pattern to design denormalized tables that reflect the structure of domain objects more directly, but this time, a more conventional table design was chosen.
TypeOrm models:
Task TypeORM Repository
export class TaskTypeormRepository implements TaskRepository {
constructor(
@InjectRepository(TaskTypeormModel)
private readonly taskRepository: Repository<TaskTypeormModel>,
@InjectRepository(TaskAssignmentTypeormModel)
private readonly taskAssignmentRepository: Repository<TaskAssignmentTypeormModel>,
@InjectRepository(TaskCommentTypeormModel)
private readonly taskCommentRepository: Repository<TaskCommentTypeormModel>,
) {}
async insert(task: Task) {
await this.taskRepository.save({
id: task.id.value,
name: task.name.value,
taskAssignment: task.userId && {
taskId: task.id.value,
userId: task.userId.value,
},
taskComments: task.comments.value.map((comment) => ({
id: comment.id.value,
userId: comment.userId.value,
content: comment.content,
postedAt: comment.postedAt,
})),
});
}
async update(task: Task) {
await this.taskRepository.update(task.id.value, { name: task.name.value });
await this.taskAssignmentRepository.delete({ taskId: task.id.value });
task.userId &&
(await this.taskAssignmentRepository.save({
taskId: task.id.value,
userId: task.userId.value,
}));
await this.taskCommentRepository.delete({ taskId: task.id.value });
await this.taskCommentRepository.save(
task.comments.value.map((comment) => ({
id: comment.id.value,
userId: comment.userId.value,
content: comment.content,
postedAt: comment.postedAt,
taskId: task.id.value,
})),
);
}
async find() {
const tasks = await this.taskRepository.find({
relations: {
taskAssignment: true,
taskComments: true,
},
});
return tasks.map((task) =>
Task.reconstitute(
new TaskId(task.id),
new TaskName(task.name),
task.taskComments.map(
(taskComment) =>
new Comment(
new CommentId(taskComment.id),
new UserId(taskComment.userId),
taskComment.content,
taskComment.postedAt,
),
),
task.taskAssignment?.userId && new UserId(task.taskAssignment.userId),
),
);
}
findOneById () ...
}
This class is responsible for persisting and reconstituting the task aggregate root.
The early generated entity ID is directly assigned as the primary key. However, using a UUID directly as a primary key can adversely affect performance in the case of InnoDB. Especially in cases where handling vast amounts of data, or in practical scenarios, it is advisable to assign the early-generated ID to a different column, such as public_id
, and use an auto-incrementing sequential number as the primary key.
In the update
method, all data corresponding to the child objects of the aggregate are deleted before being added again. While other approaches, such as adding only the necessary data or deleting only the data that needs to be removed, are possible, deleting and re-adding data is still a simpler method.
Find Tasks TypeORM Query Service
export class FindTasksTypeormQueryService implements FindTasksQueryService {
constructor(private readonly dataSource: DataSource) {}
async handle() {
const tasks = await this.dataSource.query<
{ id: string; name: string; userName?: string }[]
>(
'SELECT tasks.id as id, tasks.name as name, users.name as userName FROM tasks LEFT JOIN task_assignments ON task_assignments.task_id = tasks.id LEFT JOIN users ON users.id = task_assignments.user_id',
);
return { tasks };
}
}
This is the implementation of a query service optimized for retrieving a list of tasks. Queries are run directly via TypeORM DataSource. It selects only the necessary columns.
Thank you
If there is a next time, I would like to summarize about transactions and consistency.
Top comments (0)