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:
- Better cache locality - Queries for org data hit fewer index segments
- Faster range scans - Related entities are physically adjacent
- 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
Partitions are NOT security boundaries - Any query can access any partition. Use application-level access control.
Partition assignment is permanent - Entity IDs encode their partition. Plan your data model accordingly.
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
- Owner/admin invites via
:command/invite-memberwith email and role - Server creates invitation with a secure token and sends an email with a link
- Recipient clicks link → lands on the
/invite/acceptpage :command/accept-invitationvalidates the token, marks the invite as accepted, and stores the organisation in the session- 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
| Command | Auth | Description |
|---|---|---|
:command/invite-member | Required (owner/admin) | Send an invitation email |
:command/accept-invitation | None | Accept an invitation by token + email |
:command/register-member | None (session) | Register as an invited user |
Member Management
Owners and admins can manage organisation members through CQRS commands.
Available Commands
| Command | Auth | Description |
|---|---|---|
:command/remove-member | Required (owner/admin) | Remove a member by membership ID |
:command/update-member-role | Required (owner/admin) | Change a member's role |
:command/update-organisation | Required (owner/admin) | Update org name/slug |
:query/get-organisation | Required | Get 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