My FeedDiscussionsHeadless CMS
New
Sign in
Log inSign up
Learn more about Hashnode Headless CMSHashnode Headless CMS
Collaborate seamlessly with Hashnode Headless CMS for Enterprise.
Upgrade ✨Learn more

URL Shortener using Clean Architecture

Clean Architecture by Robert C. Martin (Uncle Bob)

Vighnesh Kulkarni's photo
Vighnesh Kulkarni
·Jan 5, 2022·

9 min read

URL Shortener using Clean Architecture

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 : 'https://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-urlshor..

Here's the link to FreeCodeCamp's URL shortener project

Any feedback/suggestions are most welcome.