In a previous blog post I said I would provide an overview of how I structure the Cloud Native Applications that I build. As always with software, there are a ton of different ways to do this. My approach is only my approach, you might find something here that helps you or you might be offended by the way I do things.
I think a lot of people have come to realize there are some tremendous benefits to Cloud Native Development - scalability, availability, speed, etc. I'm not going to talk about that stuff in this post. Instead, I want to focus a bit on the key negative of Cloud Native Development and that is building an application that is tied to a Cloud vendor. I have always felt a little icky about it but in all of my cases the benefits have outweighed that negative, but I do recognize that I am typically only around for a part of a project. I'm likely not there when a team starts to think about re-homing or re-platforming their Cloud Native application.
As cloud adoption has started to slow I think Cloud users are starting to internalize the true cost of running in the cloud long-term. For some companies building applications in a way that makes them portable might have a real economic impact.
Sky Computing also provides another interesting angle on building applications that are cloud agnostic. The idea with Sky Computing is that you don't think about the platform you are running on and that there is something between the user and the cloud that makes the choice, typically based on cost. I don't that anyone is serious entertaining that idea for Cloud Native applications but it is an interesting idea.
The last thing that runs through my mind as relevant for this topic is what it means to really be Cloud Native. When I use the term I am thinking specifically about function based applications, not container based applications. You may think of container based applications as being both Cloud Native and portable, and I don't completely disagree. While I typically work from a function based approach I am interested in building application that are portable between these two scenarios. That is, if build an application that is backed by functions today I might want to move it to containers in the future.
With all that in mind you need to understand that I DO NOT build applications that are Cloud Native and portable. What I try to do is structure my applications in a way that they could be ported with some amount of, not overwhelming, effort. What follows are my thoughts on the way I structure the Cloud Native applications that I build.
Imagine a typical web or mobile application with auth, database, storage, etc. This is how I structure that application.
The result is two Cloud agnostic layers - UI and Business Services, and two Cloud specific layers API, Cloud Services/Repos.
For me, the most important abstraction lies between the frontend and the backend, or between the UI and the API. This provides total flexibility in how you build your UI, and how you rebuild your UI. For this reason, I try to stay away from the cloud specific client libraries whenever possible. In my experience the UI is the most commonly refactored part of an application, and it is the most likely part to have multiple active instances.
I don't object to using either REST or GraphQL but the interface itself should never expose anything Cloud specific, but the technologies used to build the API are Cloud specific. These days I define everything related to the API using AWS CDK. I work almost exclusively with AWS and I talked about my preference for using CDK in a previous blog post. I can define the API interface itself, characteristics about the interface, permissions, authentication, link to handlers, and leverage ESBuild to bundle all of the downstream code behind the API all very simply and reliably with CDK.
One place in the application where there is some cloud specific code written is in the API handler itself. It needs to extract, and validate, relevant data from the API call, so that it can be passed to a Business Service. It is also sometimes where a specific API response is formatted.
I have toyed with the idea of what it would take to replace this layer if I had to move to another platform and it feels manageable. For example, if I wanted to move to containers I would likely swap out all of the API definition in CDK, and the API handlers to build effectively the same thing in software using a framework like Express.
Business Services are called by the API. The API handler accesses a Business Service via IoC (more on this below). This means the API handler doesn't know anything about a Business Service and visa versa. A Business Service is responsible for executing any required business rules. If it needs anything from a Cloud specific Service or Repo it will access it via an interface exposed by the service or repo. This interface also leverages IoC such that a Business Services know nothing about a Cloud specific Service or Repo. This follows a pretty common application paradigm where by a Business Service might talk to a Repository as the interface to a database.
One of the difficult considerations for any application is authorization. That is, limiting what a user can do in an application. There are cloud specific ways to expose this but my preference is to bake that into the Business Services layer.
Cloud Service / Repo
A Cloud Service / Repo is the interface to a Cloud Service. For example, with AWS it might be Cognito, DynamoDB, S3, etc. A Cloud Service / Repo leverages Cloud specific SDKs to do what it needs to do. However, a Cloud Service / Repo still exposes a general interface via IoC. That is, a Cloud Service / Repo does not expose anything cloud specific. This makes it possible to change the underlying Cloud Service without changing what the Business Services knows about the underlying Cloud Service, which is nothing. This can include things like changing out a data source, for example moving from a NoSQL to SQL solution without the business logic side of the application knowing. In the event that the Cloud Service needs to be changed the cloud specific code would need to be rewritten but the rest of the application remains intact.
A Quick Note on IoC
If you are not familiar with IoC it stands for Inversion of Control and it is all about the dependency inversion principal. This means we create interfaces as the dependency between components while the implementation details are defined and integrated separately. As a very simple example, it might mean a Registration Business Service is calling a Cloud Specific Service, like AWS Cognito, to create a user but all it knows it is leveraging a service with a function that requires username and password, the details are hidden and irrelevant. If the implementation details of how the user is created were changed the Registration Business Service would be none the wiser.
The one thing you have to get comfortable with when using IoC is that it does add more boilerplate to an application. It would be easier, and faster, to directly integrate your dependencies but you would lose some amount of portability which would result in more time and effort if you ever needed to move to another platform. If you are familiar with Redux but refused to adopt it because the additional boilerplate (no longer a thing given the Redux Toolkit) then IoC might not be for you.
The other benefit I have seen with IoC is more options for unit testing. For example, you can very easily isolate and unit test your Business Services without having to deal with dependencies.
My tool of choice these days for IoC is InversifyJS but there are a lot of different IoC tools out there. When I did my research (a couple years ago now) Inversify seemed to be the most popular and active (I don't know if that is still true). I have used it successfully on a number of projects at this point and really like it.
Hopefully, this quick summary makes sense. The underlying idea is to try to make the Cloud Native Application portable with a reasonable level of effort.