Hexagonal Architecture with Nest.js and TypeScript
Understanding Hexagonal Architecture
Introduction
In today’s software development landscape, maintaining a robust and testable codebase is paramount. One architectural approach that aids in achieving these goals is Hexagonal Architecture. When combined with the power of Nest.js and TypeScript, developers can create highly modular and scalable applications that are easier to test and maintain. In this article, we will delve into the intricacies of Testing in Hexagonal Architecture, examining the role of Nest.js, TypeScript, and the key concepts of hexagonal architecture, ports, and adapters. We will also discuss the tradeoffs, challenges, and the importance of considering the impact of design decisions in this context.
Understanding Hexagonal Architecture
Hexagonal Architecture, also known as Ports and Adapters Architecture, promotes a clear separation of concerns within an application. It emphasizes isolating the core business logic from external dependencies and infrastructure concerns. By dividing the application into three main layers, namely the application core, ports, and adapters, Hexagonal Architecture enables developers to create modular and highly maintainable codebases. You can find more in my previous article about Hexagonal Architecture here
In the context of a Nest.js application, the application core represents the business logic and use cases. It remains unaffected by the implementation details of external systems, such as databases or external APIs. Ports act as interfaces that define the contract between the application core and the external systems. Adapters, on the other hand, implement these port interfaces, acting as bridges between the core and the external world.
Nest.js and TypeScript is a powerful combination: Nest.js, a progressive Node.js framework, provides an excellent foundation for implementing Hexagonal Architecture. Its modular and dependency injection-based structure aligns well with the principles of Hexagonal Architecture. Additionally, the use of TypeScript, a statically-typed superset of JavaScript, brings enhanced type safety and tooling support to the table.
With Nest.js and TypeScript, developers can easily define ports as interfaces and provide concrete implementations using adapters. This allows for the flexible and testable composition of the application core with different adapters, enabling seamless integration with various external systems.
Let’s consider an example of external API integration in a Nest.js application. Here’s how the port and adapter interfaces might look.
// Port is simply the interface
export interface IExternalApiService {
getUsers(): Promise<User[]>;
createUser(user: User): Promise<User>;
// ...
}
// The adapter is implementation of our port (interface)
@Injectable()
export class ExternalApiService implements IExternalApiService {
async getUsers(): Promise<User[]> {
// ...
}
async createUser(user: User): Promise<User> {
// ...
}
}
In the above code snippet, the IExternalApiService
interface represents the port, defining the contract for interacting with an external API. The ExternalApiService
class acts as the adapter, implementing the IExternalApiService
interface and providing concrete implementations for fetching users and creating users via the external API.
The Importance of Balancing Tradeoffs
While Hexagonal Architecture offers numerous benefits, it also introduces some tradeoffs. The additional abstraction layers and interfaces may lead to increased complexity and development time. Developers need to carefully balance the benefits of modularity, testability, and maintainability against the potential overhead.
Another challenge involves maintaining a clear separation between the core logic and infrastructure concerns. It’s crucial to resist the temptation of letting implementation details leak into the application core, as this can hinder the benefits of testing and modularity.
Considering the Impact of Decisions
When adopting Hexagonal Architecture with Nest.js and TypeScript, it is vital to consider the long-term impact of design decisions. Strive for well-defined interfaces, cohesive use cases, and clear separation of concerns. Remember that the architecture should serve the needs of the application, not vice versa. While adhering to Hexagonal Architecture principles is beneficial, it’s equally important to evaluate the requirements and complexity of the project to avoid over-engineering.
Testing in Hexagonal Architecture
Now that we understand the principles of Hexagonal Architecture and its implementation with Nest.js, let’s explore how to test such an architecture effectively. In this example, we’ll focus on unit testing the application core while isolating it from the adapters that interact with external systems.
Consider a simplified example of a user service in a Nest.js application. The user service interacts with an external API to fetch and create users. Here’s an outline of the user service:
// user.service.ts
@Injectable()
export class UserService {
constructor(private readonly externalApiService: IExternalApiService) {}
async createUser(user: User): Promise<User> {
// Business logic to validate and create the user
// Delegates to the external API service for user creation
return this.externalApiService.createUser(user);
}
async getUsers(): Promise<User[]> {
// Delegates to the external API service for fetching users
return this.externalApiService.getUsers();
}
}
To test the UserService
class in isolation, we need to create a mock implementation of the IExternalApiService
interface. This will allow us to control the behavior of the external API service and avoid making actual API calls during testing. Here's an example of a mock implementation:
// external-api.service.mock.ts
export class MockExternalApiService implements IExternalApiService {
private readonly users: User[] = [];
async getUsers(): Promise<User[]> {
return Promise.resolve(this.users);
}
async createUser(user: User): Promise<User> {
const createdUser: User = { id: this.users.length + 1, ...user };
this.users.push(createdUser);
return Promise.resolve(createdUser);
}
}
With the mock implementation in place, we can now write unit tests for the UserService
. Here's an example of how to test the createUser
method:
// user.service.spec.ts
describe('UserService', () => {
let userService: UserService;
let externalApiService: MockExternalApiService;
beforeEach(async () => {
externalApiService = new MockExternalApiService();
userService = new UserService(externalApiService);
});
it('should create a user', async () => {
const user: User = { name: 'John Doe' };
const createdUser = await userService.createUser(user);
expect(createdUser).toBeDefined();
expect(createdUser.id).toBeDefined();
expect(createdUser.name).toBe(user.name);
expect(externalApiService.getUsers()).toContain(createdUser);
});
});
In the above example, we create an instance of the UserService
class, passing the MockExternalApiService
as a dependency. We then test the createUser
method, asserting that it returns a valid user object with an assigned ID and that the created user is included in the list of users retrieved from the mock external API service.
Similarly, you can write tests for other methods of the UserService
class, focusing on different use cases and asserting the expected behavior.
Conclusion
Testing Hexagonal Architecture in a Nest.js application involves isolating the application core from external systems by using mock implementations of port interfaces. By providing controlled mock behavior, you can thoroughly test the business logic while avoiding dependencies on real external systems.
Through careful unit testing, you can validate the behavior of the application core, ensuring that it correctly interacts with the adapters without requiring actual integration with external systems during testing. This approach helps maintain a robust and testable codebase, enabling faster and more reliable development iterations.
Remember to consider the specific requirements and use cases of your application when designing and executing tests. Aim for comprehensive coverage while focusing on critical business logic and ensuring that the application core operates as expected within the Hexagonal Architecture context.
Some books that I can recomend:
- Designing Hexagonal Architecture with Java: An architect’s guide to building maintainable and change-tolerant applications with Java and Quarkus by Davi Vieira
- https://blog.octo.com/hexagonal-architecture-three-principles-and-an-implementation-example/