Design Microservices Architectures The Right Way
Source
This is a great video. Below are my notes on it.
Intro
Could you change this URL from
https://foo.com/latest.bar.js
to
https://foo.com/1.5.3/bar.js
?
-> Sorry, that would take weeks. We don't have the resources to do that.
The above is not what we want.
Great architecture
- Scales development teams.
- Delivers quality.
- Allows us to make choices: high performance or low cost.
- Supports future features naturally
- This is a hard one.
- Do we have a good design? I don't know, I'll let you know in 3-4 years when I know the result.
No so great architecture
We trade near term velocity for future paralysis.
Misconceptions
1. Microservices enable our teams to choose the best programming languages and frameworks for their tasks.
Reality: this is very expensive. Look at Google, they have 20-30 thousand engineers and they use 8 programming languages. So a good metric is 1 programming language for every 3000 engineers.
2. Code generation is evil
Reality: it's just a technique. What's important is to create a defined schema that is 100% trusted.
3. The event log must be the source of truth
Reality: events are critical parts of an interface. But it's okay for services to be the system of record for their resources. Which means we receive a REST request to create a user, then we create the user, but then we guarantee these messages are going to end up in the event stream.
4. Developers can maintain no more than 3 services each
Reality: wrong metric, use automation.
Architecture of Flow.io
- More than 100 microservices.
- Every micro service has a REST and Event interface.
- All APIs are public.
API definition
{:user {:description "Represents a single user in the system"
:fields [{:name "id"
:type :string}
{:name "email"
:type :string
:required false
:annotations [:personal_data]}
{:name "name"
:type :name
:annotations [:personal_data]}
{:name "status"
:type :user-status
:default :active}]}}
{:user-form {:fields [{:name "email"
:type :string
:required false
:annotations [:personal_data]}
{:name "password"
:type :string
:required false
:annotations [:personal_data]}
{:name "name"
:type :name-form
:required false
:annotations [:personal_data]}]}}
The resource is :user
and to create a resource you use a form which by convention is :user-form
.
{:resources {"io.flow.common.v0.models.user" {:operations [{:method :get
:description "Returns information about a specific user."
:path "/:id"
:responses {200 {:type "io.flow.common.v0.models.user"}
401 {:type "unit"}
403 {:type "unit"}}}
{:method :post
:description "Create a new user. Note that new users will be created with a status of pending and will not be able to ... Flow team."
:body {:type :user-form}
:responses {201 {:type "io.flow.common.v0.models.user"}
401 {:type "unit"}
422 {:type "io.flow.error.v0.models.generic_error"}}}]}}}
APIs are first-class, which means they're in their own dedicated git repository. To modify an API you open the repository and make a PR. When you create the PR the CI will validate the definition. This also runs linter which validates things as:
- all path must be lower case
- typos
- it should feel like one person wrote the entire API, which makes consistent
The CI should verify if there are breaking changes:
- [BREAKING] 'lastName' was added as a required field for user
Code generation
Use a CLI tool to generate the scaffolding:
apibuilder update --app user
The goal is not to make things "possible", the goal is to make the code generation so nice that a developer will love using it.
Database Architecture
Each micro service has its own database. Others connect to it using API and Events.
Use a single CLI called dev
which is what developers will use.
They have a hash_code
column on the database that is used to compare if something changed or not.
The code generation is smart enough to know that the email
field has an index so it generates a findByEmail
method.
Continuous Delivery
- Deploys are triggered by a git tag
- Git tags created automatically by a change on master (e.g: merge PR)
- 100% automated, 100% reliable
Events
Producers
- Create a journal of ALL operations on table (user table has a user_journal).
- Record operation (insert, update, delete).
- After inserting on the journal send an event.
- Enables replay by simply requeuing journal records.
Consumers
- Store new events in local database, partitioned for fast removal.
- On event arrival, queue record to be consumed.
- Process incoming events in micro batches (by default every 250ms).
- Record failures locally.
- Publish these failures to a monitoring system.
Summary: Critical Decisions
- Design schema first for all APIs and Events
- consume events (not API) by default
- Invest in automation
- deployment, code generation, dependency management
- Enable teams to write amazing and simple tests
- drives quality, streamlines maintenance, enables continuous delivery