← Back to list

Building a Custom Workflow Executor for WSO2 API Manager 4.2.0

How I extended WSO2 APIM’s workflow engine to send rich HTML email notifications on both approval and rejection of application creation and…

Nelush Gayashan Fernando · 2026-06-13 17:52 · 0 claps · 15.8 min read
#java #java17 #wso2 #wso2-api-manager
Open on Medium ↗
Wiki topics: 🌐 · Web Development

Building a Custom Workflow Executor for WSO2 API Manager 4.2.0 — Full Lifecycle HTML Email Notifications (Approval + Rejection)

How I extended WSO2 APIM’s workflow engine to send rich HTML email notifications on both approval and rejection of application creation and API subscription events and why every “obvious” approach failed before the right one worked.

Table of Contents

  1. The Problem Statement
  2. What is WSO2 API Manager?
  3. What are WSO2 Workflows?
  4. The Requirements (Expanded)
  5. How the Requirements Are Met
  6. Technology Stack
  7. The OSGi Constraint — Why This Is Hard
  8. Project Architecture
  9. ApplicationWorkflowDTO and SubscriptionWorkflowDTO
  10. CustomApplicationExecutor — execute() — Approve + Notify — complete() — Handle Rejection — executeNotificationSequence() — Shared Template Dispatch — getEmailInternally() — WSO2 Identity Stack
  11. CustomSubscriptionExecutor
  12. EmailUtil — Async SMTP with System Properties
  13. HtmlTemplates — Pure Java Rendering, Now with Rejection Layouts
  14. The pom.xml — OSGi Bundle Configuration
  15. The Journey: What Failed and Why (Updated)
  16. Deployment (with Cache Wipe)
  17. WSO2 Configuration
  18. End-to-End Flow (Approval and Rejection)
  19. Testing Strategy — Unit Tests with Mocked OSGi
  20. Key Takeaways

The Problem Statement

WSO2 API Manager is the backbone of many enterprise API ecosystems. Out of the box, when a developer creates an application or subscribes to an API in the Developer Portal, WSO2 simply processes the request and approves it silently. No one is notified. The admin doesn’t know a new application was registered. The API publisher doesn’t know their API just gained a new consumer. The developer gets no confirmation email.

Worse: When an administrator rejects a workflow task (via the Admin Portal’s task listing), there is zero communication. The developer never learns why their request was denied, and the audit trail is incomplete.

Photo by Finn Mund on Unsplash

Photo by Finn Mund on Unsplash

For organizations running WSO2 in a real environment, this is a gap. Audit trails require notification. Developers expect confirmation emails and rejection explanations. API publishers want to know when their APIs are being consumed (or when access is denied).

The task: intercept WSO2’s application creation and subscription creation workflow events, dispatch rich HTML email notifications on both approval and rejection to all relevant stakeholders without breaking WSO2’s existing behavior and without introducing fragile dependencies.

This sounds straightforward. It is not.

What is WSO2 API Manager?

WSO2 API Manager (APIM) is an open-source, enterprise-grade API management platform. It handles the full API lifecycle from design and development to publishing, security, throttling, analytics, and retirement.

The platform consists of several portals and components running inside a single server:

  • Publisher Portal — where API developers design, publish, and manage APIs
  • Developer Portal — where application developers discover APIs, create applications, subscribe, and generate OAuth credentials
  • Admin Portal — where platform administrators manage throttling policies, workflow approvals, and analytics
  • Gateway — the runtime proxy that enforces security, throttling, and mediation on API traffic
  • Key Manager — handles OAuth 2.0 token issuance and validation

WSO2 APIM runs on WSO2 Carbon a modular, OSGi‑based middleware platform built on Eclipse Equinox. Every component of the server including the API manager itself is an OSGi bundle. This is the foundation of the engineering challenge in this project.

Photo by Jo Szczepanska on Unsplash

Photo by Jo Szczepanska on Unsplash

What are WSO2 Workflows?

WSO2 API Manager exposes a workflow extension mechanism that allows custom Java code to intercept lifecycle events before they are committed.

These events include:

Each event type has a corresponding executor class configured in workflow-extensions.xml. When the event occurs, WSO2 instantiates the configured executor and calls execute(WorkflowDTO).

The executor must return a WorkflowResponse that tells WSO2 how to proceed:

  • APPROVED — the event proceeds immediately
  • REJECTED — the event is denied
  • CREATED — the event is pending an asynchronous external callback

The simple executors (all the *SimpleWorkflowExecutor classes) always return APPROVED immediately — they are WSO2’s default auto‑approve implementation. This project extends those simple executors, keeping the auto‑approve behavior intact while injecting notification logic.

Crucially, workflow tasks can also be rejected asynchronously via the Admin Portal. When an admin completes a pending task with REJECTED status, WSO2 calls the executor’s complete(WorkflowDTO) method. This project overrides complete() to send rejection‑specific HTML emails.

The Requirements (Expanded)

Let’s make the requirements explicit before looking at the implementation.

Functional Requirements (Approval):

  1. When a developer creates an application, send an HTML email to the admin with full application details.
  2. Also send a confirmation HTML email to the developer.
  3. When a developer subscribes to an API, send an HTML email to the admin with subscriber and API details.
  4. Send a notification HTML email to the API publisher.
  5. Send a confirmation HTML email to the subscribing developer.

Functional Requirements (Rejection):

  1. When an administrator rejects an application creation task, send a rejection HTML email to the admin (audit log) and to the developer.
  2. When an administrator rejects a subscription creation task, send a rejection HTML email to the admin and to the developer.
  3. Emails must be styled HTML not plain text with tables, color headers (green for approval, red for rejection), and structured layout.
  4. Email failures must never affect the workflow outcome applications and subscriptions must still be approved/rejected correctly even if email dispatch fails.

Photo by Markus Winkler on Unsplash

Photo by Markus Winkler on Unsplash

Non‑Functional Requirements:

  1. The solution must be deployable as an OSGi bundle to WSO2’s dropins/ directory.
  2. Email dispatch must be asynchronous the workflow response must not be delayed by SMTP operations.
  3. Zero external library dependencies the solution must work within Equinox’s classloader constraints.
  4. All user‑supplied data inserted into HTML must be properly escaped (XSS prevention).
  5. SMTP configuration (host, port, from address) must be externalizable via JVM system properties no recompilation for different environments.

How the Requirements Are Met

| Requirement                             | Solution                                                                                                                                                                                   |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 1–5: Approval emails                    | Extend `ApplicationCreationSimpleWorkflowExecutor` and `SubscriptionCreationSimpleWorkflowExecutor`, override `execute()`, call `super.execute()` first, then dispatch approval templates. |
| 6–7: Rejection emails                   | Override `complete()`, check `WorkflowStatus.REJECTED`, dispatch rejection templates.                                                                                                      |
| 8: Styled HTML emails                   | `HtmlTemplates.java` – pure Java HTML builder with CSS, tables, gradients (green for approval, red for rejection).                                                                         |
| 9: Email failures don't affect workflow | All notification code wrapped in `try-catch` after `super.execute()` / `super.complete()`.                                                                                                 |
| 10: OSGi bundle                         | `maven-bundle-plugin` with `<packaging>bundle</packaging>`.                                                                                                                                |
| 11: Async dispatch                      | `Executors.newSingleThreadExecutor()` in `EmailUtil`.                                                                                                                                      |
| 12: Zero external deps                  | No compile-scope dependencies – `HtmlTemplates` replaces Thymeleaf / FreeMarker.                                                                                                           |
| 13: HTML escaping                       | `HtmlTemplates.escape()` applied to all model values via `v()` helper.                                                                                                                     |
| 14: Externalizable SMTP                 | `EmailUtil` reads `email.smtp.host`, `email.smtp.port`, `email.smtp.from` from `System.getProperty()`.                                                                                     |

Technology Stack

Notably absent: Thymeleaf, Spring, FreeMarker, Jackson, or any other third‑party library. This is a deliberate architectural decision, explained in detail in the OSGi section below.

The OSGi Constraint — Why This Is Hard

To understand why this project’s architecture is what it is, you need to understand OSGi classloading.

What is OSGi?

OSGi (Open Service Gateway Initiative) is a Java module system and component framework. In OSGi, each component called a bundle is a JAR with additional metadata in META-INF/MANIFEST.MF. The metadata declares:

  • What packages this bundle exports (makes available to others)
  • What packages this bundle imports (needs from others)
  • The bundle’s symbolic name and version

The OSGi container manages a runtime where each bundle gets its own isolated classloader. A class loaded by Bundle A is a completely different type from the same class loaded by Bundle B, even if they came from the same JAR.

How WSO2 uses OSGi

WSO2 API Manager is built entirely on OSGi. Every WSO2 component the API publisher, developer portal, key manager, analytics is an OSGi bundle. When you extend a WSO2 class like ApplicationCreationSimpleWorkflowExecutor, your bundle’s classloader must have access to the package containing that class. You declare this in Import-Package, and Equinox wires your bundle’s classloader to the bundle that exports that package.

The classloading problem with external libraries

When you write an OSGi bundle that uses Thymeleaf, you have multiple options — all of which fail in Equinox 3.14 + WSO2 dropins/. The only reliable solution is to eliminate external dependencies entirely. HtmlTemplates.java requires no packages beyond java.util.Map already available in the JDK.

This is not a workaround. For an OSGi bundle running in an embedded enterprise container like WSO2’s Equinox, depending only on packages exported by the runtime is the architecturally correct design.

Project Architecture

┌─────────────────────────────────────────────────────────────────────────┐
│                      WSO2 API Manager 4.2.0                             │
│                     (Eclipse Equinox OSGi)                              │
│                                                                         │
│  Developer Portal ──► ApplicationsApiServiceImpl                        │
│                              │                                          │
│                              ▼                                          │
│                    APIConsumerImpl.addApplication()                     │
│                              │                                          │
│                              ▼                                          │
│              ┌───────────────────────────────────┐                      │
│              │   CustomApplicationExecutor       │ ← YOUR BUNDLE        │
│              │                                   │                      │
│              │  execute():                       │                      │
│              │   1. super.execute() → APPROVED   │                      │
│              │   2. dispatch approval emails     │                      │
│              │                                   │                      │
│              │  complete():                      │                      │
│              │   1. super.complete()             │                      │
│              │   2. if REJECTED → dispatch       │                      │
│              │      rejection emails             │                      │
│              └───────────────────────────────────┘                      │
│                                                                         │
│  Admin Portal ──► Workflow complete (REJECTED)                          │
│                              │                                          │
│                              ▼                                          │
│              ┌───────────────────────────────────┐                      │
│              │   CustomSubscriptionExecutor      │ ← YOUR BUNDLE        │
│              │   (same pattern)                  │                      │
│              └───────────────────────────────────┘                      │
│                                                                         │
│         ┌──────────────────────────────────────────────────┐            │
│         │    Carbon Identity Stack                         │            │
│         │    ServiceReferenceHolder → RealmService         │            │
│         │    → UserRealm → UserStoreManager                │            │
│         │    → getUserClaimValue("emailaddress")           │            │
│         └──────────────────────────────────────────────────┘            │
└─────────────────────────────────────────────────────────────────────────┘
                    │ SMTP (configurable via -D flags)
                    ▼
           ┌─────────────────┐
           │  MailHog / SMTP │
           └─────────────────┘

The key design principles:

  • Additive only — super.execute() / super.complete() are always called first. Your code adds behavior; it never replaces behavior.
  • Fail‑safe — all notification code runs inside a try-catch. Exceptions are logged and swallowed. The WorkflowResponse from the superclass is always returned.
  • Non‑blocking — EmailUtil submits all SMTP work to a background ExecutorService. The WSO2 request thread returns immediately.
  • Zero dependencies — the bundle imports only packages that WSO2’s runtime already provides.
  • Environment‑agnostic SMTP — host, port, and From address are read from system properties, enabling different configs per environment without recompilation.

ApplicationWorkflowDTO and SubscriptionWorkflowDTO

WorkflowDTO — The Base

org.wso2.carbon.apimgt.impl.dto.WorkflowDTO is the base DTO for all workflow events. It carries common fields: workflowReference, tenantDomain, tenantId, workflowStatus, callbackUrl, and a properties map.

ApplicationWorkflowDTO — Application‑Specific

ApplicationWorkflowDTO extends WorkflowDTO and adds:

  • getUserName() – the developer who created the application
  • getApplication() – a full Application object with name, tier, tokenType, description, etc.

In your executor, you downcast:

ApplicationWorkflowDTO appDTO = (ApplicationWorkflowDTO) workflowDTO;
String appName = appDTO.getApplication().getName();

SubscriptionWorkflowDTO — Subscription‑Specific

SubscriptionWorkflowDTO extends WorkflowDTO and adds flattened fields (no nested object):

  • getSubscriber() – the developer subscribing
  • getApplicationName() – name of the application (string only)
  • getApiName(), getApiVersion(), getApiProvider(), getTierName()

Important asymmetry: ApplicationWorkflowDTO gives you a full Application object; SubscriptionWorkflowDTO gives you primitive strings. Both are handled transparently in the respective executors.

⚠️ Workflow Reference Parsing Restriction WSO2’s internal workflow engine parses workflowReference using Integer.parseInt() in some code paths. Both runtime values and unit test mocks must supply purely numeric strings (e.g., "998877"), otherwise you risk NumberFormatException. This project respects that constraint in all tests and production code.

CustomApplicationExecutor

Now let’s walk through the full executor implementation including both execute() (approval) and complete() (rejection) handlers.

public class CustomApplicationExecutor extends ApplicationCreationSimpleWorkflowExecutor {
    private static final Log log = LogFactory.getLog(CustomApplicationExecutor.class);

execute() — Approve + Notify

@Override
public WorkflowResponse execute(WorkflowDTO workflowDTO) throws WorkflowException {
    log.info("Executing custom HTML interceptor for Application Creation Workflow initiation...");
    WorkflowResponse response = super.execute(workflowDTO);
try {
        executeNotificationSequence(workflowDTO, "APPROVED");
    } catch (Exception e) {
        log.error("Failed executing custom application approval HTML notification dispatch loops.", e);
    }
    return response;
}
  • super.execute() persists the application and returns an APPROVED response.
  • The approval‑specific notification sequence is invoked with status "APPROVED".
  • Any exception is logged but does not change the returned response.

complete() — Handle Rejection

@Override
public WorkflowResponse complete(WorkflowDTO workflowDTO) throws WorkflowException {
    log.info("Executing custom HTML interceptor for Application Creation Workflow completion evaluation...");
    WorkflowResponse response = super.complete(workflowDTO);
try {
        if (WorkflowStatus.REJECTED.equals(workflowDTO.getStatus())) {
            executeNotificationSequence(workflowDTO, "REJECTED");
        }
    } catch (Exception e) {
        log.error("Failed executing custom application rejection HTML notification dispatch loops.", e);
    }
    return response;
}
  • super.complete() finalises the workflow state in WSO2’s database.
  • If the admin rejected the task, we call the same executeNotificationSequence() with "REJECTED".
  • Rejection emails go to both the admin (audit) and the developer.

executeNotificationSequence() — Shared Template Dispatch

private void executeNotificationSequence(WorkflowDTO workflowDTO, String targetStatus) {
    ApplicationWorkflowDTO appDTO = (ApplicationWorkflowDTO) workflowDTO;
    String creator = appDTO.getUserName();
    String tenantDomain = appDTO.getTenantDomain();
String creatorEmail = getEmailInternally(creator, tenantDomain);
    String adminEmail = getEmailInternally("admin", tenantDomain);
    String timestamp = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date());
    Map<String, Object> model = new HashMap<>();
    model.put("applicationName", appDTO.getApplication().getName());
    model.put("userName", creator);
    model.put("tenantDomain", tenantDomain);
    model.put("applicationTier", appDTO.getApplication().getTier());
    model.put("tokenType", appDTO.getApplication().getTokenType());
    model.put("description", appDTO.getApplication().getDescription());
    model.put("timestamp", timestamp);
    model.put("workflowRef", appDTO.getWorkflowReference());
    if ("REJECTED".equals(targetStatus)) {
        if (adminEmail != null) {
            EmailUtil.sendHtmlEmail(adminEmail, "❌ Application Request Rejected (Audit Log)",
                                    "admin_application_rejected", model);
        }
        if (creatorEmail != null) {
            EmailUtil.sendHtmlEmail(creatorEmail, "🛑 Notice: Your Application Request Was Declined",
                                    "developer_application_rejected", model);
        }
    } else {
        if (adminEmail != null) {
            EmailUtil.sendHtmlEmail(adminEmail, "⚠️ New Application Created",
                                    "admin_application_created", model);
        }
        if (creatorEmail != null) {
            EmailUtil.sendHtmlEmail(creatorEmail, "✓ Application Created Successfully",
                                    "developer_application_created", model);
        }
    }
}
  • The same model map is reused for both approval and rejection templates; only the template name and subject line differ.
  • getEmailInternally() fetches the email claim from WSO2’s user store (see below).
  • All dispatches are asynchronous via EmailUtil.

getEmailInternally() — WSO2 Identity Stack

private String getEmailInternally(String username, String tenantDomain) {
    try {
        int tenantId = APIUtil.getTenantId(tenantDomain);
        return ServiceReferenceHolder.getInstance().getRealmService()
                .getTenantUserRealm(tenantId).getUserStoreManager()
                .getUserClaimValue(username, "http://wso2.org/claims/emailaddress", null);
    } catch (Exception e) {
        log.error("Failed to fetch internal email identity context for: " + username, e);
        return null;
    }
}

This chain traverses WSO2’s identity infrastructure:

  • APIUtil.getTenantId() converts domain to tenant ID.
  • ServiceReferenceHolder gives access to the OSGi RealmService.
  • getTenantUserRealm() isolates the correct tenant.
  • getUserStoreManager() returns the user store (JDBC, LDAP, AD, etc.).
  • getUserClaimValue() extracts the email claim URI.

If any step fails, null is returned and that recipient is skipped.

CustomSubscriptionExecutor

The subscription executor follows exactly the same pattern, but with three recipients (admin, publisher, subscriber) and different DTO fields:

public class CustomSubscriptionExecutor extends SubscriptionCreationSimpleWorkflowExecutor {
    // execute() and complete() similar to application executor
private void executeNotificationSequence(WorkflowDTO workflowDTO, String targetStatus) {
        SubscriptionWorkflowDTO subDTO = (SubscriptionWorkflowDTO) workflowDTO;
        String subscriber = subDTO.getSubscriber();
        String tenantDomain = subDTO.getTenantDomain();
        String apiProvider = subDTO.getApiProvider();
        String subscriberEmail = getEmailInternally(subscriber, tenantDomain);
        String adminEmail = getEmailInternally("admin", tenantDomain);
        Map<String, Object> model = new HashMap<>();
        model.put("subscriber", subscriber);
        model.put("applicationName", subDTO.getApplicationName());
        model.put("tenantDomain", tenantDomain);
        model.put("apiName", subDTO.getApiName());
        model.put("apiVersion", subDTO.getApiVersion());
        model.put("apiProvider", apiProvider);
        model.put("tierName", subDTO.getTierName());
        model.put("timestamp", timestamp);
        model.put("workflowRef", subDTO.getWorkflowReference());
        if ("REJECTED".equals(targetStatus)) {
            // Admin + subscriber rejection emails
        } else {
            String providerEmail = getEmailInternally(apiProvider, tenantDomain);
            // Admin + provider + subscriber approval emails
        }
    }
}

Note that getEmailInternally() is reused verbatim.

EmailUtil — Async SMTP with System Properties

public class EmailUtil {
    private static final ExecutorService executorService = Executors.newSingleThreadExecutor();
private static final String SMTP_HOST = System.getProperty("email.smtp.host", "localhost");
    private static final String SMTP_PORT = System.getProperty("email.smtp.port", "1025");
    private static final String FROM_ADDRESS = System.getProperty("email.smtp.from", "noreply@wso2.local");
    public static void sendHtmlEmail(final String toAddress, final String subject,
                                     final String templateName, final Map<String, Object> model) {
        executorService.submit(() -> {
            try {
                String htmlContent = HtmlTemplates.render(templateName, model);
                Properties props = new Properties();
                props.put("mail.smtp.host", SMTP_HOST);
                props.put("mail.smtp.port", SMTP_PORT);
                Session session = Session.getInstance(props);
                Message message = new MimeMessage(session);
                message.setFrom(new InternetAddress(FROM_ADDRESS));
                message.setRecipients(Message.RecipientType.TO, InternetAddress.parse(toAddress));
                message.setSubject(subject);
                message.setContent(htmlContent, "text/html; charset=utf-8");
                Transport.send(message);
            } catch (Exception e) {
                log.error("Failed to send email to: " + toAddress, e);
            }
        });
    }
}

Why system properties? Different environments (dev, staging, prod) have different SMTP relays. Hardcoding localhost would break in production. By reading from System.getProperty(), you can override at startup:

-Demail.smtp.host=smtp.company.com -Demail.smtp.port=587 -Demail.smtp.from=wso2@company.com

Why newSingleThreadExecutor()? Emails are sent sequentially – no benefit to parallel SMTP connections. A single thread provides natural backpressure and avoids resource exhaustion.

HtmlTemplates — Pure Java Rendering, Now with Rejection Layouts

HtmlTemplates.java contains nine email layouts:

All templates share:

  • A responsive CSS framework (gradient headers, table styling, badge elements)
  • Safe variable substitution via v(Map, key) and escape(String)
  • A footer() with WSO2 branding

Example rejection template (admin):

private static String adminApplicationRejected(Map<String, Object> m) {
    return head("Application Request Rejected", "#450a0a", "#b91c1c")
        + "<div class=\"header\"><h1>❌ Application Request Rejected</h1>..."
        + row("Application Name", v(m, "applicationName"))
        + row("Requested By", v(m, "userName"))
        + ...
        + "<div class=\"ref-box\" style=\"background:#fef2f2;border-left:4px solid #ef4444;\">"
        + "<strong>Workflow Reference:</strong> " + v(m, "workflowRef") + "</div>"
        + footer();
}

Notice the red gradient (#450a0a to #b91c1c) and the red‑bordered reference box – immediate visual distinction from approval emails (blue or green gradients).

The pom.xml — OSGi Bundle Configuration

The pom.xml uses maven-bundle-plugin to generate a proper OSGi manifest:

<packaging>bundle</packaging>
<plugin>
    <groupId>org.apache.felix</groupId>
    <artifactId>maven-bundle-plugin</artifactId>
    <version>5.1.8</version>
    <extensions>true</extensions>
    <configuration>
        <instructions>
            <Bundle-SymbolicName>${project.artifactId}</Bundle-SymbolicName>
            <Export-Package>com.mycompany.wso2.workflow.*</Export-Package>
            <Import-Package>
                org.wso2.carbon.apimgt.impl.workflow.*,
                org.wso2.carbon.apimgt.impl.*,
                org.wso2.carbon.apimgt.api.*,
                org.wso2.carbon.user.core.*,
                org.wso2.carbon.utils.*,
                org.apache.commons.logging.*;version="[1.2,2)",
                javax.mail.*;version="[1.6,2)",
                *;resolution:=optional
            </Import-Package>
        </instructions>
    </configuration>
</plugin>
  • All dependencies are provided scope – they are not bundled.
  • Export-Package makes your executor classes visible to WSO2’s reflection‑based workflow factory.
  • Import-Package declares exactly which packages your bundle needs from the OSGi container.
  • *;resolution:=optional catches standard JDK packages.

The Journey: What Failed and Why (Updated)

This section documents the engineering path to the final solution — because understanding what doesn’t work is as valuable as knowing what does.

Attempt 1: Thymeleaf with Embed-Dependency

Result: NoClassDefFoundError: org/thymeleaf/templateresolver/ITemplateResolver Why: Equinox 3.14 does not scan nested JARs for dropins/ bundles.

Attempt 2: Thymeleaf with inline=true

Result: NoClassDefFoundError: ognl/OgnlException Why: Thymeleaf uses OGNL, which uses javassist. javassist loads classes via the thread context classloader (Equinox’s system loader), not your bundle’s loader.

Attempt 3: maven-shade-plugin fat JAR

Result: Missing Import-Package → can’t wire to WSO2 classes. Why: Shade cannot generate correct OSGi metadata.

The Solution

Remove all template engines. Build HTML with pure Java string concatenation and CSS embedded in <style> blocks. The resulting HtmlTemplates.java is self‑contained, requires no OSGi wiring beyond java.util.Map, and produces identical HTML output.

New in this version: Rejection templates follow the same zero‑dependency principle, adding ~400 lines of Java‑based HTML without any new runtime dependencies.

Deployment (with Cache Wipe)

The deployment sequence is critical. Simply copying a new JAR to dropins/ is insufficient because Equinox caches bundle state in work/osgi/.

# 1. Stop WSO2
# 2. Remove old JAR
Remove-Item "C:\wso2am-4.2.0\repository\components\dropins\com.mycompany.wso2.workflow-1.0.0.jar"
# 3. Clear OSGi cache - this is mandatory
Remove-Item -Recurse -Force "C:\wso2am-4.2.0\work\osgi\*"
# 4. Clear temp
Remove-Item -Recurse -Force "C:\wso2am-4.2.0\tmp\*"
# 5. Build
mvn clean package
# 6. Verify no external libraries leaked
jar tf target\com.mycompany.wso2.workflow-1.0.0.jar | findstr thymeleaf
# 7. Deploy
Copy-Item "target\com.mycompany.wso2.workflow-1.0.0.jar" `
          "C:\wso2am-4.2.0\repository\components\dropins\"
# 8. Start with --clean and system properties (example)
.\api-manager.bat --clean -Demail.smtp.host=mailhog -Demail.smtp.port=1025

WSO2 Configuration

Register the custom executors in deployment.toml:

[apim.workflow_extensions]
application_creation  = "com.mycompany.wso2.workflow.CustomApplicationExecutor"
subscription_creation = "com.mycompany.wso2.workflow.CustomSubscriptionExecutor"

For email notifications to work, users must have the email claim populated in their WSO2 profile. Set this in the Carbon Management Console at https://localhost:9443/carbon → Identity → Users and Roles → Users → [user] → User Profile → Email.

End-to-End Flow (Approval and Rejection)

Approval Flow (Application Creation)

[Browser] POST /api/am/devportal/v2/applications
    → WSO2 API → APIConsumerImpl.addApplication()
    → WorkflowExecutorFactory → CustomApplicationExecutor.execute()
        → super.execute() → persists application, returns APPROVED
        → executeNotificationSequence("APPROVED")
            → getEmailInternally("admin") → admin@co.com
            → getEmailInternally("john") → john@co.com
            → EmailUtil.sendHtmlEmail(admin, "admin_application_created")
            → EmailUtil.sendHtmlEmail(john, "developer_application_created")
            → (async) SMTP to MailHog
    → Return HTTP 201 to browser

Rejection Flow (Subscription Rejection via Admin Portal)

[Admin] In Admin Portal, finds pending subscription task, clicks "Reject"
    → WSO2 calls CustomSubscriptionExecutor.complete(workflowDTO)
        → super.complete() → finalises rejection in DB
        → workflowDTO.getStatus() == REJECTED
        → executeNotificationSequence("REJECTED")
            → getEmailInternally("admin") → admin@co.com
            → getEmailInternally("subscriber") → john@co.com
            → EmailUtil.sendHtmlEmail(admin, "admin_subscription_rejected")
            → EmailUtil.sendHtmlEmail(john, "developer_subscription_rejected")
            → (async) SMTP
    → Admin portal shows success

The HTTP response to the admin is not delayed by email sending all dispatch is asynchronous.

Testing Strategy — Unit Tests with Mocked OSGi

The project includes comprehensive unit tests using JUnit 5 and Mockito with MockedStatic to mock WSO2’s static utilities (APIUtil, ServiceReferenceHolder, EmailUtil).

Example test for application rejection:

@Test
void testCompleteRejectionGuaranteesAllDetailsArePresent() throws Exception {
    when(mockWorkflowDTO.getStatus()).thenReturn(WorkflowStatus.REJECTED);
    WorkflowResponse response = executor.complete(mockWorkflowDTO);
    assertNotNull(response);
ArgumentCaptor<Map> modelCaptor = ArgumentCaptor.forClass(Map.class);
    mockedEmailUtil.verify(() -> EmailUtil.sendHtmlEmail(
        eq("security-audit@mycompany.com"), anyString(), 
        eq("admin_application_rejected"), modelCaptor.capture()), times(1));
    Map<String, Object> model = modelCaptor.getValue();
    assertEquals("EnterpriseDataRouter", model.get("applicationName"));
    // ... more assertions
}

Key testing points:

  • workflowReference is always numeric (e.g., "998877") to avoid NumberFormatException.
  • Static mocks are closed in @AfterEach to prevent test pollution.
  • All nine templates are verified for missing values and XSS escaping.

Run tests with:

mvn clean test
mvn verify   # generates JaCoCo coverage report

Key Takeaways

  1. Always call super.execute() / super.complete() first The parent executor is responsible for persisting data and returning the correct WorkflowResponse. Your code must not prevent this. Wrap your notification logic in try-catch and return the superclass response unchanged.

  2. OSGi classloading is not regular Java classloading In Equinox’s isolated bundle classloader model, embedded nested JARs have documented limitations. When deploying to WSO2’s dropins/, the only reliable approach is to import only packages that WSO2’s runtime already exports.

  3. Support both execute() and complete() for full lifecycle Workflow tasks can be approved synchronously (execute()) or rejected asynchronously (complete()). Override both to ensure notifications are sent in all cases.

  4. Async dispatch is non‑negotiable A workflow executor runs in the hot path of a REST API request (or an admin portal action). Any blocking operation directly adds latency. Using ExecutorService for SMTP dispatch completely decouples email delivery from request latency.

  5. Externalise configuration via system properties SMTP host, port, and From address differ across environments. Reading them from System.getProperty() avoids recompilation and keeps the bundle environment‑agnostic.

  6. Rejection emails are as important as approval emails Developers need to know why their request was denied. Administrators need audit logs of rejections. The same template engine can produce red‑themed rejection emails with zero additional complexity.

  7. Test with mocked static calls WSO2 heavily uses static utilities (APIUtil, ServiceReferenceHolder). Mockito’s MockedStatic allows you to test your executor logic in isolation without a running WSO2 server.

  8. Clear work/osgi/ on every redeployment Equinox’s bundle cache is persistent by design. work/osgi/* must be deleted on every redeployment of a bundle, otherwise the runtime may run cached wiring from an old version.

The complete source code for this project is available on GitHub: github.com/NelushGayashan/wso2-custom-executor

Built against WSO2 API Manager 4.2.0 — Equinox OSGi 3.14 — Java 17

About the Author

Nelush Gayashan Fernando is a Lead Software Engineer with expertise in full-stack development, specializing in Java, Spring Boot, ReactJS, and scalable microservices architecture. With a background spanning banking, enterprise software, and API management, he writes about data structures, backend engineering, and practical software development best practices.

Connect with Nelush: 🔗 LinkedIn · ✍️ Medium · 💻 GitHub

If you found this article helpful, give it a clap 👏 and follow for more content on software engineering and backend development!


메타데이터
post_id
ce72e41bfc0f
slug
building-a-custom-workflow-executor-for-wso2-api-manager-4-2-0-ce72e41bfc0f
url
https://medium.com/@nelushgayashan/building-a-custom-workflow-executor-for-wso2-api-manager-4-2-0-ce72e41bfc0f
canonical_url
https://medium.com/@nelushgayashan/building-a-custom-workflow-executor-for-wso2-api-manager-4-2-0-ce72e41bfc0f
author_url
https://medium.com/@nelushgayashan
status
ok
fetched_at
2026-06-22 00:13:37