The Tyranny of Constructors

3 minute read Published: 2022-07-24

The tyranny of constructors is what happens when you create collaborators inside object constructors.

class UserRepository {
  constructor(connectionString: string) {
    this.pool = new Pool({connectionString})
  }
  
  async getAllUsers() {
    try {
      const client = await this.pool.connect()
      const result = await client.query(`select * from users`)
      return result.rows.map(User.ofDatabaseRow)
    } finally {
      client.release()
    }
  }
}

This class has too many responsibilities. It is responsible for:

  1. Constructing and managing a database pool
  2. Managing a connection from the pool
  3. Issuing the right query to the database connection

In reality it should only be responsible for the last item. Everything else is a distraction for the class which makes it less testable, and more error prone.

let's see what it looks like when we relieve it of these responsibilities

Constructing and managing a pool

When we create collaborators inside our constructor we sacrifice a lot. We can no longer substitute the pool for a different object, we're locked to a specific concrete implementation of the pool, etc.

class UserRepository {
  constructor(private readonly pool: Pool) {}

  async getAllUsers() {
    try {
      const client = await this.pool.connect()
      const result = await client.query(`select * from users`)
      return result.rows.map(User.ofDatabaseRow)
    } finally {
      client.release()
    }
  }
}

With this change we can now construct our pool as a singleton for our application. This in turn means we can share that pool across objects. Our startup file might now look like:

export async function main() {
  const config = readConfig()
  const pool = new Pool(config.pool)
  const userService = new UserRepository(pool)
  const todoService = new TodoService(pool)
  
  // ...
  
  app.start()
}

Managing a client from the pool

The logic of getting a client connection with pool.connect and subsequently releasing it after use with client.release is something we'll likely want to do in a number of places. It really deserves its own object.

class UserRepository {
  constructor(private readonly connection: IConnection) {}

  async getAllUsers() {
    const rows = await this.connection.query(`select * from users`)
    return rows.map(User.ofDatabaseRow)
  }
}

interface IConnection {
  query(text: string, values?: any[]): Promise<any[]>
}

class Connection implements IConnection {
  constructor(private readonly pool: Pool) {}
  
  async query(text: string, values?: any[]) {
    try {
      const client = await this.pool.connect()
      const result = await client.query(text, values)
      return result.rows
    } finally {
      client.release()
    }
  }
}

Where we used to have one object we now have two, and each object has a more defined responsibility.

Takeaway

Constructing collaborators in constructors is a fairly big code smell. Do not mix object graph construction with your application logic.

Addendum

An additional rule that is good to follow is

"Every object passed into a constructor should be fully initialized."

async function configure() {
  const config = readConfig()
  const client = new Client(config.client)
  await client.initialize()
  const connection = new Connection(client)
  const userService = new UserService(connection)
  // ...
}