URL Shortener using Clean Architecture
Clean Architecture by Robert C. Martin (Uncle Bob)
So, I was looking for Node/Express best practices on YouTube and I came across this video by Dev Mastery where he explains the implementation of Comments Microservice using Clean Architecture. But since that microservice was huge, I wanted to build something easier, with very few components, just for practice and to make it easier for beginners to understand the architecture.
Since the architecture has already been explained by Dev Mastery brilliantly, I would recommend watching that video first.
Here is my github repository for this project.
Entity
Entity is the main subject of any microservice. And in our case it is the response we would get after the URL has been shortened({ original_url : 'freeCodeCamp.org', short_url : 1}
).
Being the main subject we can't have it depend on any framework/library.
// /src/shorturl/shorturl.js
// Injecting validateUrl so that this entity doesn't directly upon Node's dns and URL module
export default function buildMakeShorturl({ validateUrl }) {
return async function makeShorturl({ original_url, short_url }) {
try {
await validateUrl(original_url);
} catch (error) {
console.log("DNS ERROR:-", error)
throw error;
}
return Object.freeze({
getOriginalUrl: () => original_url,
getShortUrl: () => short_url
})
}
}
The above file is responsible for entity creation. It would throw an error if the original URL to be shortened isn't valid.
buildMakeShorturl
is a function which takes in validateUrl
, a method which validates the original URL. We've injected validateUrl
and not defined it in this file itself because validateUrl uses dns
and URL
module from nodejs
and as mentioned earlier we need to seperate any such implementations which depends on external framework from entity creation.
buildMakeShorturl
returns makeShortUrl
which is an async factory function(a function which returns an object). We've frozen the object because we don't want the properties to be overridden elsewhere. And the makeShortUrl
is async because validateUrl
is also async.
// /src/shorturl/index.js
import buildMakeShorturl from "./shorturl.js";
import dns from 'dns';
const validateUrl = (url) => {
return new Promise((resolve, reject) => {
let nodeUrl = new URL(url);
if (nodeUrl.protocol === "ftp:") {
reject('Invalid URL');
}
dns.lookup(nodeUrl.hostname, (err, value) => {
if(err) {
console.log("DNS Error", err)
reject('Invalid URL');
}
resolve(value);
})
})
}
const makeShorturl = buildMakeShorturl({ validateUrl });
export default makeShorturl;
makeShortUrl
is being exported from a separate file so that dependencies like validateUrl
can be created here and injected to buildMakeShorturl and makeShorturl can be imported in any use-case(which is a layer above the entity layer in clean architecture).
Data Access
The next thing I decided to build was the data-access part of the architecture. The next layer from the inside after Entity is actually the Use-Case layer but going with data-access first was my personal preference because it would consist of methods like findById, insert, etc. which would be used by the use-case layer. But you can choose whatever flow you are comfortable with.
Data access as the name suggests would contain everything which would help us interact with the database.
Schema
Following is a basic UrlSchema created using mongoose.
// /src/data-access/Url.js
import mongoose from 'mongoose'
const Schema = mongoose.Schema;
const UrlSchema = new Schema({
original_url: 'string'
});
export const UrlDb = mongoose.model('UrlDb', UrlSchema);
DB Functions
// /src/data-access/url-db.js
// Needs URL Model so inject it from index.js
export default function makeUrlDb({ UrlDb }) {
return Object.freeze({
findById,
findOne,
insert
})
//Create insert, findbyid, etc methods and return frozen object
async function findById({ id }) {
try {
const url = await UrlDb.findById(id).exec()
const { _id, original_url } = url || {};
// Return an object with properties matching the entity
return { original_url, short_url: _id }
} catch (error) {
throw new Error(error)
}
}
async function findOne({ original_url }) {
const url = await UrlDb.findOne({ original_url }).exec()
// Add || {} bc destructuring fails if url is undefined or null
const { _id } = url || {};
if(url) {
return { original_url, short_url: _id }
}
return null;
}
async function insert({ original_url }) {
const url = new UrlDb({ original_url });
url.save()
.then(({ original_url }) => { original_url })
.catch(err => { throw new Error(err) })
return {original_url: url.original_url, short_url: url._id}
}
}
We aren't using the functions provided by mongoose directly in use-cases because we don't want those use-cases to depend on any library and they shouldn't even know what DB is being used.
Finally, we'll import this module in index.js, inject UrlDb
(the DB model) and export the frozen object returned by makeUrlDb
.
Connection
I've exported an IIFE from this module which connects to the DB and once this connection has been established, it connects to the server as well using app
which is the instance of express injected to this module. This module has been imported in server.js(main file).
// /src/data-access/connection.js
import mongoose from 'mongoose';
// DB Connection
export default function connectDB(app) {
(function() {
const url = process.env.MONGO_URI
mongoose.connect(url, {useNewUrlParser: true, useUnifiedTopology: true}, (err) => {
if(err){
console.log("Could not connect to MongoDB (DATA CENTER) ", err);
}else{
console.log("DATA CENTER - Connected")
}
})
const db = mongoose.connection;
db.on('error', console.error.bind(console, 'MongoDB connection error:'));
db.once('open', function() {
console.log('Connected to DB!!');
app.listen(process.env.PORT || 3000, function() {
console.log(`Listening on port ${process.env.PORT || 3000}`);
});
});
})()
}
Use-Cases
This microservice has 2 use-cases, GET URL and POST URL.
Below is the get-url module, which exports a function called makeGetUrl
which, similar to other modules, has a dependency injection urlDbAccess
, which we had created above.
urlDbAccess
is a frozen object which consists of a method findById
which looks for a document matching the id
passed to the function.
The id
here is passed from a controller(we'll have a look at it soon).
// /src/use-cases/get-url.js
export default function makeGetUrl({ urlDbAccess }) {
// Get id from controller
return async function getUrl({ id }) {
if(!id) {
throw new Error('Please provide an id')
}
const { original_url, short_url } = await urlDbAccess.findById({ id });
return { original_url, short_url };
}
}
Next is the post-url use-case. Again, urlDbAccess
is injected and urlInfo
is passed from a controller. But the main point to be noted here is the import statement. We could directly import the entity module(makeShortUrl
) here because use-case being the layer above the entity layer(in clean architecture), any use-case can directly depend upon the entity.
makePostUrl
returns an async function postUrl
which returns an object after validation of original_url
(using makeShorturl
) and DB insertion.
// /src/use-cases/post-url.js
import makeShorturl from '../shorturl/index.js';
export default function makePostUrl({ urlDbAccess }) {
// We'll get urlInfo from controller
return async function postUrl(urlInfo) {
try {
const validatedUrl = await makeShorturl(urlInfo)
const checkIfPresent = await urlDbAccess.findOne({ original_url: validatedUrl.getOriginalUrl() })
if(!checkIfPresent){
const result = await urlDbAccess.insert({ original_url: validatedUrl.getOriginalUrl() })
return result;
}
else {
throw {
shortUrl: checkIfPresent.short_url,
error: 'Url already exists'
}
}
}
catch(e) {
throw e
}
}
}
Below is the index.js for use-cases. This is where the dependency injection(urlDbAccess
) happens and the use-cases are exported, which can then be imported in controllers(because controllers are present in the layer above use-cases).
// /src/use-cases/index.js
import makePostUrl from "./post-url.js";
import makeGetUrl from "./get-url.js"
import urlDbAccess from "../data-access/index.js";
const postUrl = makePostUrl({ urlDbAccess });
const getUrl = makeGetUrl({ urlDbAccess })
const urlServices = Object.freeze({
postUrl,
getUrl
})
export default urlServices
export { postUrl, getUrl }
Express-Callback
So, as we saw above, we'll be getting the id
and urlInfo
from controllers and the controller would get these values from the Request(req) object provided by nodejs(For eg: req.body, req.params, etc).
app.get('/api/shorturl/', (req, res) => {
res.json("Hello World");
})
This is how the callback function looks like right? We'll be doing the same thing but in another file so that we'll be able to pass the req
object to the controller from there and finally we'll return this callback function as shown in the code below.
// /src/express-callback/express-callback.js
export default function makeExpressCallback(controller) {
return (req, res) => {
const httpRequest = {
body: req.body.url,
params: req.params
}
controller(httpRequest)
.then(({ body }) => {
body.method == "GET" ? res.redirect(body.original_url) : res.json(body);
})
.catch(e => {
console.log("EC Error:-", e);
if(e == "Invalid URL") {
res.status(200).send({error: e})
}
res.status(500).send({error: e})
})
}
}
This is where the execution of controller function begins as it gets the httpRequest
object and the data returned by the controller is handled by res
object.
Controllers
// /src/controllers/get-url.js
export default function makeGetUrlController({ getUrl }) {
return async function getUrlController(httpRequest) {
try {
const url = await getUrl({ id: httpRequest.params.id })
return {
body: {
...url,
method: "GET"
}
}
} catch (e) {
throw new Error(e.message);
}
}
}
This is the get-url controller called makeGetUrlController
where the getUrl
use-case has been injected. It returns an async function getUrlController
which calls the use-case and finally, it returns an object containing the properties returned by the use-case({ original_url, short_url }
).
I've added the method
property as well so that the express-callback knows if it is a GET request and it would respond accordingly.
Below is the code for post-url controller.
// /src/controllers/post-url.js
import makeShorturl from '../shorturl/index.js';
export default function makePostUrl({ urlDbAccess }) {
// We'll get urlInfo from controller
return async function postUrl(urlInfo) {
try {
const validatedUrl = await makeShorturl(urlInfo)
const checkIfPresent = await urlDbAccess.findOne({ original_url: validatedUrl.getOriginalUrl() })
if(!checkIfPresent){
const result = await urlDbAccess.insert({ original_url: validatedUrl.getOriginalUrl() })
return result;
}
else {
throw {
shortUrl: checkIfPresent.short_url,
error: 'Url already exists'
}
}
}
catch(e) {
throw e
}
}
}
We've imported makeShortUrl
because controller layer being above the entity layer, it can directly depend upon the entity. The rest of this code is pretty self-explanatory.
So, let's move to the index.js for these controllers, where these modules are imported, urlDbAccess
is injected and finally the controllers are exported as shown below:-
// /src/controllers/index.js
import makePostUrl from "./post-url.js";
import makeGetUrl from "./get-url.js"
import urlDbAccess from "../data-access/index.js";
const postUrl = makePostUrl({ urlDbAccess });
const getUrl = makeGetUrl({ urlDbAccess })
const urlServices = Object.freeze({
postUrl,
getUrl
})
export default urlServices
export { postUrl, getUrl }
server.js
// /server.js
import express from 'express';
import cors from 'cors';
import bodyParser from 'body-parser';
import connectDB from './src/data-access/connection.js';
const app = express();
import { getUrlController, postUrlController } from './src/controllers/index.js';
import makeExpressCallback from './src/express-callback/express-callback.js';
// Basic Configuration
const port = process.env.PORT || 3000;
app.use(cors());
app.use(bodyParser.urlencoded({extended: false}));
app.use('/public', express.static(`${process.cwd()}/public`));
app.get('/', function(req, res) {
res.sendFile(process.cwd() + '/views/index.html');
});
app.get('/api/shorturl/:id', makeExpressCallback(getUrlController))
app.post('/api/shorturl', makeExpressCallback(postUrlController))
connectDB(app);
Have a look at app.get
and app.post
. The controllers passed to makeExpressCallback
are according to the routes. As you may know by now, makeExpressCallback
would return the required callback(Refer /src/express-callback/express-callback.js) which will execute when the respective route is hit.
Now, before you start building this microservice on your own(which I highly recommend doing), select any route, have a look at the flow of all the functions being called and executed, starting from makeExpressCallback
to controller to use-case to data-access to entity.
Thank You!
Again, my github repo for this microservice is:- github.com/vighnesh192/microservice-urlsho…
Here's the link to FreeCodeCamp's URL shortener project
Any feedback/suggestions are most welcome.