Skip to content

Instantly share code, notes, and snippets.

@jcvidiri
Last active December 29, 2024 23:39
Show Gist options
  • Select an option

  • Save jcvidiri/6df631e2629a2dc184b27c3d54a39d98 to your computer and use it in GitHub Desktop.

Select an option

Save jcvidiri/6df631e2629a2dc184b27c3d54a39d98 to your computer and use it in GitHub Desktop.
Interfaces Best Practices in the context of TypeScript, Clean Code & Dependency Injection

Best Practices for using Interfaces in the context of TypeScript, Clean Code & Dependency Injection

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.


Example 1: NotificationService

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.

Project Structure

src/
  notificationService/
    interfaces/
        INotifier.ts
    services/
        NotificationService.ts

  senders/
    services/
      EmailNotifier.ts
      SMSNotifier.ts

1. Define the Interface

Define an interface INotifier that the consumer will depend on.

export interface INotifier {
  sendNotification(message: string): void;
}

2. Use the Interface in a Consumer

import { INotifier } from './INotifier';

export class NotificationService {
  constructor(private notifier: INotifier) {}

  notify(message: string) {
    this.notifier.sendNotification(message);
  }
}

3. Implement the Interface

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}`);
  }
}

Example 2: EvaluateSegment

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.

Project Structure

src/segments/
    interfaces/
        IFindSegment.ts
    services/
        FindSegment.ts
        EvaluateSegment.ts

1. Define the Interface

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>;
}

2. Implement the Interface

// 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",
    };
  }
}

3. Use the Interface in a Consumer

// 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.


Example 3: RedisClient

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?

Project Structure

src/
    commons/
        interfaces/
            IRedisClient.ts
    redis/
        RedisClient.ts
    playerJourneys/
        services/
            ProcessPlayerProgression.ts

1. Define the Interface in a Shared Location

// 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>;
}

2. Implement the Concrete Class

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;
  }
}

3. Use the Interface in a Consumer

// 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.

Managing multiple consumers

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.

Project Structure

src/
    commons/
        interfaces/
            IRedisClient.ts
            IRedisClientSetCommands.ts
            IRedisClientTimeSeries.ts
            IRedisClientTDigest.ts

2. Implement the Concrete Class

// 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;
  }
}

Conclusion & General Rule of Thumb

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment