[DDD] Tactical Design Patterns Part 2: Application Layer
minericefield
Posted on January 16, 2024
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 (this article)
- Presentation/Infrastructure Layer
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. I'll also attach a use case model diagram related to the main topic of the application layer here.
Application Layer
The main responsibility of the application layer is to coordinate the flow of scenarios while delegating business logic to domain objects.
Exceptions
Similar to the previous implementation from the bottom up, we will define shared exception classes that will serve as common objects.
typescript
export class NotFoundApplicationException extends Error {}
export class AuthenticationFailedApplicationException extends Error {}
export class UnexpectedApplicationException extends Error {}
Similar to domain layer exceptions, they do not depend on specific protocols. They are simple abstract exceptions that may occur in the application layer.
User Session
typescript
export type SessionId = string;
export type UserSession = {
readonly userId: UserId;
};
export abstract class UserSessionStorage {
abstract get(sessionId: SessionId): Promise<UserSession | undefined>;
abstract set(userSession: UserSession): Promise<SessionId>;
}
For this project, I have defined resources related to sessions in the application layer. The reasons for this decision include:
- There is no particularly complex business logic required for authentication and authorization.
- From the domain perspective, any information that should be held in a session is considered acceptable.
- Therefore, the focus is on designing for the realization of use cases.
The above considerations led to the definition in application/shared/user-session
. Additionally, we assumed that this session is created only after successful authentication.
In the implementation of use cases that require authentication, there are two possible patterns: one that accepts a session ID and another that directly receives a user session.
typescript
UseCaseWithSessionId(sessionId?: SessionId) {
const authenticatedUserSession = AvailableUserSessionAdapter.get(sessionId);
if (authenticatedUserSession) {
// do something
} else {
// do something
}
}
UseCaseWithAuthenticatedUserSession(authenticatedUserSession: UserSession) {
// do something
}
This time, I decided to implement use cases based on the latter pattern, assuming that the user is already logged in, and a valid user session is directly provided. The reasons for this choice include:
- The core concern of this product is the user's activities related to tasks, and the authentication requirements are relatively simple.
- There are no specific behaviors for roles or non-logged-in states.
- The specification is simple: if not logged in, no operations can be performed.
- Keeping use cases simple for such basic requirements is preferable.
While there might be concerns about the concept of Session
itself being impure for the application layer, it is acceptable since Session
is nothing more than an abstract concept representing a maintains of connections. So having it in the application layer won't be a problem.
Application Services
Now that we have prepared shared objects, let's proceed to implement individual application services. Since there are similar implementations, I'll provide an introduction to just a few.
Find Users Use Case
typescript
export class FindUsersUseCaseResponseDto {
readonly users: {
id: string;
name: string;
emailAddress: string;
}[];
constructor(users: User[]) {
this.users = users.map(({ id, name, emailAddress }) => ({
id: id.value,
name: name,
emailAddress: emailAddress.value,
}));
}
}
typescript
export class FindUsersUseCase {
constructor(private readonly userRepository: UserRepository) {}
async handle(): Promise<FindUsersUseCaseResponseDto> {
const users = await this.userRepository.find();
return new FindUsersUseCaseResponseDto(users);
}
}
I have implemented the simple use case for retrieving a list of users. It involves fetching a set of users, repackaging them into a DTO, and returning them. The repackaging is not mandatory; you can return domain objects directly. However, for this time, I adhere to the policy of always repackaging the return values from use cases into DTOs (not exposing domain objects to use case clients).
Increasing Layer Cohesion
The primary reason for introducing the rule of repackaging into DTOs is to avoid the application service clients calling the behavior of domain objects. The responsibility of invoking the behavior of domain objects lies within the application layer. Strictly separating responsibilities across layers and increasing the cohesion of layers is crucial.
The following example illustrates an inappropriate implementation that arose from the practice of exposing domain objects.
"If there is a behavior to format the posting date that can be called in the presentation layer, it would be convenient." This kind of thinking can lead to a violation of the responsibility of the domain layer.
typescript
class Comment {
constructor(
readonly postedAt: Date,
) {}
get postedAtForEndUser() {
return this.postedAt.toLocaleString();
}
}
Calling the behavior of domain objects in the presentation layer violates the responsibility of the presentation layer.
typescript
@Get()
async getComment() {
const comment = GetCommentUseCase.handle();
return {
...comment,
postedAt: comment.postedAtForEndUser
};
}
While the domain layer should focus on expressing business logic, there is a potential for developers to be tempted towards incorrect implementations like the one above
(Conversely, it is possible to opt for the operation of exposing domain objects, trusting the development team to have the self-discipline to avoid falling into such serious anti-patterns).
Let's consider another extreme example.
typescript
// application layer
ApplicationService () {
domainObject.doA();
return domainObject;
}
// presentation layer
ApplicationServiceClient () {
domainObject = ApplicationService();
domainObject.doB();
return Response.NoContent
}
(Adopting a collection-oriented repository, assuming that changes to domainObject
due to behavior calls will be persisted as is.)
This represents a state known as "low cohesion, high coupling". A single scenario realized by doA
and doB
cannot be completed within the application service. From the perspective of high coupling, the challenge of replacing ApplicationServiceClient
is often a central concern. While this perspective is important, what's even more crucial is that implementing such code makes the system difficult to understand and challenging to maintain.
When discussing cohesion, the focus tends to be on the cohesion of specific classes or executable files. Certainly, striving to specialize classes for specific concerns and consolidating them into smaller units is a general best practice in object-oriented programming. It is something we should indeed strive for. However, in DDD, in addition to that, emphasizing the increase in cohesion of layers (modules) becomes extremely important.
Create User Use Case
typescript
export interface CreateUserUseCaseRequestDto {
readonly name: string;
readonly emailAddress: string;
}
export class CreateUserUseCaseResponseDto {
readonly id: string;
constructor(user: User) {
this.id = user.id.value;
}
}
We've defined a DTO similar to the find user use case. The response is set to return the ID of the newly created user.
One thing to note is the naming of CreateUserUseCaseRequestDto
. It might feel odd to have "DTO" in the name for a request. This is simply because I wanted to manage both input and output in the same file named create-user.usecase.dto.ts
and opted for the term "DTO" in the request to achieve that. Other alternatives like CreateUserCommand
or CreateUserUseCaseRequestParams
could also be suitable.
typescript
export class CreateUserUseCase {
constructor(
private readonly userRepository: UserRepository,
private readonly userIdFactory: UserIdFactory,
private readonly userEmailAddressIsNotDuplicated: UserEmailAddressIsNotDuplicated,
) {}
/**
* @throws {InvalidUserEmailAddressFormatException}
* @throws {DuplicatedUserEmailAddressException}
*/
async handle(
requestDto: CreateUserUseCaseRequestDto,
): Promise<CreateUserUseCaseResponseDto> {
/**
* Create userEmailAddress.
*/
const userEmailAddress = new UserEmailAddress(requestDto.emailAddress);
await this.userEmailAddressIsNotDuplicated.handle(userEmailAddress);
/**
* Create user.
*/
const user = new User(
await this.userIdFactory.handle(),
requestDto.name,
userEmailAddress,
);
/**
* Store it.
*/
await this.userRepository.insert(user);
return new CreateUserUseCaseResponseDto(user);
}
}
The main part of the use case is to check for duplicate email addresses and then persist the newly created user.
One aspect that we have to think is how exceptions from the domain layer are handled. The application service is responsible for handling domain exceptions, and there are various approaches:
- Throwing them as is.
- Repackaging and throwing as application layer exceptions.
- Continuing the process in some way.
- (Ultimately, the result is likely to end up in the error path in most cases.)
For simplicity in this example, I chose to throw exceptions as a direct violation of business rules. I debated whether to explicitly use try catch but opted for simplicity in the sample code. I've documented the exceptions that might be thrown in the JsDoc (since exceptions thrown are not always immediately visible to the client).
Login Use Case
ts
export interface LoginUseCaseRequestDto {
readonly emailAddress: string;
}
export class LoginUseCaseResponseDto {
constructor(readonly sessionId: SessionId) {}
}
Believe it or not, the current situation allows logging in only with an email address. Details regarding the password, such as formatting or encryption specifications, are not yet finalized, and modeling for it has been postponed.
ts
export class LoginUseCase {
constructor(
private readonly userRepository: UserRepository,
private readonly userSessionStorage: UserSessionStorage,
) {}
/**
* @throws {AuthenticationFailedApplicationException}
*/
async handle(
requestDto: LoginUseCaseRequestDto,
): Promise<LoginUseCaseResponseDto> {
/**
* Create userEmailAddress.
*/
let userEmailAddress: UserEmailAddress;
try {
userEmailAddress = new UserEmailAddress(requestDto.emailAddress);
} catch (error: unknown) {
if (error instanceof InvalidUserEmailAddressFormatException) {
throw new AuthenticationFailedApplicationException('Login failed.', {
cause: error,
});
}
throw error;
}
/**
* Find user.
*/
const user =
await this.userRepository.findOneByEmailAddress(userEmailAddress);
if (!user) {
throw new AuthenticationFailedApplicationException('Login failed.');
}
/**
* Create session.
*/
const sessionId = await this.userSessionStorage.set({
userId: user.id,
});
return new LoginUseCaseResponseDto(sessionId);
}
}
The flow is simple: upon a successful login, a session is created, and the session ID is returned to the use case client. The session ID itself is a general concept that doesn't depend on any specific technical details, allowing the use case client to handle it as needed based on its own implementation.
The part that might be confusing is the creation of the email address value object. In the context of the login scenario, an exception for email address format violation seems unnatural. Therefore, I've rethrown the format violation as an authentication failed exception for a more appropriate representation of the error.
On the other hand, you could delegate this responsibility to the use case client. Considering this as a presentation layer concern, especially in terms of how errors are presented to end-users, is also a valid perspective.
This time, I interpreted that in the login scenario, a format violation of the email address is simply an authentication failure, regardless of how the error is presented to the end user.
Create Task Use Case
ts
export interface CreateTaskUseCaseRequestDto {
readonly taskName: string;
}
export class CreateTaskUseCaseResponseDto {
readonly id: string;
constructor(task: Task) {
this.id = task.id.value;
}
}
ts
export class CreateTaskUseCase {
constructor(
private readonly taskRepository: TaskRepository,
private readonly taskIdFactory: TaskIdFactory,
) {}
/**
* @throws {TaskNameCharactersExceededException}
*/
async handle(
requestDto: CreateTaskUseCaseRequestDto,
): Promise<CreateTaskUseCaseResponseDto> {
/**
* Create task.
*/
const task = Task.create(
await this.taskIdFactory.handle(),
new TaskName(requestDto.taskName),
);
/**
* Store it.
*/
await this.taskRepository.insert(task);
return new CreateTaskUseCaseResponseDto(task);
}
}
Creates a new task from the task name using Task.create
and persists it.
Add Comment Use Case
ts
export interface AddCommentUseCaseRequestDto {
readonly taskId: string;
readonly userSession: UserSession;
readonly comment: string;
}
export class AddCommentUseCaseResponseDto {
readonly id: string;
constructor(comment: Comment) {
this.id = comment.id.value;
}
}
In this case, userSession is defined as input to identify the commenter.
ts
export class AddCommentUseCase {
constructor(
private readonly taskRepository: TaskRepository,
private readonly commentIdFactory: CommentIdFactory,
) {}
/**
* @throws {NotFoundApplicationException}
* @throws {CommentNumberExceededException}
*/
async handle(
requestDto: AddCommentUseCaseRequestDto,
): Promise<AddCommentUseCaseResponseDto> {
/**
* Find task.
*/
const task = await this.taskRepository.findOneById(
new TaskId(requestDto.taskId),
);
if (!task) {
throw new NotFoundApplicationException('Task not found.');
}
/**
* Create comment.
*/
const comment = new Comment(
await this.commentIdFactory.handle(),
requestDto.userSession.userId,
requestDto.comment,
new Date(),
);
/**
* Add comment to task.
*/
task.addComment(comment);
/**
* Store it.
*/
await this.taskRepository.update(task);
return new AddCommentUseCaseResponseDto(comment);
}
}
Checks for the existence of the target task, adds a comment, and persists it.
Find Tasks Use Case
For the task list, we decide to display only ID
, Task Name
, and Assigned User Name
.
ts
export interface FindTasksUseCaseResponseDto {
readonly tasks: {
id: string;
name: string;
userName?: string;
}[];
}
Now that we've defined the DTO, let's move on to implementing the use case. However, if we were to use only the repository, we'd need to retrieve each task and user separately. This would require mapping the user names assigned to each task. Concerns arise about decreased readability due to looping and potential performance issues due to unnecessary data retrieval.
To address this, we introduce a query service to retrieve only the necessary data.
Find Tasks Query Service
ts
export abstract class FindTasksQueryService {
abstract handle(): Promise<FindTasksQueryServiceResponseDto>;
}
export interface FindTasksQueryServiceResponseDto {
readonly tasks: {
id: string;
name: string;
userName?: string;
}[];
}
Since the reference model is requested by individual use cases, it is defined in the application layer (it could also be defined in the same file as the use case). Although input for the query service is not defined in this case, query services are particularly useful for complex search requirements spanning multiple aggregates or implementing pagination.
ts
export class FindTasksUseCase {
constructor(private readonly findTasksQueryService: FindTasksQueryService) {}
async handle(): Promise<FindTasksUseCaseResponseDto> {
const { tasks } = await this.findTasksQueryService.handle();
return { tasks };
}
}
The use case simply calls the query service.
User Session Provider
ts
export class AvailableUserSessionProvider {
constructor(
private readonly userRepository: UserRepository,
private readonly userSessionStorage: UserSessionStorage,
) {}
async handle(sessionId: SessionId): Promise<UserSession | undefined> {
const userSession = await this.userSessionStorage.get(sessionId);
if (!userSession) {
return;
}
if (!(await this.userRepository.findOneById(userSession.userId))) {
return;
}
return userSession;
}
}
This is responsible for retrieving a user session based on a session ID. It also performs a validity check on the associated user ID. While it doesn't represent a specific use case, it's deemed necessary as an application service.
In this implementation, the simplicity of the application layer is prioritized, and the need for authentication is considered implicit knowledge at the implementation level of each use case. Although it may not be apparent from the use case interface, it's expected that the use case client (presentation layer) uses this user session provider to protect the execution of use cases.
Resources
Posted on January 16, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.