Anticorruption Layer in a Modular Application
Bohdan Turyk

Bohdan Turyk @benedya

Joined:
Dec 30, 2021

Anticorruption Layer in a Modular Application

Publish Date: Mar 19
0 0

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 straightforward. Application logic is structured as a set of layers, ordered by importance (the deeper the layer, the more important it is) that follow dependency rule. In this article, we will use the Domain, Application, and Infrastructure layers. It's quite similar to Clean Architecture.
Here is illustration of it:

 
 

 

  • 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).

Important thing here is the dependency rule (arrows on the screen). Dependencies should only be directed inward. This means that a layer should not depend on or be aware of outer layers. In practice, this usually means that if you need to call a service from an outer layer, you should define an interface within the current layer that specifies the required functionality. However, the implementation should reside in the outer layer (dependency inversion principle).

Modules

Let’s go a bit further and assume we have two modules, User and Notification, 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
Enter fullscreen mode Exit fullscreen mode

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:

  • Violation of the dependency rule — the Application layer "knows" about something that doesn't belong to it (not even to its own module).
  • Uncontrolled dependencies, which become difficult to manage.

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
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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,
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

Comments 0 total

    Add comment