Is it correct to say that interfaces have ownership? Not really, instead, we should think of interfaces as contracts, expectations of behaviors or how data structures should look/be used. Interfaces are a way to ensure consistency across different implementations.
What are the main benefits that we are looking for when using Interfaces?
- Abstraction: Hide the implementation details from the consumer.
- Low Coupling: Reduce dependencies between classes.
- Dependency Inversion Principle (DIP): High-level modules should not depend on low-level modules. Both should depend on abstractions.
- Single Responsibility Principle (SRP): Interfaces allow different parts of the application to be modularized and responsible for a single purpose.
Where should an interface be located? The location of the interface should attempt to promote separation of concerns, modularity, and ease of maintenance. At the same time, it should try to communicate who's responsibility is to define the contract that the interface represents.
Lets say we are building a notification system where different types of notifications (like email and SMS) need to be sent. You want to ensure consumers can send notifications without knowing the specific implementation (whether it's email, SMS, etc.). We will have our NotificationService define the contract and locate it near the consumer. This way, NotificationService depends only on the interface INotifier, which means it can easily switch between different implementations without changing its code.
src/
notificationService/
interfaces/
INotifier.ts
services/
NotificationService.ts
senders/
services/
EmailNotifier.ts
SMSNotifier.ts
Define an interface INotifier that the consumer will depend on.
export interface INotifier {
sendNotification(message: string): void;
}import { INotifier } from './INotifier';
export class NotificationService {
constructor(private notifier: INotifier) {}
notify(message: string) {
this.notifier.sendNotification(message);
}
}Now we can have concrete classes for EmailNotifier and SMSNotifier that implement the INotifier interface.
import { INotifier } from '../notificationService/interfaces/INotifier';
export class EmailNotifier implements INotifier {
sendNotification(message: string): void {
console.log(`Sending email: ${formattedMessage}`);
}
}import { INotifier } from '../notificationService/interfaces/INotifier';
export class SMSNotifier implements INotifier {
sendNotification(message: string): void {
console.log(`Sending SMS: ${formattedMessage}`);
}
}Lets say we have are building a class EvaluateSegment that has the need for another FindSegment as a dependency. This is a tightly coupled relationship inside a "module", but we still want to use interfaces to define the contract between these classes. We will place the interface near both concrete classes to promote modularity and ease of maintenance. This is also because we can easily tell that FindSegment has a lot of potential consumers within the same module.
src/segments/
interfaces/
IFindSegment.ts
services/
FindSegment.ts
EvaluateSegment.ts
Locate it in a shared interfaces directory to promote reusability and consistency across the module.
// src/segments/interfaces/IFindSegment.ts
export interface IFindSegment {
find(segmentId: string): Promise<string>;
}// src/services/FindSegment.ts
import { IFindSegment } from "../interfaces/IFindSegment";
export class FindSegment implements IFindSegment {
async find(segmentId: string): Promise<string> {
return {
name: "some-segment",
condition: "coins > 1000",
};
}
}// src/services/EvaluateSegment.ts
import { IPlayerData } from "../interfaces/IPlayerData";
import { IFindSegment } from "../interfaces/IFindSegment";
export class EvaluateSegment {
constructor(private findSegment: IFindSegment) {}
async evaluate(segmentId: string, playerData: IPlayerData): Promise<void> {
const segment = await this.findSegment.find(segmentId);
return this.evaluateCondition(segment.condition, playerData);
}
}Who is defining the contract that the interface represents in this case ? : The interface IFindSegment is defined by the EvaluateSegment class. The interface defines the contract that the FindSegment class must adhere to in order for it to be consumed. By defining the contract definition, EvaluateSegment can work with any class that implements the interface, providing flexibility and modularity.
When the concrete class implements the interface, it is still adhering to the principle of programming to an interface, which is a key tenet of Dependency Inversion (the "D" in SOLID). The concrete class knows about the interface, but the reverse is not true. The interface remains an abstract contract, unaware of any concrete or abstract classes that implement it.
RedisClient is tricky because it potentially can have many consumers. There are 468+ commands, and each consumer might use a different subset of these commands. So, how do we define the interface and its location?
src/
commons/
interfaces/
IRedisClient.ts
redis/
RedisClient.ts
playerJourneys/
services/
ProcessPlayerProgression.ts
// src/commons/interfaces/IRedisClient.ts
export interface IRedisClient {
connect(): Promise<void>;
disconnect(): Promise<void>;
set(key: string, value: string): Promise<void>;
get(key: string): Promise<string | null>;
}This concrete class will implement the IRedisClient interface from commons, this allows us to validate that the RedisClient class has implemented all the methods defined in the interface and adheres to the contract.
// src/redis/RedisClient.ts
import Redis from "ioredis";
import { IRedisClient } from "../commons/interfaces/IRedisClient";
export class RedisClient implements IRedisClient {
private client: Redis.Redis;
constructor(private redisUrl: string) {}
async connect(): Promise<void> {
this.client = new Redis(this.redisUrl);
}
async disconnect(): Promise<void> {
await this.client.quit();
}
async set(key: string, value: string): Promise<void> {
await this.client.set(key, value);
}
async get(key: string): Promise<string | null> {
const value = await this.client.get(key);
return value;
}
}// src/playerJourneys/services/ProcessPlayerProgression.ts
import { IRedisClient } from "../commons/interfaces/IRedisClient";
export class ProcessPlayerProgression {
constructor(private redisClient: IRedisClient) {}
async processData(key: string, value: string): Promise<void> {
const result = await this.redisClient.get(key);
// Process the data
}
}Who is defining the contract that the interface represents in this case ? : The interface definition IRedisClient is not strictly defined by a single consumer, but its instead in a shared "commons". Consumers can choose to use the interface to interact with the RedisClient concrete class or implement their own IRedisClient interface if they have a different use case.
We can have a scenario where we have multiple IRedisClient interfaces, each representing a different subset of Redis commands. This allows consumers to interact with Redis in a more focused and specific way.
src/
commons/
interfaces/
IRedisClient.ts
IRedisClientSetCommands.ts
IRedisClientTimeSeries.ts
IRedisClientTDigest.ts
// src/redis/RedisClient.ts
import Redis from "ioredis";
import { IRedisClient } from "../commons/interfaces/IRedisClient";
import { IRedisClientSetCommands } from "../commons/interfaces/IRedisClientSetCommands";
import { IRedisClientTimeSeries } from "../commons/interfaces/IRedisClientTimeSeries";
import { IRedisClientTDigest } from "../commons/interfaces/IRedisClientTDigest";
export class RedisClient
implements
IRedisClient,
IRedisClientSetCommands,
IRedisClientTimeSeries,
IRedisClientTDigest
{
private client: Redis.Redis;
constructor(private redisUrl: string) {}
async connect(): Promise<void> {
this.client = new Redis(this.redisUrl);
}
async disconnect(): Promise<void> {
await this.client.quit();
}
async set(key: string, value: string): Promise<void> {
await this.client.set(key, value);
}
async get(key: string): Promise<string | null> {
const value = await this.client.get(key);
return value;
}
async zadd(key: string, score: number, member: string): Promise<void> {
await this.client.zadd(key, score, member);
}
async zrange(key: string, start: number, stop: number): Promise<string[]> {
const members = await this.client.zrange(key, start, stop);
return members;
}
async tsAdd(key: string, timestamp: number, value: number): Promise<void> {
await this.client.tsAdd(key, timestamp, value);
}
async tsRange(key: string, from: number, to: number): Promise<string[]> {
const values = await this.client.tsRange(key, from, to);
return values;
}
async tdigestMerge(key: string, value: number): Promise<void> {
await this.client.tdigestMerge(key, value);
}
async tdigestQuantile(key: string, quantile: number): Promise<number> {
const value = await this.client.tdigestQuantile(key, quantile);
return value;
}
}- If multiple consumers use the interface, placing it closer to those consumers (or in a shared module) is usually best.
- If the abstract class and interface are tightly coupled and meant to evolve together, placing them in the same module makes sense.
- For large projects, consider having a "commons" or "shared" interfaces directory to promote reuse and maintainability.
In conclusion, place the interface where it will minimize coupling and maximize flexibility for both the consumers and the concrete or abstract class that implements it. The location depends on how the interface is used and its relationship with the consumers and implementers.