“Make a bajillion dollars writing a web service” they say. “Microservices are the modern design pattern” can be found on numerous tech blogs. There is a lot of hype for “web services”.
If you are a software engineer looking to stay up to date you are likely being bombarded with business people, and “tech authorities” pushing you to build web services. Their reasoning isn’t necessarily incorrect, but the conversation is not including consideration of when you shouldn’t build a service. There is a more effective design that gets you what you want, and it isn’t new.
So you have a system you are building, and this system executes compute operations based on user (or other agents) requests. It doesn’t matter the specifics, just that a computer somewhere needs to execute a series of actions. Where should this occur?
As a good engineer you are of-course designing towards high availability, fault tolerance, infinite scalability, security, and low latency/geographic co-location to name a few buzzwords. This can become non-trivial quickly if your service is popular. It is too bad there isn’t a magic bullet, a perfect solution that solves these problems cheaply and simply. There is! Well, sort of, with caveats that we will now discuss.
So, why not build a webservice? What is this magic bullet alternative that is promised?
It all boils down to where you choose to do the compute operation and why. You are already shipping a client library or application, why not do the operations there?
It might be easier to first explore when you should build a web service.
When should you build a web service
A web service is a good choice when your design requires one of the following.
Trust is probably the most common reason to not do the operations client side. There are two major aspects to consider when evaluating where to place an operation.
Your system can likely be disrupted by a bad actor. You need to own the system sensitive compute operations occur on such that it can’t be readily tampered with, without first compromising your system security.
Your service likely serves more than one client, and you need a way to prevent one client from accessing anothers (or internal) data. This is likely the most common reason to create a web service.
The data may not be specific to any of your clients, but represents an asset of your business to which you need to control access to. You may need to prevent any one person from accessing your entire dataset, either through rate limiting, partitioning the data, or some other criteria.
Your system may depend on several resources being in a consistent state in order to operate correctly. If you have a compute operation that exists to ensure this consistency, then you will want to prevent bad actors from tampering with it. A bad actor or environmental factor could potentially interrupt a sequence of operations that must occur with a request, breaking consistency.
If the value of your service is in the technology developed within its source code, then it may be a risk to your business to ship that technology directly to your users. If normal copyright/patent methods of protecting your IP are not applicable or effective enough compared to the business risk and secrecy is the only means of preventing someone stealing your IP, then you will want to make your service available in a way that allows you to keep control.
This isn’t a catchall reason to never distribute your IP though. If the logic of your web service is simple enough that someone can reproduce it, then they can simply copy the web API you publish and reimplement. You would be better off providing a better performing solution, and focus on having the market adopt your solution over competitors. You will maintain control of your product by controlling the distribution stream. If everyone is importing your library, or training against your application, then it will be harder for competitors to convince people to put in the effort needed to refactor their code to a competing library, or retrain their staff to a different application interface.
Your users may be making requests from hardware (IoT devices, mobile devices) with limited resources (CPU, Memory, Storage, throughput). The compute operation may be so intensive that reasonable consumer hardware could not complete it without impacting the users overall experience. Part of your services value then becomes providing sufficient compute resources to offload the operation to.
If your service involves a large dataset, it may not be feasible to transmit the entire dataset to the client for the client to parse. You would need the operations that query the data to be co-located with the data. This dataset could also be a large set of binary dependencies. If your libraries dependency closure is large, and it would be burdensome to transmit it to the client, then you might be incentivised to house the closure and co-locate the functionality as a service.
Your operation could be reasonably computed on the clients device, but the device is powered by a battery. To preserve the available power on the clients battery, it may make sense to offload the operation to compute hardware you own.
A more niche factor is you may have a compute operation that depends on a specific CPU architecture. If you can’t control what architecture your clients are using then you will need to provide your own compute hardware with the needed architecture.
If an operation must occur, and the client is not guaranteed to complete it or part of the services offering is that it ensures the operation is completed. Offloading the operation to a more robust, highly available system with automated fault remediation would be a good choice.
A simple example of this would be a message queue as a service. The client can pass a message to the queue service and the service will guarantee the message is either delivered or a fault is reported. This is good value when the client has intermittent connectivity or can unexpectedly terminate.
Distributing your logic as a library can complicate updating that logic to handle a possibly breaking change in a backend service the library consumes. You need to re-distribute it to all consumers and wait for them all to deploy the update, or you risk breaking your consumers. This is a much more delicate topic as there are many ways to engineer around this issue. One reason to possibly need to go with a service over a library is you may not want to have the backend constrained by needing migration paths. The backend service may need to support both the old and new interface during the migration period. If you own both the backend and the consumers you can potentially synchronise the update to the backend and library consumers without needing to implement transitional logic. If you don’t own the consumers but do own the backend service, then you simply need to implement the migration in its interface. If you only own the consumers then deploying transitional logic with your library is a non-issue. If you only own the library and neither the consumers of the library nor the backend service, then you will likely need to implement the library as a service so that you can own the deployment of the change to your logic and not impact the consumers.
When should you not build a web service
If none of the above criteria apply, then distributing your software logic as a library/SDK or built into a client application is hard to beat.
If your target is high availability: you can’t get more available than serving a request within the same process as where it was requested.
If your target is fault tolerance: having the requester able to instantly receive errors in its code path and deal with them within the context of where the request is being made doesn’t get any more robust. This is compared to having the requester send the request and then have to asynchronously resolve and remediate errors.
If your target is infinite scalability: you can’t scale better than having the requester provide the compute resources to serve the request.
If your target is security of the client data: you can’t get more secure than the clients data never leaving their device.
If your target it low latency/geographic co-location: you can’t get more co-located than serving the request in the same memory allocation as the requester.
A services interface or API is a contract between you and your clients. Generally once you publish an API, you can’t change it without impacting your clients. Even if you are good at not making breaking changes, your service will undoubtedly exist in an environment with numerous dependencies that are changing. This means that even though the API doesn’t change, the services implementation and its behaviour does.
Unless you are willing to maintain multiple instances of your service for each version of its entire dependency closure (which can get expensive/infeasible quickly), then you can’t version your service. If you change your service, even in un-anticipated ways, it can impact the clients. They have no way to version their dependency on you. A library on the other hand can be versioned, including its entire dependency closure. The clients are free to upgrade when they are ready, and able to re-test their integration for impacting changes.
Services require supporting infrastructure. If you are choosing to do the compute operations on systems you own, then you need to own those systems in some way. You can outsource the hardware and much of the orchestration, but you still need to own it and ensure it is secure, maintained, and healthy. With a library, the client provides the compute infrastructure.
Factoring your design
Some of your services components will likely meet one of the criteria that justify it as a service. You can still gain the benefits of a library while also implementing a service. This is where microservices shine. Boil your services logic down such that all operations that can be done client side remain there, only sending requests to a service when the client is not suitable.
This also applies to internal services talking to each other. Does it make more sense for these services to talk to a new intermediate service rather than just importing a new library? Always adding libraries to a existing service can result in monolith services being built. These are generally seen as compromising to a high availability model. Monoliths can also be unwieldy when updating a deployment due to a change in one of the libraries. This is generally a symptom of bad CI/CD or unit testing. If you opt for a service over a library to maintain some level of isolation of your operations, then you are just trading unit tests at build time for integration tests at deploy time.
You might try to make the argument that there should be a separation of concerns between services. When comparing a service to a library, the division is there either between the service interface or the library interface. With a service you are only trading a stack push for a network packet.
This isn’t an argument for monolith services or advocating to always build libraries over services, it is an argument that you should consider where you are locating the compute operations and why. Hopefully you take these considerations into your next design meeting, and if you decide to build a library, maybe open-source it.