Organisations

ShipClojure-Datom supports multi-tenant organisations with role-based membership. Every user belongs to at least one organisation, created automatically during registration.

Overview

Key Concepts

  • Organisation: A tenant/workspace that groups users and their data
  • Membership: Links a user to an organisation with a specific role
  • Implicit Partitions: All organisation data is co-located in Datomic for query performance

Schema

;; Organisation
{:organisation/id       :db.type/uuid}    ; Unique identifier
{:organisation/name     :db.type/string}  ; Display name (e.g., "My Organisation")
{:organisation/slug     :db.type/string}  ; URL-friendly identifier (unique)
{:organisation/memberships :db.type/ref}  ; Component refs to memberships

;; Membership (join entity)
{:membership/id    :db.type/uuid}  ; Unique identifier
{:membership/user  :db.type/ref}   ; Reference to user-account
{:membership/role  :db.type/ref}   ; Role enum

;; Role enums
:membership.role/owner   ; Full control, can delete org
:membership.role/admin   ; Can manage members and settings
:membership.role/member  ; Standard access

User Registration Flow

When a user registers, they automatically get a personal organisation:

;; From handlers.clj - register function
(let [org-id (u/uuid)
      user-tempid (org/tempid org-id)

      user-payload {:db/id user-tempid
                    :user-account/id (u/uuid)
                    :user-account/username username
                    :user-account/email email
                    ...}

      org-payload {:db/id (org/tempid org-id)
                   :organisation/id org-id
                   :organisation/name "My Organisation"
                   :organisation/slug (u/->slug username)
                   :organisation/memberships
                   [{:db/id (org/tempid org-id)
                     :membership/id (u/uuid)
                     :membership/user user-tempid
                     :membership/role :membership.role/owner}]}]

  [[:db/transact [user-payload org-payload]]
   [:http/respond {...}]])

The user becomes the owner of their personal organisation with the username as the slug.

Implicit Partitions for Performance

ShipClojure uses Datomic's implicit partitions to co-locate all organisation data for optimal query performance at scale.

Why Partitions Matter

Datomic stores data in indexes (EAVT, AEVT, VAET, AVET). When entities share the same partition, they are stored together in the E-leading indexes. This means:

  1. Better cache locality - Queries for org data hit fewer index segments
  2. Faster range scans - Related entities are physically adjacent
  3. Efficient peer caching - Hot org data stays in memory together

How It Works

(ns saas.datomic.organisation
  (:require [datomic.api :as d]))

(def ^:const partition-count 524288)  ; 2^19 implicit partitions

(defn org-partition
  "Get implicit partition for an org based on its ID hash."
  [org-id]
  (d/implicit-part (mod (Math/abs (hash org-id)) partition-count)))

(defn tempid
  "Create a tempid in the org's partition."
  [org-id]
  (d/tempid (org-partition org-id)))

Usage Pattern

Always use org/tempid for org-scoped entities:

(require '[saas.datomic.organisation :as org])

;; Creating a new project for an organisation
(defn create-project [conn org-id project-name]
  (d/transact conn
    [{:db/id (org/tempid org-id)  ; Use org's partition
      :project/id (u/uuid)
      :project/name project-name
      :project/org [:organisation/id org-id]}]))

;; Creating multiple related entities
(defn create-project-with-tasks [conn org-id project-data tasks]
  (let [project-tempid (org/tempid org-id)]
    (d/transact conn
      (into [{:db/id project-tempid
              :project/id (u/uuid)
              :project/name (:name project-data)
              :project/org [:organisation/id org-id]}]
            (for [task tasks]
              {:db/id (org/tempid org-id)  ; Same partition
               :task/id (u/uuid)
               :task/name (:name task)
               :task/project project-tempid})))))

Important Notes

  1. Partitions are NOT security boundaries - Any query can access any partition. Use application-level access control.

  2. Partition assignment is permanent - Entity IDs encode their partition. Plan your data model accordingly.

  3. Use for org-scoped data only - Global/shared entities (like enum values) should use :db.part/user.

Querying Organisation Data

Get User's Organisations

(defn get-user-organisations [db user-id]
  (d/q '[:find [(pull ?org [:organisation/id
                            :organisation/name
                            :organisation/slug]) ...]
         :in $ ?user-id
         :where
         [?user :user-account/id ?user-id]
         [?membership :membership/user ?user]
         [?org :organisation/memberships ?membership]]
       db user-id))

Get Organisation Members

(defn get-org-members [db org-id]
  (d/q '[:find [(pull ?membership [:membership/id
                                   :membership/role
                                   {:membership/user
                                    [:user-account/id
                                     :user-account/username
                                     :user-account/email]}]) ...]
         :in $ ?org-id
         :where
         [?org :organisation/id ?org-id]
         [?org :organisation/memberships ?membership]]
       db org-id))

Get Membership Join Date

Since we don't store joined-at explicitly, use the transaction time:

(defn get-member-join-dates [db org-id]
  (d/q '[:find ?username ?joined-at
         :in $ ?org-id
         :where
         [?org :organisation/id ?org-id]
         [?org :organisation/memberships ?m ?tx]
         [?m :membership/user ?user]
         [?user :user-account/username ?username]
         [?tx :db/txInstant ?joined-at]]
       db org-id))

Pull Organisation with Members

(d/pull db
  [:organisation/id
   :organisation/name
   :organisation/slug
   {:organisation/memberships
    [:membership/id
     :membership/role
     {:membership/user
      [:user-account/username
       :user-account/email]}]}]
  [:organisation/id org-id])

Member Invitations

Users can be invited to join an organisation via email. The invitation system handles token generation, email delivery, and registration of new users.

Invitation Schema

{:org.invitation/id           :db.type/uuid}     ; Unique identifier
{:org.invitation/email        :db.type/string}    ; Email of the invited user
{:org.invitation/token        :db.type/string}    ; Secure URL-safe token (unique)
{:org.invitation/role         :db.type/ref}       ; Role to assign on join
{:org.invitation/invited-by   :db.type/ref}       ; User who sent the invite
{:org.invitation/organisation :db.type/ref}       ; Target organisation
{:org.invitation/expires-at   :db.type/instant}   ; Expiration (7 days from creation)
{:org.invitation/accepted?    :db.type/boolean}   ; Whether the invite was accepted

Invitation Flow

  1. Owner/admin invites via :command/invite-member with email and role
  2. Server creates invitation with a secure token and sends an email with a link
  3. Recipient clicks link → lands on the /invite/accept page
  4. :command/accept-invitation validates the token, marks the invite as accepted, and stores the organisation in the session
  5. Recipient registers via :command/register-member — no email verification needed since the invitation itself serves as verification. The user is added to the organisation with the role specified in the invitation.

Reissuing Invitations

If an invitation already exists for an email, calling :command/invite-member again will update the existing invitation (new role, new expiry, new invited-by) rather than creating a duplicate. The original token is preserved.

CQRS Commands

CommandAuthDescription
:command/invite-memberRequired (owner/admin)Send an invitation email
:command/accept-invitationNoneAccept an invitation by token + email
:command/register-memberNone (session)Register as an invited user

Member Management

Owners and admins can manage organisation members through CQRS commands.

Available Commands

CommandAuthDescription
:command/remove-memberRequired (owner/admin)Remove a member by membership ID
:command/update-member-roleRequired (owner/admin)Change a member's role
:command/update-organisationRequired (owner/admin)Update org name/slug
:query/get-organisationRequiredGet org details with all members

Constraints

  • Last owner protection: Cannot remove the last owner or change their role away from owner — the organisation must always have at least one owner
  • Cross-org protection: Cannot modify members from a different organisation (403)
  • Permission check: Only owners and admins can manage members. Regular members get a 400 response with :cause :*/insufficient-permissions

Organisation Settings Page

The organisation settings page (src/cljc/saas/ui/pages/settings/organisation.cljc) provides a full UI for managing the organisation:

  • View and edit organisation name and slug
  • View members list with roles and join dates
  • Invite new members with role selection
  • Update member roles
  • Remove members

This route is restricted to owners and admins via :auth/roles — see Role-Based Access Control for details.

Adding Members Programmatically

(defn add-member [conn org-id user-id role]
  (let [org (d/entity (d/db conn) [:organisation/id org-id])]
    (d/transact conn
      [{:db/id (org/tempid org-id)
        :membership/id (u/uuid)
        :membership/user [:user-account/id user-id]
        :membership/role role
        :organisation/_memberships (:db/id org)}])))

;; Usage
(add-member conn org-id user-id :membership.role/member)

Checking Permissions

(defn user-has-role? [db org-id user-id role]
  (some?
    (d/q '[:find ?m .
           :in $ ?org-id ?user-id ?role
           :where
           [?org :organisation/id ?org-id]
           [?org :organisation/memberships ?m]
           [?m :membership/user ?user]
           [?user :user-account/id ?user-id]
           [?m :membership/role ?role]]
         db org-id user-id role)))

(defn user-is-owner? [db org-id user-id]
  (user-has-role? db org-id user-id :membership.role/owner))

(defn user-is-admin-or-owner? [db org-id user-id]
  (some?
    (d/q '[:find ?m .
           :in $ ?org-id ?user-id
           :where
           [?org :organisation/id ?org-id]
           [?org :organisation/memberships ?m]
           [?m :membership/user ?user]
           [?user :user-account/id ?user-id]
           [?m :membership/role ?role]
           [(contains? #{:membership.role/owner :membership.role/admin} ?role)]]
         db org-id user-id)))

For declarative, route-level and CQRS-level role enforcement, see Role-Based Access Control.

Best Practices

1. Always Use Org Partitions for Tenant Data

;; Good - uses org partition
{:db/id (org/tempid org-id)
 :project/name "My Project"
 :project/org [:organisation/id org-id]}

;; Bad - uses default partition
{:db/id (d/tempid :db.part/user)
 :project/name "My Project"
 :project/org [:organisation/id org-id]}

2. Include Org Reference on All Tenant Entities

This enables efficient queries and future database filtering:

;; Schema for tenant-scoped entities
{:db/ident :project/org
 :db/valueType :db.type/ref
 :db/cardinality :db.cardinality/one
 :db/doc "Organisation that owns this project"}

3. Validate Org Access in Handlers

(defn get-project [req]
  (let [{:keys [db]} (:nexus/state req)
        user-id (get-in req [:claims :sub])
        org-id (get-in req [:params :org-id])
        project-id (get-in req [:params :project-id])]
    (cond
      (not (user-member-of-org? db org-id user-id))
      [[:http-response/forbidden {:message "Not a member of this organisation"}]]

      :else
      (let [project (get-project-by-id db project-id)]
        (if (= (:project/org project) [:organisation/id org-id])
          [[:http-response/ok project]]
          [[:http-response/not-found {:message "Project not found"}]])))))

Future Enhancements

  • Database Filters: Add tenant isolation at the database level (Nubank pattern)
  • Billing & Plan Management: Per-organisation billing and subscription plans
  • Cross-Org Data Sharing: For enterprise features

References