Designing a managed-package file API: one core, every surface
Sliick Files lets a Salesforce org keep working the way Salesforce works while its files live wherever the org decides: Salesforce Files, Sliick Cloud, an S3 bucket, Google Cloud Storage, Azure Blob, or SharePoint. The latest release opens those operations to your own code and automation through three surfaces:
- A public Apex API (
sliick.FilesApi) for subscriber code: upload, list, download URLs, file bodies, delete, rename, move, and relocate, all bulk-first and provider-agnostic. - A dynamic surface (
System.Callable) so loosely coupled code and sibling packages can call operations by name with no compile-time dependency, and feature-detect withgetCapabilities. - Six Flow actions plus a screen-flow upload component for point-and-click automation under one Flow Builder category.
This article is not the feature tour. It is the design rationale: what a good managed-package API has to satisfy, the alternatives we evaluated, and why the shape we shipped is the one that wins. The reference material lives in the Apex and Flow reference; this is the thinking underneath it.
The design goals
A public API inside a 2GP managed package is one of the least forgiving artefacts you can ship on the platform. Five goals framed every decision.
It has to survive a one-way door. Once a global Apex symbol ships in a
promoted package version, it can never be removed or have its signature
changed. The surface can only grow. So the design brief is not “what is the
best API today” but “what shape can absorb a decade of additions without a
breaking change.”
It has to serve more than one caller, identically. Subscriber Apex, a sibling package with no hard dependency, an admin in Flow Builder, and our own Lightning UI all need the same operations. If their behaviour can drift, the contract is a lie.
It has to be safe by construction. Every operation touches files and the records they hang off. CRUD, FLS, record-level sharing, and an entitlement boundary have to hold on every path, not just the one the UI happens to take.
It has to be honest about partial failure. Bulk file work crosses HTTP callouts to cloud storage, and callouts cannot be rolled back. An API that pretends a 200-item batch either fully succeeds or fully fails is lying about what the network did.
It has to be cheap to extend. Every new surface, verb, or provider should be additive and small, or the one-way door turns into a reason never to move.
The architecture: one verified core, thin adapters
The core decision is the one everything else hangs from: all file logic lives in a single context-neutral core, and every surface is a thin adapter over it.
The core (FileOperations) owns everything that is hard and security
sensitive: the record-access gates, the version-group sweeps, the unified
delete semantics, the anti-enumeration link filtering, the provider routing. It
takes plain request objects, returns per-item outcomes, and throws a normal
exception type that is safe in any execution context. It knows nothing about
Aura, Flow, or dynamic invocation.
Each surface differs in exactly three mechanical ways, and nothing else:
| Surface | Inputs | Outputs | Errors |
|---|---|---|---|
| Lightning UI | component calls | UI view models | toast-friendly exceptions |
FilesApi (Apex) | typed request objects | typed result objects | per-item result codes |
Callable dynamic | name/value maps | plain maps and lists | per-item result codes |
| Flow actions | Flow Builder inputs | Flow Builder outputs | outputs for Decision elements |
There is one platform subtlety this shape is built to respect.
AuraHandledException, the exception every Lightning controller uses to talk to
its UI, may only be thrown from Aura and Visualforce contexts; thrown from a
batch job, a queueable, or anonymous Apex it becomes an uncatchable error that
kills the transaction. That single fact rules out the most tempting shortcut,
“just call the existing controllers,” and it is why the core throws a
context-neutral FilesApiException and the Lightning layer is the adapter that
translates outcomes into UI exceptions, not the other way around. Designing the
core to be context-neutral from the start is what lets the same logic run in a
trigger-driven queueable and behind a button click without a second
implementation.
Three properties fall out of this shape, and they are the actual product:
- Behaviour cannot drift between surfaces. A delete from a flow, a delete from subscriber Apex, and a delete from our gallery execute the same code. The semantics hold everywhere by construction, because there is no second implementation to forget to update.
- Verification concentrates where the risk is. The test suite hammers the core: access denials, version-group sweeps, partial failures, idempotent retries. Adapters only need mapping tests.
- A fix lands on every surface at once, including surfaces that do not exist yet.
Alternatives we evaluated
Good design is mostly the alternatives you reject on purpose. Here are the ones that mattered, and why each lost.
Let each surface own its logic
The path of least resistance: keep the operation logic in the Lightning controllers and write the Apex API, the Flow actions, and the dynamic surface each against their own copy. It ships the first surface fastest and is wrong by the second.
Duplicated security-sensitive orchestration is the most expensive code you can own. Every access-gate change, every version-group rule, every delete-semantics tweak now has to be made N times and tested N times, and the day they disagree is the day a flow deletes something the UI would have protected. The one-verified-core design pays a small extraction cost once and never pays the divergence tax. Rejected.
Open a @NamespaceAccessible back door for sibling packages
Sibling Sliick packages need file operations too, and @NamespaceAccessible
is the obvious way to hand them internal access. We rejected it as the default
mechanism on principle.
A back door creates lockstep upgrade coupling between packages, drags a
method’s whole type closure across the boundary, and opens a security path that
bypasses the entitlement gate every other caller honours. Worse, it forks the
product into two contracts, a public one and a “real” one, and the real one
quietly wins the design attention. Our policy is the opposite: when a sibling
hits a capability wall, that wall is market signal, and the response is to
extend the public contract so every subscriber benefits. Siblings get their
flexibility from building against unpromoted beta versions, where new global
signatures are still editable, not from a private channel. Rejected as the
default; the public Callable surface is the supported soft-dependency path.
Throw on the first failed item
The familiar Apex shape is to throw an exception when an operation fails. For a bulk API crossing uncommittable callouts, it is the wrong contract.
If item 3 of 200 throws, items 1 and 2 may already have written bytes to cloud
storage that no rollback can recall, and items 4 through 200 never run. The
caller is left with a partial side effect and no per-item account of what
happened. We chose the SaveResult shape instead: every method returns a
parallel-index list of results, each carrying its own success, errorCode,
and errorMessage. One bad item never aborts the rest, and the caller branches
on stable codes. Call-level exceptions are reserved for genuine whole-call
failures: a missing entitlement, an empty request list, a malformed dynamic
call. Rejected for the per-item outcome model.
Single-record methods
A single-record signature reads more naturally and could be wrapped for bulk later. On this platform that is a trap.
Salesforce batches record-triggered automation, and invocable actions are
required to accept and return collections. A single-record core forces a
loop somewhere, and a loop around callouts and DML is how you hit governor
limits in production. Designing bulk-first means the Flow adapter is a direct
mapping rather than a workaround, and a 200-record trigger is one call, not
200. We made every method List<Request> to List<Result> from the first
line. Rejected for bulk-first.
A global enum for error codes
An enum is the type-safe way to model a closed set of error codes, and for a normal codebase it would be the right call. A promoted managed-package global enum can never gain a value.
The first new failure mode we need to express after promotion would be
unrepresentable, and the workaround, a parallel “other” code with a string
discriminator, is uglier than the problem. We modelled error codes as
global static final String constants instead. New codes are purely additive,
callers still branch on a stable symbol, and the closed-set elegance was not
worth a permanent ceiling. Rejected for string constants.
Typed-only, no dynamic surface
A clean typed API is enough for subscriber Apex. It is not enough for a sibling package that must install and compile whether or not Sliick Files is present.
A static reference to sliick.FilesApi makes Sliick Files a hard dependency.
So alongside the typed surface we implement System.Callable: a sibling
resolves Type.forName('sliick', 'FilesApi'), degrades gracefully if it is
null, calls operations by name over maps, and asks getCapabilities what the
installed version supports before relying on a verb. The typed API stays the
ergonomic default; the dynamic surface is the decoupling escape hatch. We kept
both rather than forcing every caller to choose.
Expose everything to Flow
Once the Flow adapters exist, exposing the full method set looks like consistency. Two operations were deliberately held back.
The presigned-upload pair needs Map-typed headers Flow cannot model and a browser to PUT the bytes, which a flow does not have; raw base64 file bodies have no sensible Flow consumer. Surfacing them would add confusing, unusable actions in the name of symmetry. Device uploads in a screen flow are served by a purpose-built upload component instead, which uploads browser-direct to the active provider. A surface should expose what each consumer can actually use, not a mechanical mirror of the core. Rejected for a deliberate subset.
Why this design wins
Hold the result against the five goals.
The one-way door becomes safe. Every locked-in decision (bulk lists, per-item
outcomes, string error codes, opaque file IDs, idempotent confirms) was chosen
so the surface can only ever grow. New methods, new optional fields, new
Callable and Flow actions, and new providers are all additive, and
getCapabilities advertises each addition. We can say plainly: build against
this and it will not break under you.
One contract serves every caller. Apex, Callable, Flow, and our own UI run the same core, so their behaviour is identical by construction rather than by discipline. There is no “real” API hiding behind the public one.
Safety is structural. Record gates, CRUD/FLS in user mode, and the entitlement gate live in the core and apply on every path. The custom permission decides who may use the API; the org’s sharing model decides what each call can touch; the two are separate controls because they answer different questions.
Partial failure is honest. Per-item outcomes report exactly what each item did, and in Flow those same fields wire straight into a Decision element with no fault-connector gymnastics.
Extension is cheap. A new verb is a new method over the verified core, for
everyone, not a private side door for one caller. When relocateFiles was added
for large server-side files, it joined as a new method, a new error code, and a
getCapabilities entry, with no change to anything already shipped. That is the
design paying for itself.
The deeper point for any architect shipping a package API: the feature list is the smallest part of the work. The shape underneath, one verified core behind thin adapters, behind a contract built to grow and never break, is what decides whether the next ten features are cheap or whether the first promotion was a mistake you live with forever.
If you are building against the API, start with the Apex and Flow reference. If there is an operation you wish it had, the right time to tell us is before promotion, while the surface is still editable on the beta line.
We'll audit your architecture, security, and integration posture.