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:
| Category | Example | Orgs are | Billing |
|---|---|---|---|
| Platform tenant | Default tenant (managed by platform) | Independent churches, isolated siblings | Per-org |
| Denomination tenant | ICF Movement, FEG Switzerland | Legally independent churches within a movement | Per-org or shared |
| Single-church tenant | Multi-site church | Locations of the same legal entity | Per-tenant |
| Temporary tenant | Summer Camp 2026 | Event-specific structure | Per-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, docsyourcompany.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
- Leader fills in church details (name, type, denomination, address, branding)
- Authenticates via Zitadel (new account or existing)
- System creates: Tenant (ACTIVE) → Root Organization → User (TENANT_ADMIN)
- 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
- User opens the org’s webapp (subdomain) or native app
- Authenticates via Zitadel (new account or existing)
- API derives tenant from org, checks: User with this
externalAuthIdin this tenant? - If no → auto-provision User, check org’s
registrationMode(open/invite-only/by-request) - User entity created, OrgMembership added per registration mode
- 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)
- User receives invite link or QR code
- Opens link → consent screen (OAuth-style scope display)
- Authenticates via Zitadel (same credentials as home church)
- User entity created in camp tenant (separate from home tenant)
- 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:
- Event Syndication — for simple cross-tenant events (joint services, godi nights). Share link → local RSVP → aggregate counts. No shared news/groups/chat.
- 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.