Backend APIs: Constant evolution but tethered to the past

Depending on the challenges you face while building your app, you might encounter the need to build your own backend logic and APIs to bring your app to life.

As discussed in an earlier story, backend is the best way to secure most of your API keys from stealing. But building your own backend comes with a hidden cost: evolution and compatibility. And this Thread from Mikaela confirms my suspicion that it is not a straightforward thing to solve.

Whatever the app you work on, it’s likely that you:

In those cases, you know what to do: you change behaviors, commit your changes, and send a new build of your app containing the changes.
This build has been tested before sending, and everything works as expected.

But what about the lifecycle of backend API changes?

When you update your backend, it doesn’t just affect the code you update but also every app version you ever shipped in the past that consumes the API it exposes.

From an initial requirement…

Let’s take an example of a very simple app that allows you to publish statuses.

It probably uses a route that will allow you to send a new status to the backend:

AppPO2S0T1/CsrteaattuesdBackend

Using Vapor, such a route would look like this:

import Vapor

struct Status: Content, Validatable {
    let status: String

    static func validations(_ validations: inout Validations) {
        validations.add("status", as: String.self, is: !.empty)
    }
}

func routes(_ app: Application) throws {
    app.post("status") { req in
        try Status.validate(content: req)
        let status = try req.content.decode(Status.self)
        // Save status somewhere
        return HTTPStatus.created
    }
}

And a minimal API test checking the route would look like this:

func testStatusHelloWorld() async throws {
    try await self.app.test(.POST, "status", beforeRequest: { req async throws in
        try req.content.encode(["status": "Hello, world!"])
    }, afterResponse: { res in
        XCTAssertEqual(res.status, .created)
    })
}

func testEmptyStatus() async throws {
    try await self.app.test(.POST, "status", beforeRequest: { req async throws in
        try req.content.encode(["status": ""])
    }, afterResponse: { res in
        XCTAssertEqual(res.status, .badRequest)
    })
}

Everything works as you expect it, and you send your v1.0 to the App Store. Congrats! 🥳

A simple evolution

For v1.1, your users ask you to add a humor emoji along with your status. This was not in your initial plans, therefore you have to add it as a requirement on the backend side:

import Vapor

struct Status: Content, Validatable {
    let status: String
    let emoji: String

    static func validations(_ validations: inout Validations) {
        validations.add("status", as: String.self, is: !.empty)
        validations.add("emoji", as: String.self, is: .count(1...1) && .emoji)
    }
}

func routes(_ app: Application) throws {
    app.post("status") { req in
        try Status.validate(content: req)
        let status = try req.content.decode(Status.self)
        // Save status somewhere
        return HTTPStatus.created
    }
}

extension Validator where T == String {
    public static var emoji: Validator<T> {
        // TODO: check that all characters are emojis
        return .valid
    }
}

Now, when running your tests, you’ll get an error 💥

XCTAssertEqual failed: ("400 Bad Request") is not equal to ("201 Created")

It's not a test failure, it's a regression catching opportunity

You get this error because the new emoji property has become a requirement.

It might be tempting to update this test to make it pass, but you should NOT touch it. This test reflects how currently shipped apps are using your API in production.

If you update the test and push this change to production, currently shipped apps in the App Store will get 400 errors until they update to v1.1, and it’s very likely that you want to avoid that!

Tethered to the past

Instead of removing this test, it’s time to embrace it, and rename it so it becomes more explicit:

/// Used by app versions below 1.1
func testLegacyNoEmojiStatus() async throws {
    try await self.app.test(.POST, "status", beforeRequest: { req async throws in
        try req.content.encode(["status": "Hello, world!"])
    }, afterResponse: { res in
        XCTAssertEqual(res.status, .created)
    })
}

This test must pass to prevent breaking your already shipped app. It means that evolution for your API has to be careful enough, and that new features must come with some kind of fallback strategies.

It’ll also be time to bring new tests that will cover the new key behavior, such as when the emoji is not a single emoji string value.

There are a lot of strategies possible to bring new features to existing routes. I’ll try to cover those that come to my mind, while there might be others.

Therefore, the most important part is to keep the test that describes older app behavior up and running to prevent breaking things in the future! 😉

Backward-compatible routes

Whenever possible, I tend to prefer this approach. The tests give me assurance that old behavior is preserved and keeps working. Therefore, why not just alter the already existing route?

For my emoji feature, I could define this behavior:

Still with Vapor, this kind of behavior is pretty simple to achieve:

import Vapor

struct Status: Content, Validatable {
    let status: String
    // value is now optional
    let emoji: String?

    static func validations(_ validations: inout Validations) {
        validations.add("status", as: String.self, is: !.empty)
        // Not required validation to allow skipping the key
        validations.add("emoji", as: String.self, is: .count(1...1) && .emoji, required: false)
    }
}

func routes(_ app: Application) throws {
    app.post("status") { req in
        try Status.validate(content: req)
        let status = try req.content.decode(Status.self)
        // nil coalescing operator to use default value
        let emoji = status.emoji ?? "🙂"
        // Save status somewhere
        return HTTPStatus.created
    }
}

Versioning routes

Although I dislike this approach because it leads to a lot of code duplication, the main idea is to bring a new route with the new behavior and keep the old route for the older app version.

This is the recommended approach when bringing a breaking change, something that cannot coexist with the old route.

Naming is up to you for the new route.
You can either create a new, more explicit route name: POST /status_with_emoji.
Or move forward with a versioned namespace for your routes: POST /v1.1/status.

You end up maintaining two distinct routes for the same feature. As long as you have tests… It’s fine. But if this route evolves too much, it can become a mess to maintain.

Backward-compatible changes, or breaking changes?

Even if there are upsides and downsides to both approaches, I would tend to bring backward-compatible changes whenever possible, even if it means duplicating a return key to have the old and new behaviors coexist.

I can only recommend the versioning approach for unavoidable backward-incompatible changes. But for the example above, it’s way overkill as adding a non-required parameter is enough!

Backward-compatible changes include:

But also, to help you, here is a non-exhaustive list of backward-incompatible changes:

For the request
  • Adding a required body property
  • Renaming or removing a body property
  • Renaming or removing a query parameter
  • Changing the type or valid values of a body property
  • Updating the authentication method 🙃
  • Dropping an existing route 😱
For the response
  • Renaming or removing a response property
  • Changing the type or adding new enum cases of a response property
  • Changing the returned HTTP code
  • Changing an error code or response format for a given error

Prepare for the future early on

It’s always a good thing to shape the future soon enough when designing the first version of your API. This would give you enough flexibility to then be able to bring evolution in a backward-compatible manner.

The practices I use and recommend would be:

Write API tests that mimic your app’s behavior (input / expected output)
And of course, when adding a new behavior, write new tests. Keep those tests untouched! They are testing the backward compatibility of existing routes! The only thing you can afford to change in those tests are “side-effect” changes (like checking that the default value for a missing optional parameter is correctly saved in the database).
Encapsulate lists within an object
When returning a list of items, encapsulate it within a property. This will allow you to introduce a “semi” breaking change if the amount of objects grows too much and you have to introduce a pagination mechanism.
// Returning all items
{
    "items": [...]
}
// Could evolve with a pagination when the app grows:
{
    "items": [...],
    "count": 42,
    "next_item_id": "1234",
}
Consider all enums as frozen
When you have shipped an enum property, never add a new case to it. If you have something new to describe, it’ll have to be a new enum, with a new key-value pair to describe it.
As an example, if you had a Quorum enum with “present” and “absent”, seems fair!
Later on, if you need a “remote” option, I find it better to introduce either an optional enum describing the presence (“onsite”, “remotely”), alongside the original quorum enum, or a brand new quorum enum with the new remote case that would sit in a new key-value pair.
The old enum would fall back to “present” when the new value is “remotely”.
Never pass optional parameters in the URL path
Prefer query parameters or body properties for optional data. Removing a parameter from a route is harder to achieve in a backward-compatible manner.

Final words

Exposing an API, whether it’s an HTTP API like above or in a public library, brings new constraints: the constraint of keeping behavior and exposition the same, and introducing new behaviors while maintaining compatibility and providing deprecation notices (in documentation as well as in code for libraries).

It’s a different mindset than building an app because you lose control of the update lifecycle. You have no guarantee that your users will update their apps!

The only guarantee you have is by writing API tests that mimic your different app versions. This brings you confidence that you stay in business when updating your back-end code, while it’s almost impossible to test every version you ever shipped of your app each time you want to change something!


Don’t miss a thing!

Don't miss any of my indie dev stories, app updates, or upcoming creations!
Stay in the loop and be the first to experience my apps, betas and stories of my indie journey.

Thank you for registering!
You’ll retrieve all of my latest news!

Anti-bot did not work correctly.
Can you try again?

Your email is sadly invalid.
Can you try again?

An error occurred while registering.
Please try again