Transparent multi-tenancy with the engine pattern

The domain never asks

Geschrieben von Timo Rieber am 5. November 2025

cloudapps was multi-tenant from day one. Each organisation gets its own SQLite database, its own file storage, its own templates. The tenant isolation itself was never the problem. The problem was what held it all together.

The first version used a messagebus as the runtime container. init_tenants() built one messagebus per organisation, pre-injected with command handlers, event handlers, and a unit of work - the handlers themselves carrying further dependencies like the mail gateway. The entrypoint stored these in a dict, keyed by tenant reference. A request looked up the tenant, pulled the messagebus from the dict, and passed it to the GraphQL execution context. It worked, but the messagebus was doing two unrelated jobs: dispatching commands to handlers, and being the thing that held all the tenant-scoped dependencies together.

When the container outgrows its host

As the application grew, each tenant needed more services. Billing, payments, documents, notifications, search, file storage, a client portal. The messagebus kept accumulating them through its handler injection. New dependencies meant updating handler maps, updating the bootstrap function, updating the injection dictionary. Adding a service didn’t touch the domain. It touched the messagebus wiring.

The real tell was the entrypoint code. It extracted the messagebus from a dict, then reached through it to get services the GraphQL resolvers needed. The messagebus wasn’t dispatching commands at that point. It was being used as a service locator. Two responsibilities in one object, neither of them named.

I extracted the container into its own concept: Engine. The first version had a single field:

@dataclass
class Engine:
    course_service: CourseService
Python

Over the following weeks it grew to sixteen services, each constructed with repositories bound to that tenant’s SQLite file at /{data_root}/{tenant_reference}/database.db. A factory builds one engine per tenant on first access and caches it. Thread-local connection stacks keep concurrent requests for different tenants from crossing.

What the command layer sees

A FastAPI dependency resolves the tenant from the URL path and returns the engine:

async def tenant_engine(tenant: str) -> Engine:
    return engines().get(tenant)
Python

From there, the engine’s service hands a CommandContext to each command - a frozen dataclass of dependencies already bound to the right database. A registration command looks like this:

@dataclass(frozen=True)
class RegisterFromPortal(Command[str]):
    course_id: str
    first_name: str
    last_name: str
    email: str

    def execute(self, context: CommandContext) -> str:
        course = context.courses.get(UUID(self.course_id))
        registration = course.register_attendee(person=..., fee=course.fee)
        context.registrations.add(registration)
        return str(registration.uuid)
Python

No tenant parameter. No database selector. No filter clause. context.courses and context.registrations don’t know which tenant they belong to. They received a database connection at construction time and that’s all they need. The command doesn’t participate in tenant isolation - it just uses what it was given.

What the assembly decides

Identity management and audit logging are global - shared across all organisations through engines().idm() and engines().audit(). Each tenant’s engine receives these as injected dependencies during construction. The per-tenant code authenticates users and writes audit entries without knowing those services are shared. Per-tenant vs. global is a wiring decision, made once in the factory.

The messagebus is gone. The container responsibility that used to hide inside it has a name, a type, and sixteen service fields. When I add a new service now, I add a field to the engine and a construction step to the builder. The domain code that uses that service never learns which tenant called it, which database backs it, or that any of this is shared. That’s the part worth extracting: not the data isolation, but the moment where isolation becomes invisible.