- Maintaining a many-to-many relationship using a composing gateway service.
CAVEAT LECTOR: This is just an example of one design choice, which is almost certaintly not appropriate for all systems. Consider your use-case and maintenance budget carefully before using this design.
Recently, microservice architectures have been getting a lot of hype. Their merits and flaws have been thoroughly discussed elsewhere, so I won't revisit them here. One thing that I've been asked is how to maintain entity relationships across microservice boundaries. The issue is keeping both sides of the relationship in-sync whenever entities are owned by separate services.
To illustrate, consider two microservices, an Author microservice and a Conference microservice. Authors attend Conferences, and their attendance can be modeled as a many-to-many relationship. If we were modeling Authors and Conferences in the same database, the foreign-key relationships might look something like this:
TABLE: Authors
id: UUID -- Primary Key
TABLE: Conferences
id: UUID -- Primary Key
TABLE Attendance
author_id: UUID -- Foreign Key (Authors.id)
conference_id: UUID -- Foreign Key (Conferences.id)
In a microservices world, sharing a database decreases scalability, so we would prefer to keep the Authors and Conferences tables in isolated instances. The presence of the Attendance table causes a bit of trouble here, as we cannot maintain foreign key relationships across database instances. We could assign the Attendance table to either the Author or Conference microservice, but whichever service we choose, we will lose the ability to maintain a foreign key with one of our entities.
Concerns about referential integrity aside, we would also prefer each microservice to have enough data to perform joins without having to coordinate with its peers. Communication among microservices in the same layer quickly gets out of control. This leads us to maintain a copy of the Attendance table in each microservice, and keep the two synchronized via an external controller which composes the Author and Conference microservices.
Below is the schema we have decided on.
DATABASE : Authors DB
TABLE: Authors
id: UUID -- Primary Key
TABLE Attendance
author_id: UUID -- Foreign Key (Authors.id)
conference_id: UUID
DATABASE: Conference DB
TABLE: Conferences
id: UUID -- Primary Key
TABLE Attendance
author_id: UUID
conference_id: UUID -- Foreign Key (Conference.id)
This repository demonstrates a way to achieve eventual consistency of a many-to-many relationship using non-blocking I/O, and a discipline for querying this data which makes it appear strongly consistent. At a high-level, both the Author and Conference services each expose internal endpoints for creating an Attendance record. A Gateway service exposes an external endpoint for creating an attendance record. The gateway internally calls the endpoints at the Author and Conference service and handles any error conditions that may arise.
These error conditions are expected to be common. A request to insert the Attendance of an existing Author at a
non-existent Conference would yield a successful INSERT
in the Author.Attendance
table, whereas the corresponding
INSERT
to the Conference.Attendance
table would fail. The gateway sees these failures as HTTP response codes, and
then rolls back the successful request by performing a DELETE
on the Authors.Attendance
table, returning a
404 Not Found
response back to the client.
If the above error-handling strategy makes you queasy, you're not alone. After the successful INSERT
into
Author.Attendance
, it is possible that some query to the Author
service may yield a result which refers to a
non-existing Conference
. However, if we design our Gateway to perform its queries carefully, we can avoid these
dangling pointers before we return a message back to the client.
Our gateway will make simultaneous requests to the Author and Conference microservice for each query which involves the Attendance table. In case a spurious entry is requested by a client, the corresponding microservice will detect it, and the Gateway will handle it. As a bonus, we have an API which always eagerly fetches details of the entities it returns, thus cutting down on the number of round-trips expected from the client.
Let's consider the possible outcomes when each microservice contains spurious entries. There are two types of queries we would like to perform on the Attendance table. Ignoring joins, they look like this:
SELECT * FROM Attendance WHERE author_id = :id
SELECT * FROM Attendance WHERE conference_id = :id
In addition, there are two microservices these queries could be executed in. Symmetry will allow us to ignore two of the total four cases.
In this case, we want to learn the Conferences attended by a certain Author. To achieve this, we
- Ask the Author Service for the Author details for this author id, and
- Ask the Conference service for the list of Conferences which this author has attended.
If the Author.Id is incorrect, our query (1) will fail due to foreign key constraints in the Author table. We are further assured that every conference we return from query (2) is correct, by the foreign key constraints on the Conference table.
In this case, we want to learn the Authors who have attended a certain Conference. To achieve this, we
- Ask the Conference service for the conference details for this conference id, and
- Ask the Author service for the list of Authors which have attended this conference.
As in the previous case, if the Conference.Id is incorrect, our query (1) fails. Likewise, the Authors returned from (2) will all exist, because of the foreign key constraints on the Author table.
The last two cases "Querying Conference service by Conference.Id" and "Querying Conference service by Author.Id" are symmetric.
The tedium above can be summarized simply by the phrase "Only Ask the Owner". In our design, no microservice should ask the Author service for a list of Conferences because the Author service does not own the Conference list. In any case, this query would just dump a list of IDs in the service's lap, which would force the client to follow up by sending extra requests. Writing a microservice to make simultaneous request in this constrained way avoids these issues and ensures data integrity.
While this technique works, maintaining a many-to-many relationship across a microservice boundary is not a decision to be taken lightly. This should only be done for relationships whose queries are expected to remain extremely simple. As richer queries begin to be added, the benefits gained by separating the Authors and Conferences tables begin to outweigh the complications. To accommodate richer queries, additional data will need to be added to the join table. In the design used here, the task of maintaining this data falls to the Gateway service. This decision should be reconsidered in the context of richer queries, and weighed against the added complexity of peer-to-peer communication among the Author and Conference service.
You may be concerned that the load across the system appears to be doubled in the scheme we have described. Two services are needed to handle a single request, and we would be justified in asking if we can do any better. Of course, we can, but the added efficiency comes at additional complexity (see two-phase commit), or additional hops (see daisy-chaining).
The remainder of the README provides a walkthrough explaining the implementation. It makes a few low-level design choices you might not want to make in a Production service. This is a conscious choice, since the point is to make it easy to see how the services coordinate.
The code is organized into three microservices and a shared common library.
-
author-service: Provides RESTful CRD for Authors.
-
conference-service: Provides RESTful CRD for Conferences.
-
gateway-service: Composes endpoints exposed by author-service and conference-service to maintain the attendance relationship.
-
commons: Library containing data models and API interfaces used by both client and server as part of Micronaut's declarative HTTP client.
In the data layer, Authors and Conferences are stored in separate databases. For testing purposes, they are deployed on the same instance.
Spin up a postgres instance via Docker and make it available via localhost by running
docker run --expose 5432 -p 5432:5432 -e POSTGRES_PASSWORD=password postgres:11.2-alpine
Create the database schemas using the SQL from the conference-service and the author-service.
Start all three services on localhost.
Seed the database by executing create requests.
curl https://localhost:8070/author -d '{"full_name":"Alan Turing"}'
curl https://localhost:8080/conference -d '{"acronym":"SWAT"}'
Note the Location
header from the above request, it will be needed to create an Event
below.
curl https://localhost:8080/event -d '{"conference_id":"${CONFERENCE_ID}", "seq":1}'
Now record Alan Turing's (fictional) attendance at the first SWAT conference using the POST request below.
curl https://localhost:8443/event/${EVENT_ID}/attendance/${AUTHOR_ID} -X POST
Experiment by using non-existent values for EVENT_ID and AUTHOR_ID and check the logs of each service to note the response.