Created: June 15, 2022 11:30 AM Last Edited Time: June 19, 2022 4:19 PM Status: In Progress Type: Tech Notes
- I am working on a project that is using an SQL database (e.g. PostgreSQL) and the customer decides they want to move to a NoSQL database (e.g. MongooseDb). Now I have to go through my entire project to refactor everything to use this new database.
- I am starting a new project. The customer wants to manage users and products. My last project had similar requirements. I can copy/paste code from my previous project but, due to the way the code was written, there are lots of changes to be made so I may be better off just starting from scratch.
- I have built several projects for numerous customers and I am responsible for maintaining all of these projects. This becomes too much for me to handle alone so I hire more developers. Each customer request for fixes or features is written in their respective project. Even though I may have multiple customers asking for the same feature I end up with different implementations created by different developers. When a fix is implemented, it is likely only done in one of the projects and we may run into the same problem in our other projects in the future.
These problems result in numerous negative outcomes such as inefficient use of time, inability to scale, increased security risks, to name a few.
As a developer, I create numerous projects with varying architectures. As I work on different projects I want to build upon my “toolkit” to increase my efficiency, reduce mistakes, and simply adhere to best practices. I have created a basic example of this toolkit that implements database integration and model management. There would certainly be more to this toolkit but hopefully this is enough of a “real” example to get you started on building your own toolkit.
This sample code implements Abstract Factory design pattern. The purpose of using this pattern is to adhere to the Open/Closed Principle (OCP) in SOLID. This is implemented such that our “client” will interact with our Abstract Factories rather than interacting directly with numerous Factories. Our “client” code won’t need to change when we add a new Factory. Our Abstract Factories will require minimal or no code changes to implement a new Factory.
This example has an Abstract Factory called DbStoreFactory
and an Abstract Factory called ModelFactory
. The goal of the DbStoreFactory
is to abstract calls to our databases so that clients don’t need to be aware of what databases we are using, their distinct schemas, and their unique functions. The goal of the ModelFactory
is to abstract and normalize the operations associated with any model we need to manage. A “model” would be an entity type or collection that we will (likely) store in some database table. For example, we may have a “users” model that defines the properties (attributes or schema) and methods (functions) required to manage a “user”.
As mentioned before, the primary purpose of using the Abstract Factory design pattern is to make sure we are implementing the OCP. The secondary benefit to this design pattern is reusability across projects. A perfect example of this is our ModelFactory
Abstract Factory.
When building a new application, one of the first steps is to define your models. Let’s say this application is a blogging application. At a minimum we need to define a user and post model. Each model would include its properties (e.g. JSON schema) and its methods (e.g. CRUD operations).
Down the road we are commissioned to build another application. This application is a social messaging application. Once again, we need models for users and posts. In addition, we need a model for channels. Ideally, we want to reuse our code from the last project and extend it to include the new model.
The first piece of the implementation is to create our Abstract Factory that we will name ModelFactory
. Then, for every model, we will create a Concrete Factory, such as UserModelFactory
and PostModelFactory
.
The ModelFactory
will include any properties or methods that are shared amongst all models. For example, you could assign a type
property for all models. The ModelFactory
will have a parameter that tells it which model the “client” wants to interface with.
function ModelFactory(model) {
return {
// Properties
type: model,
// Methods
list: function() {
console.log(`Ran 'list()' for ${model}`)
},
// Model Injection
// ...coming soon...
}
}
Now we can create our UserModelFactory
and create the interface with ModelFactory
. In the ModelFactory
we need to look at the model
parameter to know which model to inject. We could use an if
or switch
statement but I’ve decided to use an object for a quicker reference.
const models = {
users: new UserModelFactory(),
}
function ModelFactory(model) {
return {
// Properties
type: model,
// Methods
list: function () {
console.log(`ModelFactory: Ran 'list()' for ${model}`)
},
// Model Injection
...models[model],
}
}
function UserModelFactory() {
return {
create: function (user) {
console.log(`UserModelFactory: Ran 'create()'`)
},
}
}
The second piece of the implementation is in our “client” code. (Client code refers to any code that interfaces with our Factories).
const $user = new ModelFactory('users')
console.log($user)
$user.list()
$user.create({ name: 'Troy' })
/*
{
type: 'users',
list: f list(),
create: f create()
}
ModelFactory: Ran 'list()' for users
UserModelFactory: Ran 'create()'
*/
From this implementation you can see that when we instantiated the users
model we ended up with the type
property and list
method from ModelFactory
and the create
method from the UserModelFactory
.
Now, let’s add our posts
model.
const models = {
users: new UserModelFactory(),
posts: new PostModelFactory()
}
function PostModelFactory() {
return {
create: function (obj) {
console.log(`PostModelFactory: Ran 'create()'`)
},
}
}
So we added our new model to our model definitions. Then we created our PostModelFactory
. Notice we did not have to make any change to our ModelFactory!
Now let’s interface with the posts model in our “client”.
const $post = new ModelFactory('posts')
console.log($post)
$post.list()
$post.create({ author: 'Troy', message: 'Hello, World' })
/*
{
type: 'posts',
list: f list(),
create: f create()
}
ModelFactory: Ran 'list()' for posts
PostModelFactory: Ran 'create()'
*/
Voila! We “extended” our ModelFactory
and made it very simple for the “client” to use because it’s used in the exact same way as with the users model.
Now we need to add our database integration(s) so our models can perform their CRUD operations.
In our Problem Statements we talked about how a change in database platforms had a significant ripple effect across our entire project. We don’t want that to happen going forward and we will, once again, use the Abstract Factory model and create our DbStoreFactory
. FWIW, I chose this name because I will also be adding a FileStoreFactory
for interfacing with file storage services and these names make sense to me.
For the sake of this example, let’s say we will be using the Firebase Firestore and Firebase Realtime databases. We won’t actually be coding that integration here but we will show how it will be structured.
The pattern for how this will work is the same pattern we used for the ModelFactory
so I will just show the outcome of the DbStoreFactory
implementation here.
const stores = {
FirebaseFirestore: new FirebaseFirestoreDbStoreFactory(),
FirebaseRealtime: new FirebaseRealtimeDbStoreFactory(),
}
function DbStoreFactory(store) {
return {
// Properties
type: store,
// Methods
list: function() {},
create: function() {},
update: function() {},
delete: function() {},
// DbStore Injection
...stores[store],
}
}
function FirebaseFirestoreDbStoreFactory() {
return {
list: function () {
console.log(`FirebaseFirestoreDbStoreFactory: Ran 'list()'`)
},
create: function () {
console.log(`FirebaseFirestoreDbStoreFactory: Ran 'create()'`)
},
update: function () {
console.log(`FirebaseFirestoreDbStoreFactory: Ran 'update()'`)
},
delete: function () {
console.log(`FirebaseFirestoreDbStoreFactory: Ran 'delete()'`)
},
}
}
function FirebaseRealtimeDbStoreFactory() {
return {
list: function () {
console.log(`FirebaseRealtimeDbStoreFactory: Ran 'list()'`)
},
create: function () {
console.log(`FirebaseRealtimeDbStoreFactory: Ran 'create()'`)
},
update: function () {
console.log(`FirebaseRealtimeDbStoreFactory: Ran 'update()'`)
},
delete: function () {
console.log(`FirebaseRealtimeDbStoreFactory: Ran 'delete()'`)
},
}
}
There we have our database integrations via the Abstract Factory method. Obviously this isn’t actually talking to the database platforms but that isn’t the point of this.
One subtle difference in the DbStoreFactory
is that I included the methods that will be found in every Concrete Factory. I like that because I can just go to the DbStoreFactory
code to see all of the methods that will be available in the different Concrete Factories.
Now we need the “client” piece of the implementation. In this case, the model factories are the “client” because that is where we will make the database calls. In this project example we will be using Firebase Firestore for all models. That means we will instantiate the database session directly in ModelFactory
because it is shared across all models. Then we update the list
method to use the database integration.
function ModelFactory(model) {
return {
// Properties
type: model,
db: new DbStoreFactory('FirebaseFirestore'),
// Methods
list: function () {
console.log(`ModelFactory: Ran 'list()' for ${model}`)
return this.db.list()
},
// Model Injection
...models[model],
}
}
From the perspective of ModelFactory
, changing a database platform, say from Firebase Firestore to Firebase Realtime, will only require a change to the db
property definition so that it points to the new store definition. We just have to ensure that DbStoreFactory
provides the normalized set of methods. Meaning, the models only need to worry about the database list
method. It is up to the different database factories to translate that to the database platforms way of returning a list.
Next we need to update our model factories to implement the database calls.
function UserModelFactory() {
return {
create: function (obj) {
console.log(`UserModelFactory: Ran 'create()'`)
this.db.create(obj)
},
}
}
function PostModelFactory() {
return {
create: function (obj) {
console.log(`PostModelFactory: Ran 'create()'`)
this.db.create(obj)
},
}
}
We should never need to change the database code in any of our model factories… ever! We can extend them by adding new methods but we should not change the existing once we are deployed.
There is no need to change our “client” code because the database interfaces are completely abstracted.
const $user = new ModelFactory('users')
console.log($user)
$user.list()
$user.create({ name: 'Troy' })
const $post = new ModelFactory('posts')
console.log($post)
$post.list()
$post.create({ author: 'Troy', message: 'Hello, World' })
/*
{
type: 'users',
db: {
type: 'FirebaseFirestore',
list: ƒ list(),
create: ƒ create(),
update: ƒ update(),
delete: ƒ delete()
},
list: ƒ list(),
create: ƒ create()
}
"ModelFactory: Ran 'list()' for users"
"FirebaseFirestoreDbStoreFactory: Ran 'list()'"
"UserModelFactory: Ran 'create()'"
"FirebaseFirestoreDbStoreFactory: Ran 'create()'"
{
type: 'posts',
db: {
type: 'FirebaseFirestore',
list: ƒ list(),
create: ƒ create(),
update: ƒ update(),
delete: ƒ delete()
},
list: ƒ list(),
create: ƒ create()
}
"ModelFactory: Ran 'list()' for posts"
"FirebaseFirestoreDbStoreFactory: Ran 'list()'"
"PostModelFactory: Ran 'create()'"
"FirebaseFirestoreDbStoreFactory: Ran 'create()'"
*/
const stores = {
FirebaseFirestore: new FirebaseFirestoreDbStoreFactory(),
FirebaseRealtime: new FirebaseRealtimeDbStoreFactory(),
}
function DbStoreFactory(store) {
return {
// Properties
type: store,
// Methods
list: function () {},
create: function () {},
update: function () {},
delete: function () {},
// DbStore Injection
...stores[store],
}
}
function FirebaseFirestoreDbStoreFactory() {
return {
list: function () {
console.log(`FirebaseFirestoreDbStoreFactory: Ran 'list()'`)
},
create: function () {
console.log(`FirebaseFirestoreDbStoreFactory: Ran 'create()'`)
},
update: function () {
console.log(`FirebaseFirestoreDbStoreFactory: Ran 'update()'`)
},
delete: function () {
console.log(`FirebaseFirestoreDbStoreFactory: Ran 'delete()'`)
},
}
}
function FirebaseRealtimeDbStoreFactory() {
return {
list: function () {
console.log(`FirebaseRealtimeDbStoreFactory: Ran 'list()'`)
},
create: function () {
console.log(`FirebaseRealtimeDbStoreFactory: Ran 'create()'`)
},
update: function () {
console.log(`FirebaseRealtimeDbStoreFactory: Ran 'update()'`)
},
delete: function () {
console.log(`FirebaseRealtimeDbStoreFactory: Ran 'delete()'`)
},
}
}
const models = {
users: new UserModelFactory(),
posts: new PostModelFactory(),
}
function ModelFactory(model) {
return {
// Properties
type: model,
db: new DbStoreFactory('FirebaseFirestore'),
// Methods
list: function () {
console.log(`ModelFactory: Ran 'list()' for ${model}`)
return this.db.list()
},
// Model Injection
...models[model],
}
}
function UserModelFactory() {
return {
create: function (obj) {
console.log(`UserModelFactory: Ran 'create()'`)
this.db.create(obj)
},
}
}
function PostModelFactory() {
return {
create: function (obj) {
console.log(`PostModelFactory: Ran 'create()'`)
this.db.create(obj)
},
}
}
const $user = new ModelFactory('users')
console.log($user)
$user.list()
$user.create({ name: 'Troy' })
const $post = new ModelFactory('posts')
console.log($post)
$post.list()
$post.create({ author: 'Troy', message: 'Hello, World' })
/* Import Libraries */
const { Validator } = require('jsonschema')
const v = new Validator()
/* END Import Libraries */
/* Constants */
const stores = {
FirebaseFirestore: new DbFirebaseFirestoreFactory(),
FirebaseRealtime: () => {},
}
const models = {
products: {
init: new ProductModel(),
schema: {
type: 'object',
},
},
users: {
init: new UserModel(),
schema: {
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'number' },
},
required: ['name'],
},
},
}
const validateJson = (obj, schema) => {
log('>>> validateJson', obj, schema)
const test = v.validate(obj, schema)
if (test.valid) {
log('<<< validateJson', { valid: true })
return { valid: true }
}
log('<<< validateJson', { valid: false, errors: test.errors })
return { valid: false, errors: test.errors }
}
const log = console.log
/* END Constants */
/* Database Factories */
function DbStoreFactory(store) {
return stores[store]
}
function DbFirebaseFirestoreFactory() {
return {
list: function ({ collection, orderBy, limit }) {
log('>>> DbFirebaseFirestoreFactory.list', collection, orderBy, limit)
return new Promise((res, rej) => {
setTimeout(() => {
log('<<< DbFirebaseFirestoreFactory.list', [])
res([])
}, 500)
})
},
}
}
/* END Datasbase Factories */
/* Model Factories */
function ModelFactory(model) {
const db = new DbStoreFactory('FirebaseFirestore')
return {
list: async function () {
log('>>> ModelFactory.list', model)
const result = await db.list({ collection: model })
log('<<< ModelFactory.list', result)
return result
},
...models[model].init,
}
}
function UserModel() {
return {
create: function (userObj) {
log('>>> UserModel.create', userObj)
const test = validateJson(userObj, models.users.schema)
if (!test.valid) {
for (let error of test.errors) {
console.error(error.stack)
}
log('<<< UserModel.create', false)
return false
}
userObj = {
...userObj,
id: new Date().valueOf(),
}
log('<<< UserModel.create', userObj.id)
return userObj.id
},
}
}
function ProductModel() {
return {
create: function (productObj) {
log('>>> ProductModel.create', productObj)
const test = validateJson(productObj, models.products.schema)
if (!test.valid) {
for (let error of test.errors) {
console.error(error.stack)
}
log('<<< ProductModel.create', false)
return false
}
productObj = {
...productObj,
id: new Date().valueOf(),
}
log('<<< ProductModel.create', productObj.id)
return productObj.id
},
}
}
/* END Model Factories */
/* Client */
const client = async () => {
const user = new ModelFactory('users')
const users = await user.list()
user.create({ name: 'Troy', age: 49 })
const product = new ModelFactory('products')
const products = await product.list()
product.create({ name: 'soap' })
}
client()