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).

Event Modeling

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.
fmodel

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:

CQRS
  • 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:

  1. 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.

  2. Step 2: Run the Admin application

    Navigate to the fmodel-admin folder.

    Rename the fmodel-admin/.env.local.example to fmodel-admin/.env.local, and specify your `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_ANON_KEY` variables. Your api url and anon key will be printed in the terminal as a result of supabase 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!

    Statistics
  • 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.

    Statistics

    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.

    Statistics

    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.

    Statistics

    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).

    Statistics

    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 Sourcing
  • 20231223160131_event_sourcing_api.sql: SQL functions for Event Sourcing
  • 20231223160229_event_streaming.sql: Tables, Index, Rules and Triggers for Event Streaming
  • 20231223160229_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.