Commanded application architecture

Describes the various ways you can architect your Elixir application with Commanded and EventStore.


  Elixir CQRS/ES Commanded


When deciding how to architect an Elixir application which uses Commanded you can choose the most suitable design from one of the following options.

  • One monolithic service with a single global Commanded application.
  • One monolithic service containing many contexts, all sharing a single Commanded application.
  • One monolithic service containing many contexts, each context using its own Commanded application.
  • Many microservices with each service using its own Commanded application, and an additional Commanded application for integration.
  • Multi-tenant service with each tenant using its own Commanded Application.

Monolithic service, global Commanded Application

This is the simplest approach where one service defines a single global Commanded Application and EventStore which is connected to a single Postgres database.

  • Single “majestic monolith” service.
  • Single global Commanded Application.
  • Single global EventStore connected to a Postgres database.

Monolithic service

Example usage

You define a single Commanded Application module and a single EventStore module:

defmodule MyApp.App do
  use Commanded.Application,
    otp_app: :my_app,
    event_store: [
      adapter: Commanded.EventStore.Adapters.EventStore,
      event_store: MyApp.EventStore
    ]
end

defmodule MyApp.EventStore do
  use EventStore, otp_app: :my_app
end

The Commanded Application (MyApp.App) is included in the Elixir application’s supervision tree:

defmodule MyApp.Supervisor do
  use Supervisor

  def start_link(init_arg) do
    Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
  end

  def init(_init_arg) do
    children = [
      MyApp.App
    ]

    Supervisor.init(children, strategy: :one_for_one)
  end
end

Once configured and started you can use the Commanded Application to dispatch commands:

:ok = MyApp.App.dispatch(command)

Or use it in an event handler to process events:

defmodule MyApp.Context.ExampleHandler do
  use Commanded.Event.Handler,
    application: MyApp.App,
    name: __MODULE__
end

Multi-context monolithic service, shared Commanded Application

  • Single monolithic service, partitioned by context.
  • Single global Commanded Application shared by each context.
  • Single global EventStore connected to a Postgres database shared by each context.

Here we split our service into smaller contexts, but still using a global Commanded Application and EventStore. The events may be consumed by any context within the service as it uses a single shared event store. This may ease development at the expense of tighter coupling and contention as contexts may need to process all events, not just their own.

Multi-context monolithic service

Example usage

In this example the monolith is designed in a modular style with multiple contexts each sharing a single Commanded Application and EventStore. The shared Commanded Application includes the individual router modules defined in each context allowing the dispatch of any command registered in any context.

defmodule MyApp.App do
  use Commanded.Application,
    otp_app: :my_app,
    event_store: [
      adapter: Commanded.EventStore.Adapters.EventStore,
      event_store: MyApp.EventStore
    ]

  router(MyApp.ContextA.Router)
  router(MyApp.ContextB.Router)
  router(MyApp.ContextC.Router)
end

defmodule MyApp.EventStore do
  use EventStore, otp_app: :my_app
end

In the Elixir application supervisor we would include the Commanded Application and the supervisors belonging to each context:

defmodule MyApp.Supervisor do
  use Supervisor

  def start_link(init_arg) do
    Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
  end

  def init(_init_arg) do
    children = [
      MyApp.App,
      MyApp.ContextA,
      MyApp.ContextB,
      MyApp.ContextC
    ]

    Supervisor.init(children, strategy: :one_for_one)
  end
end

Multi-context monolithic service, separate Commanded Applications and database schema

  • Single monolithic service.
  • Multiple Commanded Applications - one for each context.
  • Multiple EventStores - one for each context - connected to a single Postgres database segregated by schema.

The contexts within the monolithic service are further isolated by defining a separate Commanded Application and EventStore for each context. The EventStore for each context will be configured to use its own, separate database schema so that the events are stored separately. This guarantees isolation between contexts, ensuring that event handling within individual contexts do not affect each other.

Multi-context monolithic service

Example

We define a separate Commanded.Application module and EventStore module for each context. Each EventStore module would be configured to use its own Postgres database schema, specific to each context. The following example shows the modules for context A. The other contexts would define similar modules themselves.

defmodule MyApp.ContextA.App do
  use Commanded.Application,
    otp_app: :my_app,
    event_store: [
      adapter: Commanded.EventStore.Adapters.EventStore,
      event_store: MyApp.ContextA.EventStore
    ]
end

defmodule MyApp.ContextA.EventStore do
  use EventStore, otp_app: :my_app, schema: "context_a"
end

Each context’s Commanded Application would be then supervised:

defmodule MyApp.Supervisor do
  use Supervisor

  def start_link(init_arg) do
    Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
  end

  def init(_init_arg) do
    children = [
      MyApp.ContextA.App,
      MyApp.ContextB.App,
      MyApp.ContextC.App
    ]

    Supervisor.init(children, strategy: :one_for_one)
  end
end

Usually you would define a top-level Supervisor module for each context which would include the Commanded Application.

Commands would then be dispatched using the specific Commanded Application for the context. This would be MyApp.ContextA.App for context A, etc.

:ok = MyApp.ContextA.App.dispatch(%MyApp.ContextA.CommandA{})
:ok = MyApp.ContextB.App.dispatch(%MyApp.ContextB.CommandB{)
:ok = MyApp.ContextC.App.dispatch(%MyApp.ContextC.CommandC{)

It would not be possible for context A to dispatch a command for a different context without using the other context’s own Commanded Application. Instead a context should define public functions in a MyApp.ContextA module to expose any operations to be used by other contexts. Internally, these functions can use the context’s own Commanded Application, hiding this information from the caller as an implementation detail.

defmodule MyApp.ContextA do
  alias MyApp.ContextA.App

  def do_something(args) do
    command = DoSomething.new(args)

    App.dispatch(command)
  end
end

Similarly, an event handler will only process the events for the context relating to the Commanded Application it uses. In the following example the handler would only receive events from context A.

defmodule MyApp.ContextA.ExampleHandler do
  use Commanded.Event.Handler,
    application: MyApp.ContextA.App,
    name: __MODULE__
end

Multi-context monolithic service, separate Commanded Applications and database

  • Single monolithic service.
  • Multiple Commanded Applications - one for each context.
  • Multiple EventStores and Postgres databases - one for each context.

This is almost identical to the previous example, except the configuration for each context’s EventStore would connect to a different Postgres database rather than using a different schema within the same database. Otherwise the usage is the same as before with each context having its own Commanded Application module and EventStore module.

Multi-context monolithic service

Example

Define a Commanded Application module and EventStore module for each context:

defmodule MyApp.ContextA.App do
  use Commanded.Application,
    otp_app: :my_app,
    event_store: [
      adapter: Commanded.EventStore.Adapters.EventStore,
      event_store: MyApp.ContextA.EventStore
    ]
end

defmodule MyApp.ContextA.EventStore do
  use EventStore, otp_app: :my_app, schema: "context_a"
end

Configure the event store for each context to connect to its own Postgres database:

# config/config.exs
config :my_app, MyApp.ContextA.EventStore,
  username: "postgres",
  password: "password",
  database: "context_a",
  hostname: "localhost"

config :my_app, MyApp.ContextB.EventStore,
  username: "postgres",
  password: "password",
  database: "context_a",
  hostname: "localhost"

Microservices

  • One service per context.
  • One Commanded Application per service.
  • One EventStore and Postgres database per service.
  • Additional Commanded Application and EventStore used for inter-service messaging and integration.

In the microservices deployment model each service would define its own Commanded Application and EventStore. In addition to these, another shared Commanded Application and EventStore could be used for any inter-service messaging. With this architecture each service is autonomous and can be independently scaled, deployed, and upgraded.

To allow inter-service communication a shared event store is used. With this approach each service can choose to publish a subset of its internal private domain events to the shared public integration event store. These public integration events can then be consumed by any other service.

The services could also be integrated using another mechanism, such as with an enterprise service bus, message queue, etc.

Microservices

Example

Define a Commanded Application and EventStore for each individual service:

defmodule MyApp.ServiceA.App do
  use Commanded.Application,
    otp_app: :my_app_service_a,
    event_store: [
      adapter: Commanded.EventStore.Adapters.EventStore,
      event_store: MyApp.IntegrationEventStore
    ]
end

defmodule MyApp.ServiceA.EventStore do
  use EventStore, otp_app: :my_app_service_a
end

Additionally, define a shared Commanded Application and EventStore used by all services:

defmodule MyApp.Integration.App do
  use Commanded.Application,
    otp_app: :my_app_integration,
    event_store: [
      adapter: Commanded.EventStore.Adapters.EventStore,
      event_store: MyApp.IntegrationEventStore
    ]
end

defmodule MyApp.Integration.EventStore do
  use EventStore, otp_app: :my_app_integration
end

Within each service the supervision tree would need to include both the service’s own Commanded Application and the shared integration Commanded Application:

defmodule MyApp.ServiceA.Supervisor do
  use Supervisor

  def start_link(init_arg) do
    Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
  end

  def init(_init_arg) do
    children = [
      MyApp.Integration.App,
      MyApp.ServiceA.App
    ]

    Supervisor.init(children, strategy: :one_for_one)
  end
end

Inter-service messaging

Any service can use the integration Commanded Application to publish a public integration event. This is done by appending one or more events to a named stream. In this example a MyApp.Integration.Event event struct is used and it contains the type of the event and its data. A single integration event struct is used for all integration events so that each service does not need to have a dependency on one another nor share its own event modules. It allows dynamic event types to be published and consumers can opt-in to process only the events they are interested in and are able to process.

defmodule MyApp.Integration.Event do
  defstruct [:source, :type, :data]
end

events = [
  %Commanded.EventStore.EventData{
    event_type: "Elixir.MyApp.Integration.Event",
    data: %MyApp.Integration.Event{
      source: "service_a",
      type: "UserRegistered",
      data: %{
        "userId" => user_id,
        "name" => "Ben"
      }      
    },
    metadata: %{}
  }
]

:ok = Commanded.EventStore.append_to_stream(MyApp.Integration.App, "service_a/users/#{user_id}", :any_version, events)

The purpose of using Commanded.EventStore to append events, instead of using the EventStore module directly, is because it acts as a facade to use whatever event store is configured for the Commanded Application. For instance, this allows the in-memory event store to be configured for testing and the Postgres EventStore to be used in development and production.

An event handler can be used in any service to process these public integration events:

defmodule MyApp.ServiceA.ContextA.ExampleHandler do
  use Commanded.Event.Handler,
    application: MyApp.Integration.App,
    name: __MODULE__

  def handle(%MyApp.Integration.Event{type: "UserRegistered"} = event, _metadata) do
    %MyApp.Integration.Event{data: %{"userId" => user_id}} = event

    Logger.info("User registered: #{user_id}")

    :ok
  end
end

Multi-tenant service

  • Single or multiple services and/or contexts.
  • Multiple Commanded Application instances - one per tenant.
  • Multiple EventStore instances - one per tenant - connected to a separate Postgres database or schema.

Any of the previous architectures can be augmented with a multi-tenant approach by running multiple instances of any Commanded Application and its EventStore. One instance is started for each tenant. Each instance will use its own isolated event store, either segregated by Postgres schema or using a separate database.

The benefit of this architecture is that each tenant’s data is stored in its own event store, completely isolated from one another. It also allows concurrent processing of each tenant’s data since event handlers run for each tenant, processing events for that single tenant only. One large tenant will not negatively impact other tenants.

There is additional complexity with this approach as each tenant must be supervised and have its own event store database or schema provisioned.

Multi-tenant service

Example

First we define a Commanded Application module and EventStore module. The Commanded Application uses the init/1 callback function to configure the event store for the tenant that is provided when it starts. This means each tenant-specific Commanded Application instance will use its own event store instance connected to its own Postgres database or schema.

defmodule MyApp.App do
  use Commanded.Application,
    otp_app: :my_app,
    event_store: [
      adapter: Commanded.EventStore.Adapters.EventStore,
      event_store: MyApp.EventStore
    ]

  def init(config) do
    {tenant, config} = Keyword.pop(config, :tenant)

    config =
      config
      |> put_in([:event_store, :name], Module.concat([MyApp.EventStore, tenant]))
      |> put_in([:event_store, :prefix], "#{tenant}")

    {:ok, config}
  end
end

defmodule MyApp.EventStore do
  use EventStore, otp_app: :my_app
end

Then we start one instance of the Commanded Application for each tenant.

defmodule MyApp.Supervisor do
  use Supervisor

  def start_link(init_arg) do
    Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
  end

  def init(_init_arg) do
    # List of tenants could be dynamically populated, not just a static list
    tenants = [:tenant1, :tenant2, :tenant3]

    children =
      Enum.flat_map(tenants, fn tenant ->
        application = Module.concat([MyApp.App, tenant])

        [
          {MyApp.App, name: application, tenant: tenant},
          {MyApp.ExampleHandler, application: application}
        ]
      end)

    Supervisor.init(children, strategy: :one_for_one)
  end
end

Included in the supervision tree above for each tenant was an event handler which specifies the Commanded Application at runtime. This is to allow multiple instances of the handler to run, one instance for each tenant. The event handler will only process events for a single tenant.

defmodule MyApp.Context.ExampleHandler do
  use Commanded.Event.Handler,
    name: __MODULE__

  def handle(event, metadata) do
    # Only events for a single tenant will be handled

    :ok
  end
end