Senior
How to solve the problem of using dependencies of peripheral components in the core?
sobes.tech AI
Answer from AI
Solving the problem of dependency management of peripheral components (adapters, interfaces) in the core (domain logic) within architecture, such as Clean Architecture or DDD, can be achieved through the Dependency Inversion Principle (DIP).
DIP essence:
- High-level modules should not depend on low-level modules. Both should depend on abstractions.
- Abstractions should not depend on details. Details should depend on abstractions.
Practical application:
- The core declares interfaces: The core defines abstractions (interfaces or abstract classes) that describe the functions it requires from peripheral components.
- Peripheral components implement interfaces: External modules (database adapters, HTTP clients, file systems, etc.) implement these interfaces.
- Dependency Injection (DI): Peripheral components are injected into the core from outside, usually at a higher level of the application (for example, in a composition root or using a DI container). The core works only with abstractions.
Example of using DIP in Node.js:
Suppose we have a core that needs access to data.
// src/domain/interfaces/UserRepository.ts
// Core declares an interface
export interface UserRepository {
getUserById(id: string): Promise<User | null>;
}
// src/domain/entities/User.ts
// Core entity
export class User {
constructor(public id: string, public name: string) {}
}
// src/domain/usecases/GetUserUseCase.ts
// Core use case depends on the UserRepository abstraction
import { UserRepository } from '../interfaces/UserRepository';
import { User } from '../entities/User';
export class GetUserUseCase {
constructor(private userRepository: UserRepository) {}
async execute(id: string): Promise<User | null> {
return this.userRepository.getUserById(id);
}
}
// src/infrastructure/database/MongoDbUserRepository.ts
// Peripheral component implementing the core interface
import { UserRepository } from '../../domain/interfaces/UserRepository';
import { User } from '../../domain/entities/User';
export class MongoDbUserRepository implements UserRepository {
// Logic for interacting with MongoDB
async getUserById(id: string): Promise<User | null> {
console.log(`Fetching user with id ${id} from MongoDB...`);
// Simulate data retrieval
if (id === '123') {
return new User('123', 'John Doe');
}
return null;
}
}
// src/app/CompositionRoot.ts
// Composition root assembles dependencies
import { GetUserUseCase } from '../domain/usecases/GetUserUseCase';
import { MongoDbUserRepository } from '../infrastructure/database/MongoDbUserRepository';
// Dependency injection: The core receives the MongoDbUserRepository implementation via interface
const userRepository = new MongoDbUserRepository();
const getUserUseCase = new GetUserUseCase(userRepository);
// Now we can use the use case
async function run() {
const user = await getUserUseCase.execute('123');
if (user) {
console.log(`Found user: ${user.name}`);
} else {
console.log('User not found');
}
}
run();
Advantages of this approach:
- Core independence: The core does not know about specific implementations of peripheral components.
- Testability: The core is easy to test by substituting mock implementations of interfaces.
- Flexibility: It is easy to replace one implementation (e.g., database) with another without changing the core code.
- Support: Simplifies maintenance and modification of the code.