Not too long ago, I wrote a post called A future-proof Node.js express file/folder structure. It's been over six months, and I'm still using that folder structure today.
Let's face it - six months is a long time not to change something in tech!
But it turns out that if you are building an API (even if it's not a public one) - at some point, you'll need to figure out versioning.
And that's something I need to figure out today. So, I've come up with a few options. I'll debate the pros and cons in this blog post and decide which one will be most 'future-proof'.
Here are my options:
// route-based versioning
app.use('/api/v1/books', bookRoutesV1);
app.use('/api/v2/books', bookRoutesV2);
// header-based versioning at the router level
app.use('/api/books', versionMiddleware('2.0.0'), bookRoutesV2);
app.use('/api/books', bookRoutesV1);
// header-based versioning at the route level
app.use('/api/books', bookRoutes);
// routes
booksRoutes.get('/', versionMiddleware('2.0.0'), booksHandlers.booksV2);
booksRoutes.get('/', booksHandlers.books);
Let's go!
Route-based versioning
What do I mean by route-based versioning? Well, I mean that the version is in the request. E.g. /api/v1/books
, `/api/v2/books'.
This way is the traditional(?) approach. Or at least the one I expected to use by default, with the version included in the route path.
// route-based versioning
app.use('/api/v1/books', bookRoutesV1);
app.use('/api/v2/books', bookRoutesV2);
In fact, when I started implementing some versioning this morning, I started with this approach. But then I stopped because something didn't feel right.
Don't get me wrong - this is probably a great approach, especially for a public API.
But let's get back to our imaginary library API. We have routes for "books", "borrow", and "users".
And say that we currently have a Node.js folder structure like this:
📁 server/
└── 📁 api/
├── 📁 books/
├── 📁 borrow/
└── 📁 user/
Now I want to update my books
routes because I'm recreating the books side of the app and need extra information from my server. So I need api/v2/books
.
If we have a v1
and v2
(so that the old app will still work for users who haven't updated yet), the new folder structure could look like this:
📁 server/
└── 📁 api/
├── 📁 v2/
├── 📁 books/
├── 📁 v1/
├── 📁 books/
├── 📁 borrow/
└── 📁 user/
And we can do the v1
and v2
book routes.
// import the routes for books
const bookRoutesV1 = require('./api/v1/books');
const bookRoutesV2 = require('./api/v2/books');
// wire up to the express app
app.use('/api/v1/books', bookRoutesV1);
app.use('/api/v2/books', bookRoutesV2);
That all seems to make sense.
But what about "borrow" and "users"? We haven't updated those parts of our API.
Do I keep those on v1
while books moves to v2
?
But then all the latest code isn't together. And in the future, if I decide I no longer need to support v1
for "books", I can't delete the directory because I don't know if it has the latest code for the other features.
That doesn't sound very "future-proof". And it will only get more confusing if "books" is on v10
, "borrow" on v4
, and "users" is on v3
.
No, let's keep it simple. All features should be on the same version - especially if the API is public.
But should I duplicate all the files and code from "borrow" and "users" into v2
? Since they haven't changed, that's just duplication - but at least the v2
has all the latest code for each feature.
Or should I point the v2
routes back to the v1
code?
// books (updated)
app.use('/api/v1/books', bookRoutesV1);
app.use('/api/v2/books', bookRoutesV2);
// borrow (not updated)
app.use('/api/v1/borrow', borrowRoutesV1);
app.use('/api/v2/borrow', borrowRoutesV1);
// users (not updated)
app.use('/api/v1/users', userRoutesV1);
app.use('/api/v2/users', userRoutesV1);
I've still got the same issue with the directories not being correct.
I just couldn't think of a sensible solution to this problem*. It would make perfect sense for an API update involving everything. But I couldn't find a future-proof way to handle changes to only one feature or a few routes on the API with this method.
*I did (finally) come up with a solution at the end of this blog post!
Header-based versioning at the router level
Instead of needing the user (or the client) to specify the version in the route, the request could send it back in a header.
What I like about this approach is that if you have a SPA, you can use the application version.
Here is how I would send the version with the API request using axios.
import axios from 'axios';
export const axiosInstance = axios.create({
baseURL: "https://api.mylibrary.com/api",
withCredentials: true,
headers: { 'x-version': process.env.VERSION }
});
If you are not sure how to get access to process.env.VERSION
and you are using Webpack - check out my blog post on automatically reloading the latest version of your single page applications.
Now your server will know which application version the user has loaded, and you can send back the correct API response.
This setup would rely on the client sending the version back in a request header and routing it to the correct API version.
// header-based versioning at the router level
app.use('/api/books', versionMiddleware('2.0.0'), bookRoutesV2);
app.use('/api/books', bookRoutesV1);
I wrote a versionMiddleware
middleware function to handle this, and it looks like this:
const semver = require('semver');
exports.versionMiddleware = function(version) {
return function(req, res, next) {
if (semver.gte(req.headers['x-version'], version)) {
return next();
}
return next("route"); // skip to the next route
};
};
This middleware will check if the version supplied in the header is greater than or equal to the version
argument. If it is, it continues. But if it's an older version, it skips to the next route instead.
You could stack this up with many different versions.
app.use('/api/books', versionMiddleware('3.5.0'), bookRoutesV3p5);
app.use('/api/books', versionMiddleware('3.0.0'), bookRoutesV3);
app.use('/api/books', versionMiddleware('2.0.0'), bookRoutesV2);
app.use('/api/books', bookRoutesV1);
I like this. I no longer have to change the URL (from v1
to v2
in the request path), and the version updates client-side are automatic, so that's good.
But it does solve some of the problems I was up against with route-based versioning. From the API routes point of view, I don't need to worry about "books" being on v2
while "borrow" and "users" are still on v1
.
// books (updated)
app.use('/api/books', versionMiddleware('2.0.0'), bookRoutesV2);
app.use('/api/books', bookRoutesV1);
// borrow (not updated)
app.use('/api/borrow', borrowRoutesV1);
// users (not updated)
app.use('/api/users', userRoutesV1);
And if I update "borrow" in the future, and release version 3. That could look like this.
// books (not updated since v2)
app.use('/api/books', versionMiddleware('2.0.0'), bookRoutesV2);
app.use('/api/books', bookRoutesV1);
// borrow (updated v3)
app.use('/api/borrow', versionMiddleware('3.0.0'), borrowRoutesV3);
app.use('/api/borrow', borrowRoutesV1);
// users (not updated since v1)
app.use('/api/users', userRoutesV1);
And I guess I could change the folder structure like this:
📁 server/
└── 📁 api/
├── 📁 books/
├── 📁 v1/
└── 📁 v2/
├── 📁 borrow/
├── 📁 v1/
└── 📁 v3/
└── 📁 user/
└── 📁 v1/
The directory name (e.g. v3
) matches the version release.
I'm actually warming to this approach - and I may use it. Let's consider the other option first.
Header-based versioning at the route level
The last approach solved some of the issues I had in route-based versioning. I'm happy that I can upgrade one feature without:
Updating the API routes to the other features for no reason, e.g.:
"/api/v2/books"
"/api/v2/borrow"
"/api/v2/users"
Or creating mismatched versions in my API, e.g.:
"/api/v2/books"
"/api/v1/borrow"
"/api/v1/users"
But what if you need to make a breaking change to a few routes? For example, your book list. But the rest of the "books" API routes will stay the same.
Do you copy all the re-used code to the v2
directory? I'd prefer not to do that.
Maybe we can solve this by moving the versioning to the route level (instead of the router).
Instead of bookRoutesV2
and bookRoutesV1
, each route can have different versions - and we could handle each at the route level.
// one router for all books routes
app.use('/api/books', bookRoutes);
Then each route can have a new version(s).
We will use the versionMiddleware
function we created earlier.
// routes
booksRoutes.get('/', versionMiddleware('2.0.0'), booksHandlers.booksV2);
booksRoutes.get('/', booksHandlers.books);
I don't think it would make sense to tie each route version to the repository or API version.
Let's imagine three routes - each updated at different times.
const express = require('express');
const booksRoutes = express.Router();
// Handlers
const booksHandlers = require('./books.handlers');
// list all books
booksRoutes.get('/', versionMiddleware('2.2.0'), booksHandlers.getBooksV3);
booksRoutes.get('/', versionMiddleware('2.0.0'), booksHandlers.getBooksV2);
booksRoutes.get('/', booksHandlers.getBooks);
// add a book
booksRoutes.post('/', versionMiddleware('2.3.0'), booksHandlers.addBookV2);
booksRoutes.post('/', booksHandlers.addBook);
// get a book
booksRoutes.get('/:bookId', versionMiddleware('2.1.0'), booksHandlers.getBookV2);
booksRoutes.get('/:bookId', booksHandlers.getBook);
// export bookRoutes
module.exports = booksRoutes;
Version 2 of the "books list" route got released with version 2 of the API. But we updated the "add a book" route later (version 2.3 of the API). And that's OK!
I like this. It feels like it could be future-proof. It definitely reduces the need to duplicate code for stuff that doesn't change between versions.
But could it be too messy?
I think that I will need to re-imagine the folder structure (again!) for this one.
📁 server/
└── 📁 api/
├── 📁 books/
├── 📁 add-book/
├── 📄 add-book.handlers.js
├── 📄 add-book.v1.js
├── 📄 add-book.v2.js
├── 📄 index.js
├── 📁 get-book/
├── 📄 get-book.handlers.js
├── 📄 get-book.v1.js
├── 📄 get-book.v2.js
├── 📄 index.js
├── 📁 get-books/
├── 📄 get-books.handlers.js
├── 📄 get-books.v1.js
├── 📄 get-books.v2.js
├── 📄 get-books.v3.js
├── 📄 index.js
├── 📄 books.routes.js
└── 📄 index.js
├── 📁 borrow/
└── 📁 user/
Wait, what happened here?!
Call me crazy, but I'm considering adding a directory for each endpoint, separating the .handlers
file from my Node.js file structure, and moving the handler in with the endpoints.
Because I need to allow that when the API updates, the response might change, but the request could change too, I'll have a different handler for each version update on the route.
In each endpoint, I'll have an index
file that exports the handlers - like this:
// export the handlers
module.exports = require('./add-book.handlers');
Then instead of require('/add-book/add-book.handlers')
, I can use the nicer require('./add-book')
. Using the handlers in the routes file will look like this:
const express = require('express');
const booksRoutes = express.Router();
// Handlers
const addBook = require('./add-book');
const getBook = require('./get-book');
const getBooks = require('./get-books');
// list all books
booksRoutes.get('/', versionMiddleware('2.2.0'), getBooks.V3);
booksRoutes.get('/', versionMiddleware('2.0.0'), getBooks.V2);
booksRoutes.get('/', getBooks.V1);
// add a book
booksRoutes.post('/', versionMiddleware('2.3.0'), addBook.V2);
booksRoutes.post('/', addBook.V1);
// get a book
booksRoutes.get('/:bookId', versionMiddleware('2.1.0'), getBook.V2);
booksRoutes.get('/:bookId', getBook.V1);
// export bookRoutes
module.exports = booksRoutes;
Now that I have written this all out, I like this versioning style the best.
I like that I can still look in my api
directory to see what features I have.
📁 server/
└── 📁 api/
├── 📁 books/
├── 📁 borrow/
└── 📁 user/
And I can open a feature to see all the endpoints.
📁 server/
└── 📁 api/
├── 📁 books/
├── 📁 add-book/
├── 📁 get-book/
├── 📁 get-books/
├── 📄 books.routes.js
└── 📄 index.js
├── 📁 borrow/
└── 📁 user/
But what about if I remove an endpoint? Let's say I no longer allow adding books via the API. How would that work?
Well, I wouldn't want to just 404 an API route that did previously exist. I could add a v3 handler to return a message to the client. So that would totally work.
// add-book.handlers.js
exports.V3 = async function(req, res) {
res.status(410).send({ error: 'Adding books is no longer active since version 2.7.0.' });
}
exports.V2 = async function(req, res) {
// ...
}
exports.V1 = async function(req, res) {
// ...
}
// books.routes.js
booksRoutes.post('/', versionMiddleware('2.7.0'), addBook.V3);
booksRoutes.post('/', versionMiddleware('2.3.0'), addBook.V2);
booksRoutes.post('/', addBook.V1);
The main compromise with this method is you can't (easily) depreciate an old API version. For example, when services say "In 6 months, we will no longer support version 1".
For example, I couldn't remove addBook.V1
because V2 got released in v2.3.0 of the API. So addBook.V1
was still active in v2.0.0.
But that would be the case in the other scenarios too. I'd either have duplicate code in a v2
folder - so I could safely delete v1
- or I'd need to go through each endpoint to see what was active and when.
For now, I think the positives outweigh the negatives with this approach. But that's because I'm using a monorepo - making it easy to keep the versions in sync on the client and the API. It isn't a public API.
Route-based versioning revisited
If I had a public API, I would go for the same back-end structure but go back to sending the version in the route from the client. I'd use the major versions, e.g. v1
, v2
, and v3
, for any breaking changes.
Now that I have a new API folder structure that I am happy with (and includes versions) - let's see how this could work with routes.
📁 server/
└── 📁 api/
├── 📁 books/
├── 📁 add-book/
├── 📄 add-book.handlers.js
├── 📄 add-book.v1.js
├── 📄 add-book.v2.js
├── 📄 index.js
├── 📁 get-book/
├── 📄 get-book.handlers.js
├── 📄 get-book.v1.js
├── 📄 get-book.v2.js
├── 📄 index.js
├── 📁 get-books/
├── 📄 get-books.handlers.js
├── 📄 get-books.v1.js
├── 📄 get-books.v2.js
├── 📄 get-books.v3.js
├── 📄 index.js
├── 📄 books.routes.js
└── 📄 index.js
├── 📁 borrow/
└── 📁 user/
We are keeping the new file structure and handlers, but instead of getting the version from the header, we will get it from the route.
// route-based versioning
app.use('/api/:version/books', bookRoutes);
It still looks like one route for books, but it can now take any version.
Our version middleware will change to get the version from the route instead of the header. And since we are just using major versions, it makes it easier to compare numbers:
exports.versionMiddleware = function(version) {
return function(req, res, next) {
let requestVersion = parseInt(req.params.version.substring(1)); // removes the "v" and turns into a number
if (typeof requestVersion !== 'number') {
return next(new Error("Invalid API version requested."));
} else if (requestVersion >= version) {
return next();
}
return next("route"); // skip to the next route
};
};
Now we need to be more considerate about API changes (probably a good thing!).
We don't need to change the version:
- If we add an endpoint
- If we make changes to an endpoint, providing they don't break backward compatibility
But we do if:
- We remove an endpoint
- We break backward compatibility, e.g. the response was a string, but now it is an array
I found this comment on Stack Overflow really useful for figuring this out. Thanks Nic!
So here's how the routes would look now.
const express = require('express');
const booksRoutes = express.Router({ mergeParams: true }); // keep the route params from parent router
// Handlers
const addBook = require('./add-book');
const getBook = require('./get-book');
const getBooks = require('./get-books');
// list all books
booksRoutes.get('/', versionMiddleware(3), getBooks.V3);
booksRoutes.get('/', versionMiddleware(2), getBooks.V2);
booksRoutes.get('/', getBooks.V1);
// add a book
booksRoutes.post('/', versionMiddleware(4), addBook.V3);
booksRoutes.post('/', versionMiddleware(2), addBook.V2);
booksRoutes.post('/', addBook.V1);
// get a book
booksRoutes.get('/:bookId', versionMiddleware(2), getBook.V2);
booksRoutes.get('/:bookId', getBook.V1);
// export bookRoutes
module.exports = booksRoutes;
This way also makes it cleaner to remove old versions. Since we can easily see that all these routes have had updates since v1 of the API, and if we no longer supported v1, we could remove those old routes.
🤯 I've changed my mind (again) - I've gone full circle and like this way the best!