Domain Model

Domain Model

This is the single source of truth for aggregates, value objects, and their relationships. For the full specification with implementation details, see docs/domain-model.md.


Architecture Overview

ZITADEL (Universal Identity — outside all tenants)
  └── One person, one login, many tenant apps


WITHIN A TENANT:
  Tenant → Organization tree (ltree) → Users (with OrgMemberships) → Groups → Events

Key principles:

  • Identity is universal (Zitadel), data is tenant-scoped
  • Trees, not fixed levels — org hierarchy is arbitrary depth
  • Everything is typed, types are configurable per tenant (UI labels)
  • Aggregates communicate via domain events only

Admin Role Hierarchy

Platform Super Admin     — access to all tenants, billing, system config
  └── Tenant Admin       — access to all orgs within a tenant
       └── Org Admin     — access to this org + all descendant orgs (via ltree)
            └── Group Leader — manages a specific group

Cascading rule: An org admin of a parent node (e.g., ICF Zürich) automatically has admin access to all descendant orgs (ICF Zürich City, ICF Zürich Oerlikon) via ltree ancestor queries. No explicit role assignment needed for child orgs.


Modules & Aggregates

Core/Platform

  • Shared contracts: AbstractId, Timestamps, DomainEventInterface
  • Value objects: TenantId (UUID wrapper, shared across modules)

Core/Identity

  • Tenant — configuration & isolation boundary (see Tenant Categories below)
  • User — authenticated person within a tenant (mapped from Zitadel)
  • ApiKey — API authentication for programmatic access

Organization Context (to be implemented in src/Module/Organization)

  • Organization — node in the org tree (arbitrary depth via ltree)
  • Value objects: OrganizationId, OrgTypeKey, OrgStatus, PhysicalLocation

People Context (modeled on User aggregate in Core/Identity)

  • User — tenant-scoped person with profile, privacy, org memberships
  • Value objects: UserId, EmailAddress, PhoneNumber, OrgMembership, OrgRole, PrivacySettings
  • Note: “User” not “Member” — everyone has a login. The word “membership” is preserved for relationships.

Community Context (to be implemented in src/Module/Groups)

  • Group — community unit (small group, team, community, class)
  • GroupMembership — user’s role in a group
  • Value objects: GroupId, GroupTypeKey, GroupRole, GroupVisibility

Activity Context (to be implemented in src/Module/Events)

  • Event — calendar event with recurrence (iCal RRULE)
  • RSVP — attendance tracking per event/occurrence
  • Value objects: EventId, EventTypeKey, RecurrenceRule, EventSchedule, EventStatus

News (to be implemented in src/Module/News)

  • Post — news/announcement with multi-level visibility
  • Value objects: PostId, Visibility, ContentBlock

Giving (Phase 2)

  • Donation, Campaign

Chat (Phase 2)

  • Conversation, Message

Tenant Categories

See ADR 016 for the full rationale.

The TenantType enum describes what a tenant contains. But tenants also differ in who manages them and how orgs relate to each other:

CategoryExampleOrgs areBilling
Platform tenantDefault tenant (managed by platform)Independent churches, isolated siblingsPer-org
Denomination tenantICF Movement, FEG SwitzerlandLegally independent churches within a movementPer-org or shared
Single-church tenantMulti-site churchLocations of the same legal entityPer-tenant
Temporary tenantSummer Camp 2026Event-specific structurePer-tenant

The platform tenant is the default tenant for small churches without a denomination. The platform acts as the “movement.” Each church is an org with its own admin, branding, and billing. Sibling orgs are naturally isolated by the org tree (content only flows down from ancestors, not across siblings). The default native app and root webapp domain connect to this tenant.

Domain structure:

  • yourcompany.io — marketing site, status, docs
  • yourcompany.app — root webapp → platform tenant’s root org
  • {slug}.yourcompany.app — org-specific webapp (slug resolves to org → tenant derived server-side)

Key Aggregates (Detail)

Tenant (Core/Identity)

TENANT (Aggregate Root)
├── id: TenantId (UUID)
├── name: string
├── slug: string (globally unique — may be deprecated; org slugs are used for subdomain resolution)
├── type: TenantType — church | camp | conference | organization
├── lifecycle: permanent | temporary { endsAt, retentionDays }
├── parentTenantId: TenantId | null (for camp → church link)
├── branding: TenantBranding (primaryColor, accentColor, logoUrl, darkMode)
├── defaultLocale, supportedLocales
├── orgTypeLabels, groupTypeLabels, eventTypeLabels (localized)
├── moduleEntitlements: ModuleEntitlement[]
├── status: TenantStatus
├── createdAt

├── INVARIANTS:
│   — slug globally unique
│   — at least one supported locale
│   — at least one root organization
│   — temporary tenants must have endsAt

└── EVENTS:
    — TenantCreated, TenantBrandingChanged, TenantModuleToggled
    — TenantArchived, TenantPurged

Organization (Module/Organization)

ORGANIZATION (Aggregate Root)
├── id: OrganizationId (UUID)
├── tenantId: TenantId
├── parentId: OrganizationId | null (null = root)
├── type: OrgTypeKey — "root", "region", "branch", "location", "micro"
├── name, slug (unique within tenant)
├── description: TranslatableText | null
├── location: PhysicalLocation | null
├── contactEmail, contactPhone
├── settings: OrgSettings (defaultLocale, privacyDefaults, modulesOverride)
├── sortOrder, status: OrgStatus, createdAt, updatedAt
├── path: ltree (materialized path for tree queries)

├── INVARIANTS:
│   — slug unique within tenant
│   — parentId must be same tenant
│   — no self-referencing (no cycles)
│   — max tree depth configurable (default: 5)

├── EVENTS:
│   — OrganizationCreated, OrganizationRenamed, OrganizationMoved
│   — OrganizationArchived, OrganizationSettingsChanged

└── ADMIN CASCADING:
    — Org admin of parent → automatic admin of all descendants (ltree ancestor query)
    — Tenant admin → admin of all orgs

User (Core/Identity + People Context)

USER (Aggregate Root)
├── id: UserId (UUID)
├── tenantId: TenantId
├── externalAuthId: string (Zitadel user ID — global across tenants)
├── firstName, lastName, displayName, email, phone, avatarUrl
├── dateOfBirth, locale, bio
├── privacySettings: PrivacySettings
├── notificationPreferences: NotificationPreferences
├── organizationMemberships: OrgMembership[]
│   ├── organizationId: OrganizationId
│   ├── role: OrgRole — admin | leader | member | guest
│   ├── joinedAt
│   └── status: MembershipStatus — active | inactive | pending
├── status: UserStatus — active | suspended | deleted
├── createdAt, updatedAt

├── INVARIANTS:
│   — must belong to at least one org within tenant
│   — email unique within tenant
│   — externalAuthId unique within tenant (one User per Zitadel identity per tenant)
│   — same externalAuthId CAN exist in multiple tenants

└── EVENTS:
    — UserRegistered, UserProfileUpdated
    — UserJoinedOrganization, UserLeftOrganization, UserRoleChanged
    — UserSuspended, UserDeleted, UserPrivacySettingsChanged

Group (Module/Groups)

GROUP (Aggregate Root)
├── id: GroupId (UUID)
├── tenantId, organizationId (primary org)
├── parentGroupId: GroupId | null (sub-groups)
├── type: GroupTypeKey, name, slug
├── visibility: open | request | invite_only | hidden
├── settings: GroupSettings (maxMembers, enableChat, meetingSchedule, etc.)
├── memberships: GroupMembership[]
│   ├── userId: UserId
│   ├── role: GroupRole — leader | co-leader | member | guest
│   ├── joinedAt, status
├── tags, status: GroupStatus

├── INVARIANTS:
│   — slug unique within organization
│   — at least one leader
│   — users must be members of the group's org (or ancestors)

└── EVENTS:
    — GroupCreated, GroupUpdated, GroupArchived
    — UserJoinedGroup, UserLeftGroup, GroupRoleChanged
    — GroupMembershipRequested, GroupMembershipApproved
    — GroupMovedToOrganization

Event (Module/Events)

EVENT (Aggregate Root)
├── id: EventId (UUID)
├── tenantId, organizationId
├── groupId: GroupId | null
├── parentEventId: EventId | null (sub-events)
├── type: EventTypeKey, title, slug
├── scheduling: single { startAt, endAt, timezone } | recurring { recurrenceRule, duration }
├── location: physical | online | hybrid
├── registration: { required, maxCapacity, waitlist, formFields, ticketPrice }
├── rsvps: RSVP[] (per occurrence for recurring)
├── visibility: public | group_only | invite_only
├── status: draft | published | cancelled | archived

└── EVENTS:
    — EventCreated, EventUpdated, EventCancelled, EventPublished
    — UserRSVPd, UserCancelledRSVP, EventCapacityReached

Sign-Up Flows

See ADR 014: Two Distinct Registration Workflows for the full design rationale.

Two fundamentally different registration workflows exist on separate surfaces:

Flow 1: Tenant Registration — Backend only (/register)

  • Surface: Symfony backend (Twig form), never the webapp/native app
  • Who: Church leaders creating a new tenant on the platform
  1. Leader fills in church details (name, type, denomination, address, branding)
  2. Authenticates via Zitadel (new account or existing)
  3. System creates: Tenant (ACTIVE) → Root Organization → User (TENANT_ADMIN)
  4. Leader lands in admin dashboard

Flow 2: User Registration — Webapp / Native app only

  • Surface: React Native webapp (subdomain) or native app, never the backend
  • Org context is always present: subdomain (slug.yourapp.app) resolves to org → tenant derived server-side
  1. User opens the org’s webapp (subdomain) or native app
  2. Authenticates via Zitadel (new account or existing)
  3. API derives tenant from org, checks: User with this externalAuthId in this tenant?
  4. If no → auto-provision User, check org’s registrationMode (open/invite-only/by-request)
  5. User entity created, OrgMembership added per registration mode
  6. If yes → authenticated, existing User loaded

Flow 3: Joining a Camp/Conference Tenant

  • Surface: Webapp / native app (same as Flow 2, triggered via invite link)
  1. User receives invite link or QR code
  2. Opens link → consent screen (OAuth-style scope display)
  3. Authenticates via Zitadel (same credentials as home church)
  4. User entity created in camp tenant (separate from home tenant)
  5. Camp appears in tenant switcher (Phase 2) or web app

Cross-Organization Groups

A group always belongs to one org node. For groups spanning multiple orgs (e.g., “Youth Leaders Network” across ICF Zürich, Basel, Bern):

  • The group belongs to the nearest common ancestor in the org tree (e.g., “ICF Switzerland”)
  • Users from child orgs can join the group
  • This keeps the permission model clean — the group inherits from its org’s context

Cross-Tenant Org Migration

See ADR 016 for the full rationale.

Moving an org (with all its data) between tenants is a first-class operation, not a nuclear option. Scenarios:

  • Small church outgrows the platform tenant → extract into own tenant
  • Independent church joins a denomination → move into denomination tenant
  • Superadmin consolidates duplicate tenants

What moves: The org subtree + all Users, Groups, Events, News, Media belonging to those orgs. tenant_id is updated on all affected rows. Entity UUIDs don’t change.

User conflicts: If a person (same externalAuthId) already has a User in the target tenant, OrgMemberships are merged onto the existing User (higher role wins). No duplicate Users.

Implementation: Superadmin-only. Preview → confirm → execute (event-driven, each module updates its own rows) → source subdomain redirects to target.


Cross-Tenant Collaboration

Two models coexist:

  1. Event Syndication — for simple cross-tenant events (joint services, godi nights). Share link → local RSVP → aggregate counts. No shared news/groups/chat.
  2. Camp Tenants — for multi-day events needing full features. The camp IS its own temporary tenant with news, groups, chat, schedule.

See docs/domain-model.md for full syndication protocol and camp tenant lifecycle.