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:
- Constructing and managing a database pool
- Managing a connection from the pool
- 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)
// ...
}