Sliick / Articles / Developer
Developer 15 June 2026

Designing a Managed-Package File API: One Core, Every Surface

Sliick Files exposes file operations to Apex, a dynamic Callable surface, and Flow. This is the architecture walkthrough: the design goals a managed-package public API has to satisfy, the alternatives we weighed against them, and why a single verified core behind thin adapters is the design that wins.

Jerry Huang

Jerry Huang

Author

Designing a Managed-Package File API: One Core, Every Surface

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 with getCapabilities.
  • 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:

SurfaceInputsOutputsErrors
Lightning UIcomponent callsUI view modelstoast-friendly exceptions
FilesApi (Apex)typed request objectstyped result objectsper-item result codes
Callable dynamicname/value mapsplain maps and listsper-item result codes
Flow actionsFlow Builder inputsFlow Builder outputsoutputs 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.

Need a hand?
Not sure your Salesforce setup is configured correctly?

We'll audit your architecture, security, and integration posture.

Book an audit →

Share this article

Jerry Huang
Written by
Jerry Huang

Jerry Huang is the Founder & CEO of Sliick. He is passionate about building apps, helping customers succeed, and starting and scaling great businesses with the Salesforce platform. Jerry has been in tech for over two decades. He has 30 Salesforce certifications, including the Salesforce Certified Technical Architect, and an approved U.S. patent.

Continue reading