Why should you separate Controllers from Services in Node REST API's?

Corey Cleary - Feb 21 '19 - - Dev Community

Originally published at coreycleary.me. This is a cross-post from my content blog. I publish new content every week or two, and you can sign up to my newsletter if you'd like to receive my articles directly to your inbox! I also regularly send cheatsheets and other freebies.

This is a follow-up to my last post, What is the difference between Controllers and Services in Node REST API's?. In that post we covered the differences between the two, and what kind of logic goes where, but only briefly touched on why you might want to do this.

You might still be wondering, "why is it a good idea to separate the two?". Why use services when controllers are already working?

The WHY is what we'll be going into in more depth in this post.

Using controllers only

If you have a really small application, like only a couple simple routes and controllers, and haven't pulled out your business logic into any services, you probably haven't gotten too annoyed by your current structure yet. And to be clear, we're talking about service files within a project, not separate REST services.

But if your application has grown beyond that, I bet you've experienced several of the following pains:

  • Controllers that have lots of code in them, doing lots of things - AKA "fat controllers".
  • Closely related to the previous one, your code looks cluttered. With controllers making 4 or 5 or more database/model calls, handling the errors that could come with that, etc., that code probably looks pretty ugly.
  • You have no idea where to even begin writing tests.
  • Requirements change, or you need to add a new feature and it becomes really difficult to refactor.
  • Code re-use becomes pretty much non-existent.

How does separation help?

To re-iterate from the previous post on this subject, what you're exactly separating from controllers and services is the business logic from the web/HTTP logic.

So your controllers handle some basic things like validation, pulling out what data is needed form the HTTP request (if you're using Express, that's the req object) and deciding what service that data should go to. And of course ultimately returning a response.

While the services take care of the heavy lifting like calling the database, processing and formatting data, handling algorithms based on business rules, etc. Things not specific to the HTTP layer, but specific to your own business domain.

After doing this separation, those pains mentioned above greatly lessen, if not go away entirely. That's the beauty of using services. Yes there will always be refactoring and things that are difficult to test, but putting things into services makes this much easier.

And this is the WHY.

Let's go over each of these pains one by one. Below is a code example where all the logic is in the controller, from the previous post:

const registerUser = async (req, res, next) => {
  const {userName, userEmail} = req.body
  try {
    // add user to database
    const client = new Client(getConnection())
    await client.connect()

    await client.query(`INSERT INTO users (userName) VALUES ('${userName}');`)
    await client.end()

    // send registration confirmation email to user
    const ses = new aws.SES()

    const params = { 
      Source: sender, 
      Destination: { 
        ToAddresses: [
          `${userEmail}` 
        ],
      },
      Message: {
      Subject: {
        Data: subject,
        Charset: charset
      },
      Body: {
        Text: {
          Data: body_text,
          Charset: charset 
        },
        Html: {
          Data: body_html,
          Charset: charset
        }
      }
    }

    await ses.sendEmail(params) 

    res.sendStatus(201)
    next()
  } catch(e) {
    console.log(e.message)
    res.sendStatus(500) && next(error)
  }
}

Controller with lots of code, bloated and cluttered - AKA "fat controller"

You may have heard the term "fat controller" before. It's when your controller has so much code in it that it looks, well, fat.

This obviously makes it more difficult to read and figure out what the code is doing. Having long and complex code is sometimes unavoidable, but we want that code to be isolated and responsible for one general thing.

And because the controller should orchestrate several different things, if you don't have those different things pulled out into services they'll all end up in the controller, growing the amount of code contained there.

By pulling out the business logic into services, the controller becomes very easy to read. Let's look at the refactored version of the above code using services:

Simplified controller:

const {addUser} = require('./registration-service')
const {sendEmail} = require('./email-service')

const registerUser = async (req, res, next) => {
  const {userName, userEmail} = req.body
  try {
    // add user to database
    await addUser(userName)

    // send registration confirmation email to user
    await sendEmail(userEmail)

    res.sendStatus(201)
    next()
  } catch(e) {
    console.log(e.message)
    res.sendStatus(500) && next(error)
  }
}

module.exports = {
  registerUser
}

Registration service:

const addUser = async (userName) => {
  const client = new Client(getConnection())
  await client.connect()

  await client.query(`INSERT INTO users (userName) VALUES ('${userName}');`)
  await client.end()
}

module.exports = {
  addUser
}

Email service:

const ses = new aws.SES()

const sendEmail = async (userEmail) => {
  const params = { 
    Source: sender, 
    Destination: { 
      ToAddresses: [
        `${userEmail}`
      ],
    },
    Message: {
      Subject: {
        Data: subject,
        Charset: charset
      },
      Body: {
        Text: {
          Data: body_text,
          Charset: charset 
        },
        Html: {
          Data: body_html,
          Charset: charset
        }
      }
    }
  }

  await ses.sendEmail(params) 
}

module.exports = {
  sendEmail
}

Now we have a "thin controller" and can much more easily figure out what's going on.

Can't reuse code

Another big problem is that you can't reuse your code. Let's say we wanted to use the same email-sending code in another controller somewhere else, maybe one supporting an API route that sends emails for followup comments on a Reddit-style forum.

We'd have to copy that code and make some adjustments, rather than just making an email service that is generalized enough to send different kinds of emails, and importing that service into each controller that needs it.

Difficult to refactor

Following on the above two problems, when we don't have business logic isolated to services, it becomes more difficult to refactor and/or add new features.

If code is cluttered and bloated, it's much more difficult to refactor without accidentally breaking some other code in proximity. That's the more obvious one.

But what if we have to add a new feature or new functionality? Imagine if we now had two controllers that both sent emails out after some event was triggered (user registered, user received a follow-up comment on their post, etc). If we had two separate pieces of very similar email code, and we wanted to change the email provider (say from AWS to Sendgrid). We'd have to make that change in two places now! And change the tests in two places as well.

Difficult to write tests

Lastly, and this is a big one, when you don't make use of services it becomes much more difficult to write tests for the logic you're trying to cover.

When you have controllers with multiple different pieces of logic in them, you have multiple code paths you have to cover. I wouldn't even know where to start with writing a test for the controller-only example above. Because it is doing multiple things, we can't test each of those things in isolation.

But when code is more isolated, it becomes easier to test.

And with services, there is no HTTP request object or web framework we have to deal with. So our tests don't have to take that into consideration. We don't have to mock the req and/or res objects.

Once the business logic is pulled out into services, and you have tests written for those, I'd argue you might not even need tests for the controller itself. If there is logic that decides which service to route the request to, then you might want tests for that. But you can even test that by writing some end-to-end tests using supertest and just calling the API route to make sure you get the correct responses back.

Wrapping up

So should you start with controllers then pull business logic out into services later? Or should you start with them from the beginning? My recommendation is to start each project / new feature where you need to add a controller by separating it into a controller and services. It's what I do with every application I work on.

If you already have an application that is not making use of services, for each new feature you need to add, if it's a new route/controller, start with the services approach. And if it doesn't require a new controller, try to refactor the existing one into using services.

You'll make it much easier on yourself in the long run, for all of the reasons discussed above, plus you'll get used to practicing structuring projects in this way.

I'm writing a lot of new content to help make Node and JavaScript easier to understand. Easier, because I don't think it needs to be as complex as it is sometimes. If you enjoyed this post and found it helpful here's that link again to subscribe to my newsletter!

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .