
In the last post we built up two Frontend Apps, one using Next.js and another using Leptos (Rust/WASM). In this post we will be building an API that our Apps can talk to. See the full overview of posts here.
At the end of this post we will have:
There is quite a lot to cover. My recommendation is to clone down the Part 4 branch in the GitHub repository and use this post as an explanation of what is set up.
If you are new to schema federation, then I recommend reading the Apollo Federation docs. In short, schema federation is a way to compose multiple GraphQL schemas into a single schema. This is done by defining a @key directive on types in each schema, which tells the gateway how to resolve references to that type (very simplified).
The problem federation solves is one of growth—both our codebase as well as our organization.
As we grow our API and our domains, they will start to become hard to keep separate in a traditional monolith architecture. We don't build on a foundation that requires good discipline, we build discipline into our constructs and foundation.
Discipline doesn't scale
- Probably someone, somewhere
Instead we want to design for a future where multiple teams will work on our services, while keeping the overhead low so that it does not slow us down while we are still small.
Our initial architecture will look like this:
graph LR
Client --> Router[λ Apollo Router]
subgraph Supergraph
Router --> Products[λ Products\n subgraph]
Router --> Users[λ Users\n subgraph]
Router --> Reviews[λ Reviews\n subgraph]
end
style Supergraph stroke:#333,stroke-width:2px,fill:transparent
Each of these services, included the Apollo Router itself, will be deployed as AWS Lambda functions. The Apollo Router is responsible for knowing how to compose each of the subgraphs, and how to resolve references between them.
We will base our services on the example subgraphs from Apollo's intro to Federation.
Our Users schema:
type Query
# Users have a name and nothing more.
type User @key(fields: "id")
Our Products schema:
type Query
# Products simply have a name and price.
type Product @key(fields: "id")
# External Type: User.
type User @key(fields: "id")
Our Reviews schema:
type Query
# Reviews are written by a User and are about a Product.
type Review @key(fields: "id")
# External Type: User.
type User @key(fields: "id")
# External Type: Product.
type Product @key(fields: "id")
All schemas extend the schema, which you may or may not need to do depending on your GraphQL tooling/server:
extend schema
@link(url: "https://specs.apollo.dev/federation/v2.0", import: )
We will start from our building blocks, which will be our subgraphs, and then build up to the Router that will compose these into the supergraph.
The Router needs an HTTP endpoint, we will use AWS Lambda Function URLs.
lib-handler
Price per 1ms (eu-west-1):
Cold-start:
Warm-start:
Cold-start:
Warm-start:
A rabbit hole/detour apollo-router-lambda.
Compare Apollo Gateway v.s. App Runner.
We have multiple environments, and multiple ways we can deploy and orchestrate our services. Instead of enforcing every deployment to make the same tradeoff between cost and performance, we want to make it configurable so that we can choose the right tradeoff for each environment.
For example:
Developer, Preview, and Integration TestProduction Multi-tenant and Production Single-tenantTo help facilitate this we will create a few helper functions and types that allow us to construct our configurations safely.
Let's first get the types out of the way, create a new file deployment/lib/types.ts:
/**
* Make the possible environments available during runtime by constructing them
* as a const array.
*/
;
/**
* The possible environments as a type, inferred from `validEnvironments`.
*/
;
/**
* Mapping between environment and configuration. The `Base` configuration is required, but
* the rest are optional and will fall back to `Base` if not specified.
*/
;
;
;
;
;
There's a lot going on, but all you need to care about is that we very specifically make sure only a valid configuration can be constructed.
Finally, we'll add some helpers to make it nicer to work with the configuration within our deployment files. Create a new file deployment/lib/helpers.ts:
;
;
/**
* Resolve the configuration for the current environment and fall back to
* the `Base` environment if no explicit configuration is found.
*/
;
/**
* The configuration for the current environment.
*/
;
/**
* Construct a type that becomes concrete based on which record is passed in. This
* utilizes discriminated unions so that we can make the input type of `stackFn` dependent
* on the input of e.g. `name` and/or `runtime` in the `setupSupergraph`/`setupApp` functions.
*
* Example:
* ```ts
* const setupApp = <N extends App['service']>(name: N, stackFn: (appConfig: Specific<App, { service: N }>) => void) => {
* // ..
* }
* ```
*/
;
/**
* Convenience function for looking up relevant Supergraph configurations and setting
* up a supergraph along with its routes.
*
* Example:
* ```ts
* setupSupergraph('router', 'lambda', supergraphRoutes, (config) => {
* const supergraph = new lambdaFn.Stack(this, 'MsRouterLambda', {
* ...props,
* functionName: 'ms-router',
* assets: 'artifacts/ms-router',
* billingGroup: 'ms-router',
* architecture: lambda.Architecture.X86_64,
* environment: {
* ...subGraphUrls,
* },
* });
* // ..
* return config?.pinToVersionedApi ? supergraph.aliasUrlParameterName : supergraph.latestUrlParameterName;
* });
* ```
*/
;
/**
* Convenience function for looking up relevant App configurations and setting
* up the App stack.
*
* Example:
* ```ts
* setupApp('internal', (appConfig) => {
* new s3Website.Stack(this, 'WebsiteUiInternal', {
* ...props,
* assets: 'artifacts/ui-internal',
* index: 'index.html',
* error: 'index.html',
* domain: `${appConfig.subdomain}.${props.domain}`,
* hostedZone: props.domain,
* certificateArn: props.certificateArn,
* billingGroup: 'ui-internal',
* redirectPathToUrl: supergraphRoutes,
* });
* });
* ```
*/
;
Let's break down what we've got:
resolveConfig: Figure out which configuration to apply, and validate we didn't accidentally pass in something that can't exist.config: Resolve and return the correct configuration for the current environment.setupSupergraph: Helper for conditionally setting up a Supergraph stack, passing in the specifically narrowed down type for the Supergraph.setupApp: Helper for conditionally setting up an App stack, passing in the specifically narrowed down type for the App.Now all that's left is to create our actual config, which is the one we'll be adjusting to our own needs. Create a new file deployment/config.ts:
;
;
;
;
We configure our Base environment to use a Lambda Gateway which is cheaper, but we then opt for performance in our Production environments with the App Runner-based Apollo Router.
Next up is to use our new API from our existing Frontend Apps! Follow along in Part 5 of the series (will be posted soon).