Skip to content

feat: add Tempest quickstart examples and multi-tenant ORM fix#674

Open
dgafka wants to merge 2 commits into
mainfrom
feat/tempest-quickstart
Open

feat: add Tempest quickstart examples and multi-tenant ORM fix#674
dgafka wants to merge 2 commits into
mainfrom
feat/tempest-quickstart

Conversation

@dgafka

@dgafka dgafka commented Jun 10, 2026

Copy link
Copy Markdown
Member

Why is this change proposed?

Ecotone's Tempest integration shipped without runnable examples, leaving new users without a concrete starting point. This adds three Docker-runnable quickstarts covering the core patterns (active-record model as aggregate, event-sourced projection, multi-tenant message bus). Building the multi-tenant example surfaced a real bug: a Tempest active-record aggregate did not follow the per-tenant connection switch — IsDatabaseModel::save() wrote to Tempest's discovered default (SQLite) instead of the active tenant's database — so the fix ships alongside the examples that prove it.

Description of Changes

  • Tempest/Model — a Tempest IsDatabaseModel as an Ecotone #[Aggregate], exercised via Command/Query bus, a #[Repository] gateway, and a #[DbalQuery] read side.
  • Tempest/Projection/DatabaseReadModel — an event-sourced User aggregate with a #[ProjectionV2] read model and projection lifecycle commands.
  • Tempest/MultiTenant/MessageBus — one Customer aggregate routed to two engines (tenant_a→PostgreSQL, tenant_b→MySQL) by message header, asserting isolation via the native #[MultiTenantConnection] attribute.
  • Fix TempestTenantDatabaseSwitcherswitchOn now registers a Database built from the shared tenant connection as the untagged singleton, so aggregate save() follows the active tenant (root cause: DatabaseConfig implements HasTag, so the previous singleton(DatabaseConfig::class, …) was silently re-tagged and the SQLite default was rebuilt over the promoted connection).
  • New test TenantAggregatePersistenceTest proving UUID-PK aggregate persistence isolates across postgres + mysql.

Usage

#[Aggregate]
#[Table('customers')]
final class Customer
{
    use IsDatabaseModel;

    #[Uuid]
    public PrimaryKey $id;
    public string $name;

    #[CommandHandler]
    public static function register(RegisterCustomer $command): self
    {
        $customer = new self();
        $customer->name = $command->name;
        $customer->save();

        return $customer;
    }

    #[IdentifierMethod('id')]
    public function getId(): string { return (string) $this->id->value; }
}

#[ServiceContext]
public function multiTenantConfiguration(): MultiTenantConfiguration
{
    return MultiTenantConfiguration::create(
        tenantHeaderName: 'tenant',
        tenantToConnectionMapping: [
            'tenant_a' => TempestConnectionReference::create('tenant_a'),
            'tenant_b' => TempestConnectionReference::create('tenant_b'),
        ],
    );
}

// routes the write to tenant_a's PostgreSQL database
$commandBus->send(new RegisterCustomer('Alice'), metadata: ['tenant' => 'tenant_a']);

// receives tenant_a's connection — proves which physical DB
#[QueryHandler('customer.platformForActiveTenant')]
public function platformForActiveTenant(#[MultiTenantConnection] Connection $connection): string
{
    return $connection->getDatabasePlatform()::class;
}

Use cases

  1. SaaS per-tenant databases — same domain code, each customer's data physically isolated in its own database (even a different engine), selected per message.
  2. Learning Ecotone + Tempest — copy-pasteable, Docker-runnable references for model-as-aggregate, projections, and multi-tenancy.
  3. Heterogeneous tenant infrastructure — one tenant on PostgreSQL, another on MySQL, with no branching in handler code.

Flow

sequenceDiagram
    participant Client
    participant CommandBus
    participant Switcher as TempestTenantDatabaseSwitcher
    participant Customer as Customer (Aggregate)
    participant DB as Tenant DB (PG / MySQL)
    Client->>CommandBus: send(RegisterCustomer, metadata[tenant=tenant_a])
    CommandBus->>Switcher: tenant activated, switchOn
    Switcher->>Switcher: promote tenant Connection + Database (untagged)
    CommandBus->>Customer: register() then save()
    Customer->>DB: INSERT into the active tenant's database
Loading

Pull Request Contribution Terms

  • I have read and agree to the contribution terms outlined in CONTRIBUTING.

dgafka added 2 commits June 10, 2026 21:13
Runnable Tempest + Ecotone examples: active-record model as aggregate, event-sourced projection read model, and multi-tenant message bus routing one aggregate to per-tenant databases (postgres + mysql) with #[MultiTenantConnection] assertions.
TempestTenantDatabaseSwitcher promoted the tenant DatabaseConfig via singleton(), but DatabaseConfig implements HasTag so the binding was silently re-tagged to the tenant slot, leaving the discovered (SQLite) default in place; DatabaseInitializer then rebuilt the default Connection from it and clobbered the promoted one. switchOn now builds a GenericDatabase from the shared tenant Connection and registers it as the untagged Database singleton, so aggregate save() follows the active tenant.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant