When I scaffolded my project, an enterprise workflow platform on ABP Framework, Elsa 3.x, EF Core, and PostgreSQL, I ran into a problem that never shows up in either framework's docs, because each framework assumes it's the only one in the room.
ABP wants you to expose functionality through application services. That's the whole point of the framework: you write a C# class, decorate it with a permission attribute, and ABP auto-generates a REST controller for it. No manual routing, no manual DTO wiring if you let AutoMapper profiles do the work.
Elsa also wants to expose functionality over HTTP. It ships an HttpEndpoint activity that lets a workflow itself define a route, accept a request, and respond, no ASP.NET controller involved at all. You drop the activity into a workflow definition and Elsa wires up the endpoint for you.
Put those two together on the same project and you get two frameworks each offering to be your public API. That's not a hypothetical: it's what I had in front of me the day I needed to expose "start an approval workflow" and "complete a human task" to the rest of the system. Elsa would happily let me do it inside the workflow definition. ABP would happily let me do it as an application service. Pick wrong and you end up with two different ways to call what is conceptually the same operation: discoverable through two different mechanisms, secured by two different permission systems, versioned (or not) by two different conventions.
Why this is worse than it sounds
The obvious reaction is "just pick one and move on." The problem is that Elsa's HttpEndpoint is convenient in a way that's easy to fall for. You're already inside the workflow definition, you already have the business logic for what happens when a request comes in, and Elsa gives you a working route with zero extra files. For a demo, or a single standalone workflow, that's a fine trade.
But ProcessHub isn't a single workflow. It's a platform where workflows are one implementation detail behind an approval-task inbox, an audit trail, role-based access, and caching. The moment you let Elsa own the public boundary for even one endpoint, you've committed to two rules for how the outside world talks to your system:
- "For approval submission, call this ABP application service, which is authorized by an ABP permission, logged by the ABP audit system, and documented by the ABP Swagger integration."
- "For task completion, call this Elsa
HttpEndpoint, which is authorized however that workflow definition wired it up, logged wherever Elsa's execution log happens to write it, and documented nowhere in particular."
Neither rule is wrong in isolation. Together, they mean every new integration has to ask "wait, is this one of the Elsa-exposed operations or an ABP one?" before it can even find the right client to call. That's the kind of inconsistency that looks small in a design doc and turns into a support ticket six months later when someone can't find the endpoint for a feature they know exists.
The rule I landed on
The public boundary is the application service, not the workflow engine's own endpoints. Elsa runs entirely behind that boundary. No HttpEndpoint activities, no direct client-to-Elsa communication of any kind.
Concretely, that means the approval flow looks like this:
public class WorkflowTaskAppService : ApplicationService, IWorkflowTaskAppService
{
private readonly IApprovalTaskRepository _tasks;
private readonly IRunTaskRequestHandler _runTaskHandler;
[Authorize(ProcessHubPermissions.Tasks.Complete)]
public async Task CompleteAsync(CompleteTaskInput input)
{
var task = await _tasks.GetAsync(input.TaskId);
// ApprovalTask is our own aggregate, not Elsa's execution state.
task.Complete(CurrentUser.Id, input.Decision, input.Comment);
await _tasks.UpdateAsync(task);
// Resume the suspended Elsa workflow through our handler,
// not through any endpoint Elsa exposes itself.
await _runTaskHandler.ResumeAsync(task.WorkflowInstanceId, input.Decision);
}
}
The workflow itself contains a RunTask activity that suspends execution and waits. Nothing about that activity is reachable from outside the process. The only door in is WorkflowTaskAppService, authorized by an ABP permission (ProcessHubPermissions.Tasks.Complete). It shows up in the same Swagger document as every other endpoint in the system, and it gets logged by the same audit tables that log everything else: the append-only domain tables I built independent of Elsa's own execution log, specifically so audit history doesn't depend on which engine happens to be running underneath.
ApprovalTask is worth calling out too. It's a domain aggregate I own, not Elsa's workflow instance state. The application service reads and writes it directly. Elsa's job is to drive the state machine that decides when a task becomes eligible and what happens after it's resolved. It doesn't get to be the system of record for what the task inbox shows a user.
Why the "just use both" instinct is wrong here
I get why the instinct is to use HttpEndpoint for anything workflow-shaped and application services for everything else. It feels like using each tool for what it's good at. But that's a category error. HttpEndpoint is good at "this workflow needs to react to an inbound webhook from a third party who doesn't know or care about your internal architecture." It is not good at "this is a first-class operation my own frontend calls," because it opts that operation out of every convention ABP gives you for free: permission checks, DTO validation, Swagger docs, the audit trail, the unit-of-work interceptor that wraps SaveChanges around the request.
Once I framed it that way, the rule got easy to defend in code review. If the caller is internal, meaning my own UI or my own downstream services, it goes through an application service, full stop. If the caller is a genuinely external system triggering a workflow with no other integration in the codebase (say, a webhook from a payment provider), HttpEndpoint is fine, because there's no ABP convention being bypassed. There was never an ABP endpoint for that caller anyway.
The Native AOT wrinkle, and why it doesn't change the answer
There's a second reason application services stay the boundary, and it's less about design taste and more about what ABP actually costs you. ABP builds application services on dynamic proxies and interceptors: the unit-of-work wrapper, the auto-validation, the auditing all run through interception at runtime. That's convenient, but it means Native AOT is off the table for this codebase. If you're chasing trimmed, ahead-of-time-compiled binaries, ABP's application service layer isn't going to get you there.
It also means unit-testing an application service in true isolation is awkward. Mock the repository, call the method directly, and you've bypassed the interceptor pipeline that does half the actual work (the unit-of-work commit, the auditing, the authorization check). I stopped fighting that and write integration tests through the HTTP boundary instead, hitting the real controller ABP generates. Slower than a pure unit test, but it's testing the thing that's actually going to run in production, interceptors included.
None of that pushed me toward letting Elsa own more of the surface. If anything it reinforced the opposite: if application services are going to cost me AOT and force integration-style testing, I want that cost paid once, at one consistent boundary, not paid partially while a second boundary quietly exists elsewhere with different rules.
The general version of this
If you're bringing ABP into a project that already has an opinionated engine underneath it, whether that's a workflow engine, a job scheduler, or a message bus with its own HTTP hooks, check whether that engine wants to expose its own API surface. A lot of them do, because it's a nice feature to advertise in isolation. Almost none of them coordinate with whatever else in your stack is also trying to be "the" API.
The fix isn't a framework setting. It's a decision you make explicitly and write down: one thing owns the public boundary, and everything else, no matter how convenient its shortcut looks, runs behind it. On ProcessHub that's the ABP application service layer: modules instead of Program.cs registrations, permission attributes instead of manual IAuthorizationService checks, with Elsa treated as an internal state machine and nothing more. It's a small rule. It's also the one I've had to re-explain the most, because the alternative always looks like less code in the moment you're writing it.
Top comments (0)