Composition and Dependency Boundaries

Types make dependencies explicit. A service can depend on an interface-shaped capability instead of a concrete database or network implementation, making it easier to test and swap infrastructure. TypeScript interfaces are erased at runtime, however, so dependency injection needs runtime tokens, factory functions, or concrete values depending on the framework.

Use small interfaces that describe what a consumer needs rather than mirroring an entire library. This reduces coupling and makes unit tests simpler. Do not create abstractions solely to satisfy a type-system pattern; the boundary should represent a meaningful responsibility.

Depend on a capability
interface UserRepository {
  findById(id: string): Promise<{ id: string; name: string } | null>;
}

class UserService {
  constructor(private readonly users: UserRepository) {}

  async displayName(id: string): Promise<string> {
    const user = await this.users.findById(id);
    return user ? user.name : "Unknown user";
  }
}
💡 UserService depends on the repository contract rather than a specific database. Tests can provide an in-memory implementation with the same method signature.
Key Takeaways
  • Depend on the smallest useful capability.
  • TypeScript interfaces do not exist as runtime injection tokens.
  • Abstractions are valuable when they reduce real coupling.
Knowledge Check

What is a primary benefit of typing a service against a repository interface?