Depend on Abstractions
The Dependency Inversion Principle says: high-level code — your business logic — should not depend on low-level details like a specific database or file format. Both should depend on an abstraction: in TypeScript, an interface.
The "inversion" is the direction of the arrow. Instead of the service pointing at one concrete storage, the service points at an interface — and every storage points at that same interface from the other side.
Hard-Wired to the Details
This user service creates and uses a concrete DiskStore directly. It works — but the service is welded to one storage. A different store, a faster store, a fake store for tests: every one of them means editing the service itself.
The service welded to its storage
UserService builds its own DiskStore inside. High-level registration logic now depends on a low-level detail — the file on disk.
Output: TypeScript file and captured output
class DiskStore {
save(user: string): void {
console.log("writing", user, "to file users.txt on disk")
}
}
// UserService builds its own storage — welded to the detail.
class UserService {
private store: DiskStore
constructor() {
this.store = new DiskStore()
}
register(user: string): void {
console.log("registering user:", user)
this.store.save(user)
}
}
const service = new UserService()
service.register("ana")
Invert the Dependency
The fix: the service asks for a UserStorage interface in its constructor and never learns which one it got. The disk store and the memory store both implement the interface.
Now the arrow points from both sides to the abstraction — and swapping the storage is a one-line change at the place where the service is created, not inside it.
The service accepts an interface
Same registration logic, two different storages. new UserService(new DiskStore()) or new UserService(new MemoryStore()) — the service code itself never changes.
Output: TypeScript file and captured output
// The abstraction both sides depend on.
interface UserStorage {
save(user: string): void
}
class DiskStore implements UserStorage {
save(user: string): void {
console.log("writing", user, "to file users.txt on disk")
}
}
class MemoryStore implements UserStorage {
save(user: string): void {
console.log("keeping", user, "in memory")
}
}
class UserService {
private store: UserStorage
constructor(store: UserStorage) {
this.store = store
}
register(user: string): void {
console.log("registering user:", user)
this.store.save(user)
}
}
const onDisk = new UserService(new DiskStore())
onDisk.register("ana")
const inMemory = new UserService(new MemoryStore())
inMemory.register("ben")
The payoff: testing without a disk
Because the service depends on the abstraction, a test can hand it a memory store and check the result instantly — no files, no cleanup. Dependency inversion is what makes code easy to unit-test.
Output: TypeScript file and captured output
interface UserStorage {
save(user: string): void
}
// MemoryStore records what was saved — perfect for checks.
class MemoryStore implements UserStorage {
users: string[] = []
save(user: string): void {
this.users.push(user)
}
}
class UserService {
private store: UserStorage
constructor(store: UserStorage) {
this.store = store
}
register(user: string): void {
this.store.save(user)
}
}
const store = new MemoryStore()
const service = new UserService(store)
service.register("ana")
service.register("ben")
if (store.users.length === 2) {
console.log("check passed: 2 users saved")
}
console.log("saved users:", store.users.join(", "))