In this article, I would like to describe an example of an anticorruption layer for modules based on a layered architecture. Hopefully, this will be useful to someone.

To keep the article concise, I will try to be short in explanations/examples. Below, you can find a link to a more detailed example of this approach. And, of course, if you have any questions or thoughts, feel free to use the comments section. 😉

So, let's get started. First, we will see a very short overview of layered architecture. Then, we will review an example of modules based on this architecture and identify issues with communication. After that, we will try to address these issues using the Anticorruption layer.

Short Overview of Layered Architecture

The idea behind layered architecture is that logic is split into different layers. In this example, we will use the Domain, Application, and Infrastructure layers along with the dependency rule. It's quite similar to Clean Architecture.
Here is illustration of it:

 
 

Image description

 

  • The Domain layer contains entities and services with significant business rules (e.g., the Order entity).
  • The Application layer contains use cases (services) that the module offers (e.g., CreateOrderService).
  • The Infrastructure layer contains implementations of interfaces defined in the inner layers (low-level details) (e.g., OrderRepository).

Dependencies should only be directed inward. This means that a layer should not depend on or be aware of outer layers. If you need to call a service that belongs to an outer layer, you should create an interface that defines the necessary functionality for the current layer. However, the implementation should reside in the outer layer (dependency inversion principle).

Modules

Let's say we have User and Notification modules that follow this architecture. The Notification module is responsible for sending notifications to a user, while the User module provides notification settings for a user.

According to this architecture, the structure of these modules could be as follows:

Module
├── Notification
│   ├── Application
│   │   ├── CreateNotificationUseCase.ts
│   ├── Domain
│   └── Infrastructure
└── User
    ├── Application
    │   ├── ProvideNotificationSettingsUseCase.ts
    ├── Domain
    └── Infrastructure

To send a notification (handled by the Notification module), we need to retrieve the user's notification settings (provided by the User module).
Strictly speaking, the CreateNotificationUseCase needs to call ProvideNotificationSettingsUseCase. However, doing this directly introduces several issues:

  • Uncontrolled dependencies, which become difficult to manage.
  • Increased coupling between modules — more uncontrolled coupling points lead to tighter coupling.
  • Violation of the dependency rule — the Application layer "knows" about something that doesn't belong to it.

In simple terms, the Notification module becomes corrupted by the User module.

So, what would be a better approach?

Anticorruption layer

We can define clear communication rules for these two modules and encapsulate them in an Anticorruption layer (actually sublayer, will see shortly).

According to layered architecture principles, the Domain and Application layers contain the core business logic of a module and should not include low-level implementation details (such as database queries). Let's stick to this principle.

To achieve this, we will create an interface in the Application layer that provides the required functionality (retrieving notification settings) for the current layer. The implementation of this interface will reside in the Infrastructure layer of this module, treating it as a low-level detail.

Additionally, to maintain better control over such dependencies, we will place them in an Anticorruption folder/sublayer within the Infrastructure layer. Each module will have its own dedicated folder within this sublayer. This approach makes dependencies between modules explicit.

Now, let's look at the updated module structure (pay attention to the ADDED sections):

Module
├── Notification
│   ├── Application
│   │   ├── CreateNotificationUseCase.ts
│   │   └── UserNotificationsSettingsProviderInterface.ts # ADDED - defines required functionality for this layer
│   ├── Domain
│   └── Infrastructure
│       └── Anticorruption  # ADDED
│           └── User # Name of the module this one depends on
│               └── UserNotificationsSettingsProvider.ts # Implementation of the interface defined in the Application layer
└── User
    ├── Application
    │   ├── ProvideNotificationSettingsUseCase.ts
    ├── Domain
    └── Infrastructure

So far, so good. Now, let’s take a closer look at the logic within these layers, starting with the Application layer of the Notification module.

// src/Module/Notification/Application/CreateNotificationUseCase.ts

export class CreateNotificationUseCase {
  constructor(
    private readonly userNotificationsSettingsProvider: UserNotificationsSettingsProviderInterface,
  ) {}

  async createNotification(payload: NotificationPayload): Promise<void> {
    const settings = await this.userNotificationsSettingsProvider.getSettings(
      payload.userUuid,
    );
    // todo send notification based on settings
  }
}

// src/Module/Notification/Application/UserNotificationsSettingsProviderInterface.ts

export interface UserNotificationsSettingsProviderInterface {
  getSettings(userUuid: string): Promise<NotificationsSettings>;
}

As seen in the example above, we have not introduced any direct dependencies on the User module within the Application layer. This is great because this layer should not depend on outer layers (following the dependency rule).

Instead, we created the UserNotificationsSettingsProviderInterface interface, which is sufficient for CreateNotificationUseCase. This inverts the dependency.

Now, let’s see how this interface is implemented in the Anticorruption layer:

// src/Module/Notification/Infrastructure/Anticorruption/User/UserNotificationsSettingsProvider.ts

import { UserNotificationsSettingsProviderInterface } from '@Module/Notification/Application/UserNotificationsSettingsProviderInterface';
import { ProvideNotificationSettingsUseCase } from '@Module/User/Application/ProvideNotificationSettingsUseCase';

export class UserNotificationsSettingsProvider
  implements UserNotificationsSettingsProviderInterface
{
  constructor(
    // inject service from User module
    private readonly provideNotificationSettingsUseCase: ProvideNotificationSettingsUseCase,
  ) {}

  async getSettings(userUuid: string): Promise<NotificationsSettings> {
    const userNotificationsSettings =
      await this.provideNotificationSettingsUseCase.getUserNotificationsSettings(
        userUuid,
      );

    return {
      ...userNotificationsSettings,
    };
  }
}

As you can see, we injected ProvideNotificationSettingsUseCase from the User module. That's okay because this implementation resides in the Infrastructure layer.

This is the only place where communication between modules happens. The communication looks manageable, clear, and does not break the dependency rule.

You can find a more detailed version of this approach in my GitHub repository:
https://github.com/benedya/nestjs-layered-architecture
Additionally, this repository includes preconfigured ESLint rules that help track dependency violations between layers.