Skip to content

klinkonskydev/hexagonal-architecture-structure

Repository files navigation

Hexagonal Architecture with NestJS

This project is a small NestJS application that demonstrates Hexagonal Architecture, also known as Ports and Adapters Architecture.

The main goal of this architecture is to keep the business logic independent from external details such as HTTP controllers, databases, frameworks, queues, files, or third-party APIs.

In this project, the example domain is User creation.

Core Idea

Hexagonal Architecture separates the application into clear boundaries:

  • Domain: business entities, value objects, and business rules.
  • Application: use cases and ports that describe what the system needs.
  • Infrastructure: concrete technical implementations, such as repositories or external services.
  • Presentation: entry points into the system, such as HTTP controllers.

The most important rule is that the business logic should not depend on infrastructure.

For example, the CreateUserUseCase should not know if users are stored in memory, PostgreSQL, MongoDB, Redis, or an external API. It only depends on a repository contract, called a port.

Project Structure

src
├── app.module.ts
├── main.ts
└── user
    ├── application
    │   ├── ports
    │   │   └── user.repository.port.ts
    │   └── use-cases
    │       └── create-user.use-cases.ts
    ├── domain
    │   ├── entities
    │   │   └── user.entity.ts
    │   └── value-objects
    │       ├── email.vo.ts
    │       └── user-id.ts
    ├── infrastructure
    │   └── adapters
    │       └── in-memory-user.repository.ts
    └── presentation
        ├── user.controller.ts
        └── user.module.ts

Folder Responsibilities

src/main.ts

This is the application entry point.

It creates the NestJS application and starts the HTTP server:

const app = await NestFactory.create(AppModule);
await app.listen(process.env.PORT ?? 3000);

By default, the application runs on port 3000.

src/app.module.ts

This is the root NestJS module.

It imports the UserModule, making the user feature available in the application:

@Module({
  imports: [UserModule],
})
export class AppModule {}

User Feature

The user folder contains all code related to the user domain.

It is divided into four main layers:

  • domain
  • application
  • infrastructure
  • presentation

Domain Layer

Location:

src/user/domain

The domain layer contains the business model and business rules.

This layer should not depend on NestJS, HTTP, databases, repositories, controllers, or infrastructure code.

Entity: User

File:

src/user/domain/entities/user.entity.ts

The User entity represents the main business object of this project.

It contains user data:

  • id
  • name
  • email
  • createdAt
  • updatedAt

It also protects business rules.

For example, a user name must have at least two characters:

if (!name || name.trim().length < 2) {
  throw new Error('Name must be at least 2 characters long');
}

The User.create() factory method is responsible for creating a valid user:

const user = User.create(dto.name, dto.email);

When a user is created, the entity:

  • validates the name
  • generates a new user ID
  • creates an email value object
  • sets creation and update dates

Value Object: Email

File:

src/user/domain/value-objects/email.vo.ts

The Email value object represents a valid email address.

Instead of passing a raw string everywhere, the domain wraps the email in a specific object that validates the value:

constructor(email: string) {
  if (!this.isValid(email)) {
    throw new Error('Invalid email format');
  }

  this.value = email;
}

This keeps email validation close to the email concept itself.

Value Object: UserId

File:

src/user/domain/value-objects/user-id.ts

The UserId value object represents the user identifier.

If an ID is not provided, it generates a new UUID:

constructor(id?: string) {
  this.value = id || randomUUID();
}

This allows the same class to be used when creating a new user or rebuilding an existing user from persistence.

Application Layer

Location:

src/user/application

The application layer contains the use cases of the system.

Use cases coordinate domain objects and ports to execute application behavior.

The application layer should not know about HTTP, databases, or framework-specific infrastructure.

Use Case: CreateUserUseCase

File:

src/user/application/use-cases/create-user.use-cases.ts

This use case contains the application flow for creating a user.

The execute() method performs the following steps:

  1. Receives a DTO with name and email.
  2. Checks if a user with the same email already exists.
  3. Throws an error if the email is already used.
  4. Creates a valid User entity.
  5. Persists the user through the repository port.
  6. Returns the saved user.
async execute(dto: CreateUserDto): Promise<User> {
  const existingUser = await this.userRepository.findByEmail(dto.email);

  if (existingUser) {
    throw new Error('User with this email already exists.');
  }

  const user = User.create(dto.name, dto.email);
  const savedUser = await this.userRepository.create(user);

  return savedUser;
}

The use case depends on UserRepositoryPort, not on a concrete database class.

This is what makes the application easy to test and easy to change.

Port: UserRepositoryPort

File:

src/user/application/ports/user.repository.port.ts

A port is a contract that describes what the application needs from the outside world.

In this project, the application needs a way to store and retrieve users:

export interface UserRepositoryPort {
  create(user: User): Promise<User> | User;
  findById(id: string): Promise<User | null> | User | null;
  findByEmail(email: string): Promise<User | null> | User | null;
  findAll(): Promise<User[]> | User[];
  delete(id: string): Promise<void> | void;
}

The port does not say how users are stored.

It only says what operations are required.

This allows different adapters to implement the same contract:

  • in-memory repository
  • PostgreSQL repository
  • MongoDB repository
  • Prisma repository
  • external API repository

Injection Token: USER_REPOSITORY

The same file also exports an injection token:

export const USER_REPOSITORY = Symbol('USER_REPOSITORY');

This is necessary because TypeScript interfaces do not exist at runtime.

NestJS cannot inject UserRepositoryPort directly because it is erased when TypeScript is compiled to JavaScript.

The symbol gives NestJS a real runtime value that can be used for dependency injection.

Infrastructure Layer

Location:

src/user/infrastructure

The infrastructure layer contains concrete implementations of ports.

This is where technical details live.

Adapter: InMemoryUserRepository

File:

src/user/infrastructure/adapters/in-memory-user.repository.ts

This adapter implements the UserRepositoryPort contract:

export class InMemoryUserRepository implements UserRepositoryPort {
  private readonly users: Map<string, User> = new Map();
}

It stores users in a JavaScript Map:

private readonly users: Map<string, User> = new Map();

When a user is created, it saves the user by ID:

this.users.set(user.getId().getValue(), user);

This adapter is useful for learning, testing, and prototyping.

It is not persistent. If the application restarts, the stored users are lost.

Because the use case depends on the port, this adapter can later be replaced with a real database implementation without changing the use case.

Presentation Layer

Location:

src/user/presentation

The presentation layer receives external input.

In this project, the external input is an HTTP request.

Controller: UserController

File:

src/user/presentation/user.controller.ts

The controller exposes an HTTP endpoint:

@Controller('users')
export class UserController {}

The create user route is:

POST /users

The controller receives the request body and delegates the work to the use case:

@Post()
async createUser(@Body() createDto: CreateUserDto) {
  const user = await this.createUserUseCase.execute(createDto);
  return this.mapUserToResponse(user);
}

The controller should not contain business rules.

Its responsibilities are:

  • receive HTTP input
  • call the correct use case
  • map the result to an HTTP response

Module: UserModule

File:

src/user/presentation/user.module.ts

The module wires the user feature together:

@Module({
  providers: [
    CreateUserUseCase,
    {
      provide: USER_REPOSITORY,
      useClass: InMemoryUserRepository,
    },
  ],
  controllers: [UserController],
})
export class UserModule {}

This tells NestJS:

  • UserController is the HTTP controller.
  • CreateUserUseCase is a provider that can be injected.
  • USER_REPOSITORY should be resolved using InMemoryUserRepository.

Dependency Injection in This Project

NestJS uses dependency injection to create classes and provide their dependencies automatically.

@Injectable()

@Injectable() marks a class as a provider that can be managed by NestJS.

Example:

@Injectable()
export class CreateUserUseCase {}

This allows NestJS to create an instance of CreateUserUseCase and inject dependencies into it.

The repository adapter also uses @Injectable():

@Injectable()
export class InMemoryUserRepository implements UserRepositoryPort {}

This allows NestJS to instantiate the adapter when it is requested through the USER_REPOSITORY token.

@Inject()

@Inject() tells NestJS exactly which provider token should be used for a constructor parameter.

In the use case:

constructor(
  @Inject(USER_REPOSITORY)
  private readonly userRepository: UserRepositoryPort,
) {}

This means:

When creating CreateUserUseCase, inject the provider registered under USER_REPOSITORY.

The module registers that token like this:

{
  provide: USER_REPOSITORY,
  useClass: InMemoryUserRepository,
}

So even though the use case is typed against UserRepositoryPort, the real object injected at runtime is InMemoryUserRepository.

Conceptually, NestJS does this:

const repository = new InMemoryUserRepository();
const useCase = new CreateUserUseCase(repository);

The difference is that NestJS manages this automatically.

Request Flow

Example request:

POST /users
Content-Type: application/json

{
  "name": "John",
  "email": "john@example.com"
}

Flow:

Client
  |
  | POST /users
  v
UserController
  |
  | receives request body
  v
CreateUserUseCase
  |
  | checks if email already exists
  v
UserRepositoryPort
  |
  | implemented by
  v
InMemoryUserRepository
  |
  | searches users Map by email
  v
CreateUserUseCase
  |
  | creates User entity if email is available
  v
User Entity
  |
  | validates name
  | creates UserId
  | creates Email value object
  | sets timestamps
  v
CreateUserUseCase
  |
  | saves user through repository port
  v
InMemoryUserRepository
  |
  | stores user in memory
  v
UserController
  |
  | maps domain object to response
  v
Client

Architecture Diagram

                  External World
                        |
                        v
              +-------------------+
              | HTTP Client       |
              +-------------------+
                        |
                        v
              +-------------------+
              | Presentation      |
              | UserController    |
              +-------------------+
                        |
                        v
              +-------------------+
              | Application       |
              | CreateUserUseCase |
              +-------------------+
                        |
                        v
              +-------------------+
              | Port              |
              | UserRepositoryPort|
              +-------------------+
                        |
                        v
              +-------------------+
              | Infrastructure    |
              | InMemoryRepository|
              +-------------------+

              +-------------------+
              | Domain            |
              | User              |
              | Email             |
              | UserId            |
              +-------------------+

Dependency Direction

The dependency direction should point inward toward the domain and application core.

Presentation -> Application -> Domain
Application -> Port
Infrastructure -> Port

Important points:

  • The domain does not depend on the application layer.
  • The domain does not depend on infrastructure.
  • The use case does not depend directly on InMemoryUserRepository.
  • The use case depends on UserRepositoryPort.
  • The infrastructure adapter implements the port.
  • The NestJS module connects the port to the adapter.

Replacing the Repository Implementation

Currently, the app uses:

InMemoryUserRepository

If a database repository is added later, for example:

PostgresUserRepository

The use case does not need to change.

Only the module provider needs to change:

{
  provide: USER_REPOSITORY,
  useClass: PostgresUserRepository,
}

This is one of the main benefits of Hexagonal Architecture.

Summary

Entity
  Business object with identity and behavior.
  Example: User.

Value Object
  Small immutable object that validates and represents a domain concept.
  Examples: Email, UserId.

Use Case
  Application-specific operation.
  Example: CreateUserUseCase.

Port
  Interface that defines what the application needs from external systems.
  Example: UserRepositoryPort.

Adapter
  Concrete implementation of a port.
  Example: InMemoryUserRepository.

Controller
  Entry point for HTTP requests.
  Example: UserController.

Module
  NestJS wiring layer that connects controllers, use cases, ports, and adapters.
  Example: UserModule.

The result is a codebase where business logic is isolated, dependencies are explicit, and infrastructure can be changed with minimal impact.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors