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:
- The
Domain
layer contains entities and services with significant business rules (e.g., theOrder
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.