This is an article that describes my vision of building a system that actively uses Go as the main programming language and SOA/microservices as a design paradigm.
Here I will try to cover 4 chapters that together allow us to build a solid and reliable system.
1. System design
Let's start from the global view of a system that can handle web requests. This schema shows which layers our system has.
On the left side, we can see Users. They can be real persons or third-party machines that use us as a provider of some service. They interact with the Clients layer and in some cases with the API layer.
The Clients layer contains applications like FrontOffice, BackOffice, iosApp, etc. Those applications can be written in JS/Kotlin/Swift/etc. Generally, they're UI applications. This is what is called FrontEnd. The purpose of this layer is to provide a good user experience.
The API layer receives requests from the Clients layer or directly from the Users. This layer contains the API Gateway(GTW) and SingleSignOn(Auth) services. The API Gateway allows us to terminate SSL Requests, do caching, do authentication/etc. The SSO(Auth) service encapsulates information about users, roles, and permissions granted to entities and operations. It can use RBAC/ABAC approach. When we receive a request in GTW we retrieve information about the user, its roles, permissions from SSO and inject this information into the request and pass it to the next layer.
The BFF(BackendForFrontend) layer contains specific APIs that were built for specific clients cause sometimes we want to have different behavior in different clients. We can create a separate API for the ios application or for the integration with a big third-party client. If we don't need to have some specific behavior for clients we can use Generic API for simplicity.
The Deep Backend layer. This is the place where general magic happens. This block contains our services that handle business needs. They receive requests from BFF and go to the External Service Gateways. This layer cannot interact with the external world directly. Deep Backend contains some business services and some utility services.
Lock Coordinator is a service using for distributed transactions(should be avoided as much as possible) and distributed locks(when you want to guarantee that some operation with the specific parameters will not be executed multiple at the same time).
Feature Hub is a service using for enabling or disabling some functionality of our system. It's a single point of this truth that can say is a specific feature is enabled or not. We can even build AB/MV tests here. In some cases, this service can be moved to the API layer.
Object Storage is a service using for file handling. This is the only place where our files are stored. It can be backed by S3/EFS/etc. If some service wants to store or retrieve a file it should interact with this service.
Message Bus is a service(it can be just a Kafka cluster ) using for asynchronous communications. Every service can send events(something that happened) or just async request/response to communicate with another service.
The External Service Gateway layer sends requests out of our system. When we have the integration with a third-party service we place a service here. For example, if you want to communicate with Google you place google-eg svc here. When your services want to communicate with Google they come to this service and it goes to the real world. This approach allows you to mock this part of request execution in some cases and switch to another provider in a short period of time if it's needed.
2. Service structure
Having looked at our system from a higher perspective, we can now dive into services and their structure.
It is recommended to put the service source code into a separate directory and the API of this service into a directory with the _sdk suffix.
The SDK repo contains information about how other services can communicate with ours.
We put the interface of the service into the proto directory and generate clients for other languages by Makefile.
The service is placed in another directory. This design allows clients to include only the part with the interface and not the whole service.
3. Basis repo
Sometimes we have the functionality that can be shared between services. This is the basis repo. The basis is a set of utility libraries like a framework.
It can contain common HTTP-middlewares, logger, RDBMS-connectors, etc.
It can be mono-repo like /basis or split into multiple repositories like /basis-core, /basis-network, /basis-storage, etc.
Creating this repository allows us to extract common modules and reuse them. At the same time, this block of code can be transferred to the support of the infra team.
4. Useful patterns
1) When we have a system where every service goes to another service we have a distributed monolith. It means that our system is very unstable. When one service goes down it can affect multiple parts of the system.
The better way is to use the CQRS pattern. When we update some data in some service that is the master of those data we produce an event. This event describes what was changed.
Other services listen to this event and update their copy of the data. When they need to fetch some information they don't go to the master-service they go to their own copy. It reduces network calls and improves the stability of the system.
Obviously, we may have some delay between updating data in the main service and copies of the data, but generally speaking, we have this delay even when we have a synchronous call. Once you have received the data from the main service, it can be updated and you will have an outdated version of the data.
2) When we need to produce some events/messages from our service It's recommended to use the transactional outbox pattern.
We can use this pattern even if we need to do some synchronous calls. For example, we want to update some information in another service after some action.
In this case, we can run this call on hit and lost this call if the second service is in downtime (of course we can use retries but we don't know when the target service will be ready) OR we can create a task in our database with the specific type and parameters and perform this task in the background. if we cannot perform the task we just return it to the queue. The advantages of this pattern are in the use of transactions and therefore in maintaining consistency.