Documentation
Introduction
This comprehensive setup provides a solid foundation for building compositional, safe, ergonomic and event-driven applications. It includes an Restaurnat Ordering System example, demonstrating how to implement a domain model and how to run it on the edge (as edge functions).
Fmodel aims to bring functional, algebraic and reactive domain modeling to Kotlin. It is inspired by DDD, EventSourcing and Functional programming communities, yet implements these ideas and concepts in idiomatic TypeScript.
FModel promotes clear separation between data and behaviour.
Data:
- Command - An intent to change the state of the system.
- Event - The state change itself, a fact. It represents a decision that has already happened.
- State - The current state of the system. It is evolved out of past events.
Behaviour:
- Decide - A pure function that takes command and current state as parameters, and returns the flow of new events.
- Evolve - A pure function that takes event and current state as parameters, and returns the new state of the system.
- React - A pure function that takes event as parameter, and returns the flow of commands, deciding what to execute next.
Why Supabase
Supabase is an open-source alternative to Firebase that provides a complete backend solution for your applications. It offers a range of features that make it a powerful and flexible choice for your development needs.
Scalable and Reliable
Supabase is built on top of PostgreSQL, one of the most robust and scalable databases available. This ensures that your application can handle growing amounts of data and traffic with ease.
Open-Source and Customizable
As an open-source platform, Supabase allows you to customize and extend its functionality to fit your specific needs. You can also contribute to the project and help shape its future development.
Comprehensive Feature Set
Supabase offers a wide range of features, including authentication, real-time updates, storage, and more, all in a single platform. This helps you save time and reduce the complexity of your application.
Cost-Effective
Supabase`s pricing model is designed to be more affordable than traditional backend services, especially for small to medium-sized projects. This makes it an attractive option for startups and independent developers.
Features
Supabase is using Deno as a serverless runtime for executing edge functions in a secure and scalable way.
You can implement three types of edge functions in your domain model:
Command Handlers / Decider
The command handler is responsible for handling commands and producing new events/decisions that will be stored in `events` table.
Your main responsibility is to implement the decider / A pure command handling algorithm. It does not produce any side effects, such as I/O, logging, etc. It utilizes type narrowing to make sure that the command is handled exhaustively.
export const restaurantDecider: Decider< RestaurantCommand, Restaurant | null, RestaurantEvent > = new Decider<RestaurantCommand, Restaurant | null, RestaurantEvent>( (command, currentState) => { switch (command.kind) { case "CreateRestaurantCommand": return (currentState === null || currentState.restaurantId === undefined) ? [ { version: 1, decider: "Restaurant", kind: "RestaurantCreatedEvent", id: command.id, name: command.name, menu: command.menu, final: false, }, ] : [ { version: 1, decider: "Restaurant", kind: "RestaurantNotCreatedEvent", id: command.id, name: command.name, menu: command.menu, reason: "Restaurant already exist!", final: false, }, ]; case "ChangeRestaurantMenuCommand": return (currentState !== null && currentState.restaurantId === command.id) ? [ { version: 1, decider: "Restaurant", kind: "RestaurantMenuChangedEvent", id: currentState.restaurantId, menu: command.menu, final: false, }, ] : [ { version: 1, decider: "Restaurant", kind: "RestaurantMenuNotChangedEvent", id: command.id, menu: command.menu, reason: "Restaurant does not exist!", final: false, }, ]; case "PlaceOrderCommand": return (currentState !== null && currentState.restaurantId === command.id) ? [ { version: 1, decider: "Restaurant", kind: "RestaurantOrderPlacedEvent", id: command.id, orderId: command.orderId, menuItems: command.menuItems, final: false, }, ] : [ { version: 1, decider: "Restaurant", kind: "RestaurantOrderNotPlacedEvent", id: command.id, orderId: command.orderId, menuItems: command.menuItems, reason: "Restaurant does not exist!", final: false, }, ]; default: // Exhaustive matching of the command type const _: never = command; return []; } }, (currentState, event) => { switch (event.kind) { case "RestaurantCreatedEvent": return { restaurantId: event.id, name: event.name, menu: event.menu }; case "RestaurantNotCreatedEvent": return currentState; case "RestaurantMenuChangedEvent": return currentState !== null ? { restaurantId: currentState.restaurantId, name: currentState.name, menu: event.menu, } : currentState; case "RestaurantMenuNotChangedEvent": return currentState; case "RestaurantOrderPlacedEvent": return currentState; case "RestaurantOrderNotPlacedEvent": return currentState; default: // Exhaustive matching of the event type const _: never = event; return currentState; } }, null, );
The edge function is a thin layer that is responsible for parsing and validating the incoming request, and invoking the decider with the parsed command. You can combine many deciders in the edge function (
supabase/functions/command-handler/index.ts
) to extend its capability./** * (Deno) Edge function to handle commands in Supabase. * @param req Request/Command to be handled * @param commandSchema Command schema to be used for parsing and validation * @param decider Decider to be used for the command handling logic */ Deno.serve(async (req: Request) => await edgeCommandHandler( req, commandAndMetadataSchema, restaurantDecider.combine(orderDecider), ), );
In respect to the `combine` operation the `decider` is a commutative monoid, effectivelly allowing you to combine small deciders into one decider, in any order. The Deciders are represented in the bottom swimlane of the Blueprint (`Introduction` section).
Extend your system by adding new deciders. Maybe `accountingDecider`, `inventoryDecider`, `customerDecider`, etc.
Event Handlers / View
The event handler is responsible for handling events and and evolving the materiliazed view(s) state based on these events. They play an important role in automating integration with other systems, like payment providers.
Your main responsibility is to implement the view / A pure event handling algorithm. It does not produce any side effects, such as I/O, logging, etc. It utilizes type narrowing to make sure that the event is handled exhaustively.
export const restaurantView: View<RestaurantView | null, RestaurantEvent> = new View<RestaurantView | null, RestaurantEvent>( (currentState, event) => { switch (event.kind) { case "RestaurantCreatedEvent": return { restaurantId: event.id, name: event.name, menu: event.menu }; case "RestaurantNotCreatedEvent": return currentState; case "RestaurantMenuChangedEvent": return currentState !== null ? { restaurantId: currentState.restaurantId, name: currentState.name, menu: event.menu, } : currentState; case "RestaurantMenuNotChangedEvent": return currentState; case "RestaurantOrderPlacedEvent": return currentState; case "RestaurantOrderNotPlacedEvent": return currentState; // deno-lint-ignore no-case-declarations default: // Exhaustive matching of the event type const _: never = event; return currentState; } }, null, );
The edge function is a thin layer that is responsible for parsing and validating the incoming request, and invoking the view with the parsed event. You can combine many views in the edge function (
supabase/functions/event-handler/index.ts
) to extend its capability./** * (Deno) Edge function to handle events in Supabase. * @param req Request/Event to be handled * @param eventSchema Event schema to be used for parsing and validation * @param view View to be used for the event handling logic * @param viewStateRepository Repository to be used for the view state fetching/storing / Event handlers are always specific to the view state they are projecting. */ Deno.serve(async (req: Request) => await edgeEventHandler( req, eventAndMetadataWithViewSchema, restaurantView.combine(orderView), new SupabaseRestaurantViewStateRepository( authenticatedClient(req), ), ) );
In respect to the `combine` operation the `view` is a commutative monoid, effectivelly allowing you to combine small views into one view, in any order. The Views are represented as green sticky notes on the Blueprint (`Introduction` section).
Extend your system by adding new views. Maybe `accountingBalanceView`, `inventoryAvailableView`, etc.
Query Handlers / View
Query handlers are responsible for handling queries and producing the response based on the current state of the materilized view. As your event handler is responsible for evolving the state of the materilized view, the query handler is responsible for querying the materilized view state. These two components are tightly coupled, and are often implemented in the same edge function. We have choosen to separate them for clarity.
The query handler edge function is located in
supabase/functions/query-handler/index.ts
Installation
The project sutructure includes two main parts/directories:
- supabase: Supabase/Postgres SQL migrations and TypeScript/Deno edge functions.
- fmodel-admin (THIS APPLICATION): NextJs 14 (Tailwind, shadcn/ui) admin application to control the design of your application, browse events and manage event handlers. It also alows you to send commands, events and queries to (restaurant/order management) Edge functions, directily from the Admin console.
To get started with our product, follow these simple steps:
Step 1: Run the Supabase and Edge Functions
The start command uses Docker to start the Supabase services:
supabase start
Serve your functions locally:
supabase functions serve
You can use the
supabase stop
command at any time to stop all services:supabase stop
You can now visit your local Supabase Dashboard at
http://localhost:54323
.Step 2: Run the Admin application
Navigate to the
fmodel-admin
folder.Rename the
fmodel-admin/.env.local.example
tofmodel-admin/.env.local
, and specify your `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_ANON_KEY` variables. Yourapi url
andanon key
will be printed in the terminal as a result ofsupabase start
command.Run it:
yarn dev
The application will be available at
http://localhost:3000
.
Usage
Once you have the application installed, you can start using it to its full potential. Here are some tips to get you started:
Sign in / Sign Up
Sign Up a new user on the log-in page, and login to the application. It will auto-confirm the user in the local development environment. In production environment, the user will receive an email with a confirmation link!
Register command handler/decider
Register a demo restaurant/order management decider, that will be used to demonstrate the event sourcing and event streaming capabilities of the application.
This page allows you to register a new decider by its name, and event types that it can publish. This way you control the events that can be published by the decider, so bad actors can not publish events that are not allowed.
Demo Edge Functions
Navigate to the
Demo Functions
page , and start issuing the commands, events and queries.This page allows you to send commands, events and queries to the edge functions (command/event/query handlers). It demonstrates how to interact with the edge functions.
Statistics
Navigate to the
Statistics
page, and observe business metrics.This page demonstrates how to analize your event store and view the business statistics. It is a good starting point to understand the data that is stored in the event store. For example you can count the number of events daily, weekly, monthly, etc. Would you like to know how many orders were placed in the last 24 hours or every month of January? This is the place to start. It is supported by TimeScaleDB extension (apache 2.0).
Register event handler/view
Register your first `view`, that will stream your events to event handler(s).
There are two types of views: regular and scheduled.
Regular views are used to stream events to the event handlers. In this case event handlers are responisble to pool the events, and process them. Check the
stream_events
SQL function in `20231223160230_event_streaming_api.sql`. It is used to stream events to the concurrent consumers/views.Scheduled views are extending the regular views with schedule expresion and edge function (event handler) URL to be called. The Cron job will use the same
stream_events
SQL function and call the edge function URL to push events to it. The edge function will process the events. Observe the previous image with the cron scheduled on every second, targeting your local event handler.Event handlers are not running in the context of the user, but in the context of the service role. This is important to note. Navigate to the `supabase/migrations/20231231180033_extensions_cron.sql` and change the Authorization Bearer key to match your local ANON KEY, in the `extensions.schedule_events` SQL function!
Database
Database changes are managed through migrations
. This way, you can keep track of all changes to your database schema.
These migration scripts are foundational to the event sourcing and event streaming API, that your applications(edge functions) are going to use. They are located in the supabase/migrations
directory:
20231223160131_event_sourcing.sql
: Tables, Index, Rules and Triggers for Event Sourcing20231223160131_event_sourcing_api.sql
: SQL functions for Event Sourcing20231223160229_event_streaming.sql
: Tables, Index, Rules and Triggers for Event Streaming20231223160229_event_streaming_api.sql
: SQL functions for Event Streaming- ...
Feel free to create your own migration scripts:
supabase migration new add_department_to_employees_table
You can apply migrations manually by running the following command(s) in your terminal:
# To apply the new migration to your local database:
supabase migration up
# To reset your local database completely:
supabase db reset
Multi-Tenant
All tables have tenant_id
column, and the application is multi-tenant ready. Every user/email is a tenant, and can only see their own data. You might want to extend this functionality to support teams and organizations.
Check the fmodel-admin/login/actions.ts
and change the way the tenant is extracted, to match your needs. Check the supabase/migrations/20231223160130_utils.sql
for `auth.tenant()` sql function that is used to specify our Row-Level Security policies.
Row-level security (RLS) is a feature that allows you to restrict rows returned by a query based on the user/tenant executing the query.