Building a great API is not trivial, you have to design the data structure, choose the right connection mechanism, implement handlers for every endpoint, serialize/deserialize the data and validate it before accepting it in your business logic.
In addition to that, it is often necessary to build API clients for front-end applications or for other services in order to make use of the functionality offered by your service.
If you use GraphQL, the second part can definitely be simplified, as you can use any standard GraphQL client to query the system, but manually building and maintaining schemas and resolvers is still such a hassle that some teams might prefer to stay in REST.
Boost your API with CQRS!
The Booster Framework introduces a very different approach to building APIs: not doing it at all!
This is only possible thanks to the opinionated implementation of the CQRS pattern and Booster's inference capabilities. Let's walk through the structure of a typical Booster application before going back to APIs.
In a Booster application, most functionality is defined in commands. A Command
is a data structure that has a handler, like this one that processes a backflip:
@Command({ authorize: 'all' })
export class PerformABackflip {
public constructor(
readonly personName: string,
readonly personAge: number
) {}
public static async handle(command: PerformABackflip, register: Register): Promise<void> {
const luck = Math.rand()
if (command.personAge > 35 && luck > 0.5) {
register.events(PersonSurvivedABackflip(command.personName)
} else {
callAnAmbulance()
register.events(AmbulanceCalled(command.personName)
}
}
}
When a command handler is processed, it finishes by writing one or more events into the event store. In this case, a PersonSurvivedABackflip
event for lucky or young people, or an AmbulanceCalled
for the rest of us π
.
An Event
is just a data structure that looks like this:
@Event
export class PersonSurvivedBackflip {
public constructor(
readonly personName: string
) {}
/* We define the `entityID` method to help Booster match
the event with the corresponding entity */
public entityID() {
return this.personName
}
}
These events are later reduced into entities that represent the current state. The reduction is carried out by reducer functions that look like these:
@Entity
export class Person {
public constructor(
readonly name: string,
readonly backflipTrials: number,
readonly backflipSuccesses: number,
) {}
@Reduces(PersonSurvivedBackflip)
public static reduceSurvival(
event: PersonSurvivedBackflip,
currentPerson?: Person
): Person {
return buildNextPersonObject(currentPerson, true)
}
@Reduces(AmbulanceCalled)
public static reduceFailure(
event: AmbulanceCalled,
currentPerson?: Person
) {
return buildNextPersonObject(currentPerson, false)
}
private static buildNextPersonObject(
currentPerson?: Person,
success: boolean
) {
const trials = (currentPerson?.backflipTrials ?? 0) + 1
let successes = (currentPerson?.backflipSuccesses ?? 0)
if (success) { successes++ }
return new Person(
event.personName,
trials,
successes
)
}
}
Securing queries with Read Models
At this point, the CQRS design helped us to separate the data schema that the system accepts (commands) from the state data schema (entities). This in itself already simplifies the API design: The API schema could just match the command and entity schemas, and we could call that a nice API. But before calling it a day, we need to make an extra consideration; Allowing direct API access to entities would mean no restrictions on data access, so private fields like password hashes or bank accounts would become accessible. That's why Booster adds Read Models to the mix.
ReadModels
are eventual consistent caches of the internal state. They're not only a way to filter which fields you want to make accessible, but they can also aggregate related data or make small data transformations to optimize reads. Accessing read models is highly performant and they're updated in real-time when data changes, pushing these changes to the client applications.
A typical read model that projects a single entity would look like this:
@ReadModel({ authorize: 'all' })
export class PersonReadModel {
public constructor(
readonly name: string,
readonly backflipTrials: number,
readonly backflipSuccesses: number,
) {}
@Projects(Person, 'name')
public static projectPerson(
entity: Person,
currentState?: PersonReadModel
): ProjectionResult<PersonReadModel> {
return new PersonReadModel(
entity.name,
entity.backflipTrials,
entity.backflipSuccesses
)
}
}
Inferring APIs from code!
At this point, we could build a very nice, useful, and secure API by just copying the schemas from the classes decorated as Command
or ReadModel
, and that's exactly what Booster does for you!
Booster analyzes the class structure of all classes decorated as @Command
or @ReadModel
in compile-time, generating metadata that is used in deploy time to generate a GraphQL schema and provision all the cloud resources required to make the application work, including API gateways, lambda functions, containers, permissions, and even the database tables to store the events.
For some people, this might look similar to maintaining the schema in a regular GraphQL schema file, because, at the end of the day, you're still expressing the schemas as Command
and ReadModel
classes, but Booster brings many extra advantages to the table:
- Commands, Read Models, and all their usages are type-checked in compile-time, reducing or eliminating the likelihood of making mistakes.
- No errors can be introduced when serializing/deserializing data because this is done transparently by the framework.
- When you update a command or a read model, the API is updated automatically, you don't need to change any other files.
- Resolvers are hidden under higher-level abstractions like commands or read models, so you don't need to deal with low-level protocol nuances.
- All Read Models support WebSockets by default, so there is no need to implement any extras for real-time support in your applications.
To summarize, by writing highly semantic code and letting the machine do the heavy lifting, Booster allows you to build fully functioning real-time APIs in a breeze, making everything else work out of the box, and saving a ton of time that you can use to add new use cases, write better tests, or manage elusive corner cases.
Try it!
Booster is a 100% open-source project developed by The Agile Monkeys. You can get its full potential for free and with no hidden fees. The typical Booster application can be run on the free tier in AWS, but it also has experimental support for Azure and Kubernetes, so you can run it locally using Minikube.
Learn more about Booster on the official website, the documentation, or the Github project.
If you try it out, be sure to let us know what you thought of it on the projectβs Discord channel!
Top comments (0)