Filipe Sousa
arrow_backBack to Labs
Labs · Notes18 JAN 2026schedule8 min read

Building Bulk-Action Frameworks in Multi-Tenant Products

Multi-tenant products accumulate bulk operations. Copy this record from workspace A to B. Archive these ten records across three workspaces. Delete the ones no longer active. Every one of these looks different from the outside — but underneath they share almost everything: permission checks, validation, chunking, progress reporting, error aggregation, audit trail, a wizard UI on top.

If you build them one at a time, each operation becomes its own project. If you build them as a framework, the tenth operation ships in an afternoon.

The API shape

Every bulk action goes through one shared mutation:

mutation CreateBulkAction($input: CreateBulkActionInput!) {
  createBulkAction(input: $input) {
    bulkAction {
      id
      status
      resultSummary { ok skipped failed }
    }
  }
}

input.actionType identifies the operation as a string — COPY_RECORDS, ARCHIVE_RECORDS, DELETE_TAGS, whatever. input.params is a JSON blob that varies per action.

Server-side, each action registers against a shared base class:

class BulkAction::Base
  # Handles for the subclass:
  #   permission_check(actor, source, target)
  #   validate(records, targets) -> per-row conflicts
  #   perform_one(record, target) -> :ok | :skipped | { error: "..." }
  #   chunk_size (default 50)
  #   audit_event_type
end

Adding a new bulk action becomes ~50 lines: subclass BulkAction::Base, implement perform_one, register the action type. The base handles permission checks per source and per target workspace, chunking to avoid timeouts on large operations, async progress reporting via subscriptions, per-record success/failure aggregation, audit event emission, and error surfacing back to the client in a structured shape.

First bulk action took a couple of days to design. The second took an hour.

The client shape

Every bulk action from the UI runs through the same wizard steps:

  1. Select source items
  2. Select target workspaces (from the list the current user has permission in)
  3. Server-side validation → results shown with per-row conflicts flagged
  4. Resolve conflicts row by row (skip / proceed)
  5. Confirm summary
  6. Run → show progress
  7. Post-run result page

The shell is one React component tree. Individual actions plug in their item selector and their validation renderer. Nothing else varies.

<BulkActionWizard
  actionType="COPY_RECORDS"
  renderSourceSelector={(props) => <RecordSelector {...props} />}
  renderConflict={(row) => <RecordConflictRow row={row} />}
  labels={{
    action: "Copy",
    summarySingular: "record",
    summaryPlural: "records",
  }}
/>

The wizard state machine — pick → validate → resolve → confirm → run → summary — is generic. Every specific action adapts through props. Same pattern as the setup shell: one form, many uses.

Why the shape matters

Two properties that a bulk-action framework must have, and are easy to accidentally miss.

Every operation is idempotent per (record, target). If a copy from record A to workspace B fails, you can retry the whole bulk action; the framework skips (A, B) pairs that already succeeded. Without this, half-failed operations become manual cleanups.

Validation is server-side, but returned per-row for the client to render. The client can't be trusted to compute conflicts — it doesn't have the whole picture. The server can't render UI. Split the responsibilities cleanly: server returns { record → { target → status } }, client renders it. Get this API shape right on day one — refactoring it later is painful.

Permissions and scoping

Every list query behind the panel filters by "workspaces the user has permission in." This is not a simple where clause on a database — it involves a join through the role membership table.

Two rules I follow: server-side, every mutation double-checks permission. Client-side gating is a UX nicety; the server is the truth. And use policy-driven visibility instead of permission-bit-driven — can?(:manage, Record) reads better than checking three boolean columns.

Audit trail as infrastructure

Every bulk action emits a single audit event with child references. Not one event per record — one per operation, with the child records nested inside. Makes the audit log queryable at both levels. Cheap to query. Powers a future customer-facing history view.

The wizard-shell reuse

Six months after shipping the framework, an unrelated admin surface needed bulk-import for a completely different record type. The wizard shell got reused. Cost of reuse: one small refactor PR to genericise the source selector. Payoff: the new surface shipped bulk imports in a single PR instead of two weeks of work.

That compounding is the point. First operation is expensive. Second is cheap. Fifth is trivial.

Closing

If you're staring at "we need bulk copy for records" as an item on the roadmap and you don't already have a framework, the honest cost is: 2× the time for the first operation to buy 10× speed on operations 2 through N. Whether that trade is worth it depends how many bulk operations you can see landing over the next year. In my experience, always more than you think.

#multi-tenant#graphql#architecture
Filipe Sousa · Senior Full-Stack Engineer