Writing Better Apps: Initialization
Whether you are writing a script, a multicommand CLI utility, or a networked server, applications should follow the same initialization pattern:
- Get configuration
- Build dependencies
- Resolve and execute the entry-point function
Get configuration
Using the environment, and possibly command line arguments, return a validated configuration to the caller. If environment variables or CLI arguments result in an invalid config, fail immediately.
type Config = {
imageStoreProvider: 'db' | 's3',
dbUri: string,
s3: {
bucket: string,
prefix: string,
},
}
export default function getConfig(env: NodeJS.ProcessEnv): Config {
const imageStoreProvider = env.IMAGE_STORE_PROVIDER || 's3'
if (imageStoreProvider === 'db' && !env.DB_URI) {
throw new Error('DB_URI property must be present.')
}
return {
imageStoreProvider,
dbUri: env.DB_URI,
s3: {
bucket: env.S3_BUCKET || 'mycorp_prod',
prefix: 'images/',
}
}
}
Build dependencies
Given application configuration, create an object graph that will be used by the application. The build dependencies phase involves a function or component that builds and supplies the configured implementation for the application's abstractions.
type Deps = {
// generally there will be a lot more functions/objects
// in this map.
imageStore: ImageStore,
}
export default function getDeps(config: Config): Deps {
return {
imageStore: config.imageStoreProvider === 'db' ?
new DBImageStore(config.dbUri) :
new S3ImageStore(config.s3)
}
}
Resolve and execute the entry-point function
With the application configured and the necessary components built, the application should extract the entry-point code and invoke it with the required arguments.
const helpMessage = '...'
async function main() {
// Get config
const config = getConfig(process.env)
// Build deps
const deps = getDeps(config)
// Resolve entrypoint
const { imageStore } = deps
const args = parseArgs(process.argv)
// Invoke
switch (args.action) {
case 'store': await imageStore.store(args.file)
case 'get': await imageStore.retrieve(args.key, args.file)
default: console.log(helpMessage)
}
}
main()
My Thoughts
I already know this; why are you telling me, Richard?
It's an odd thing that something as simple as this pattern takes many years to learn and internalize. Writing clear, concise code is perhaps one of the most challenging things to do as an engineer.
I find that many engineers tend to convolute their entry-points. For simple apps, they think adding extra structure is a waste of time and end up with a big "ball of mud" as the app grows, or they end up refactoring to something similar to what I have proposed.
Larger apps tend to suffer from the opposite problem. They tend to become over-engineered to support use cases developers think they may need in the future. In terms of configuration, this tends to involve supporting a level of dynamism difficult to support operationally.
For dependency management, it could be introducing an IOC container or application framework that does too much. However, I think the biggest mistake developers make is not using inversion of control. Instead, they instantiate dependencies within domain code (not relying on abstractions), abuse singletons, or rely on global state to pass services and providers.
I will be exploring many of these topics in later blog posts.
You might also be interested in these articles...
Stumbling my way through the great wastelands of enterprise software development.