Back to Community Blog

Building Flexible Onboarding Workflows: How We Eliminated Code Deploys for Flow Changes

authorImage

Mounika Gampa

May 26, 2026

11 min read

featuredImage

The Core Problem: Workflows Change; Code Shouldn't

Building a customer onboarding system for PayPal Balance accounts sounds straightforward until you realize how many stakeholders need to touch the workflow:

·       Compliance teams need to add KYC verification steps

·       Legal teams need to update agreement flows based on regulations

·       Risk teams want to modify the sequence based on user segments

·       Product teams want to A/B test different checkpoint orders to optimize conversion

·       Account provisioning needs conditional flows based on account type

In a traditional approach, each workflow change means code changes. Each code change means a PR, code review, testing, and a deployment cycle. In a regulated financial environment, this becomes a bottleneck. You end up with engineers becoming gatekeepers for business decisions instead of enablers.

We needed a system where workflow definitions were configuration, not code.


Why JSON-Based Workflows Make Sense

Before diving into implementation, let's be clear about the scope. Our onboarding workflow has roughly 11-12 checkpoints that can be arranged in different sequences:

·       Contact verification (email OTP, phone OTP)

·       KYC verification (document scan, personal info, address info)

·       Risk checks (AML, source of income)

·       Legal agreements (consent, document signing)

The individual checkpoint logic is stable, we call specific downstream services for each one. But the sequence and combinations change frequently based on business requirements.

This is the key insight: if the building blocks are stable but the arrangements are variable, use configuration.

We designed our workflow definitions as JSON because:

1. Easy to edit and version control - JSON diffs clearly show what changed

2. Schema-based validation - We can validate structure before execution

3. Human-readable - Non-engineers can understand and review changes

4. Composable - Same checkpoints can be arranged into different workflows

Here's what an actual workflow definition looks like:



{

  "onboarding_type": "ACCOUNT_SETUP",

  "onboarding_category": "PERSONAL_ACCOUNT",

  "checkpoints": [

    {

      "name": "CV_SEND_EMAIL_OTP",

      "description": "send email OTP",

      "type": "USER",

      "data": {

        "email_id": { "data_type": "string", "is_required": true }

      }

    },

    {

      "name": "CV_VALIDATE_EMAIL",

      "description": "confirm email OTP",

      "type": "USER",

      "data": {

        "email_id": { "data_type": "string", "is_required": true },

        "code": { "data_type": "string", "is_required": true },

        "timestamp": { "data_type": "string", "is_required": true },

        "ip_address": { "data_type": "string", "is_required": false },

        "skip_notification": { "data_type": "boolean", "is_required": false }

      }

    },

    {

      "name": "KYC_LIVE_DOC_SCAN",

      "description": "live document scan",

      "type": "USER",

      "data": {

        "doc_ref_id": { "data_type": "string", "is_required": true },

        "doc_upload_ts": { "data_type": "string", "is_required": true },

        "scan_status": {

          "data_type": "string",

          "is_required": true,

          "allowed_values": ["SUCCESS", "FAILED", "PENDING"]

        },

        "government_id": {

          "type": { "data_type": "string", "is_required": true, "allowed_values": ["SSN", "PASSPORT"] },

          "value": { "data_type": "string", "is_required": true }

        }

      }

    },

    {

      "name": "KYC_INFO",

      "description": "Personal Info",

      "type": "USER",

      "data": {

        "name": { "data_type": "string", "is_required": true },

        "paternal_last_name": { "data_type": "string", "is_required": true },

        "maternal_last_name": { "data_type": "string", "is_required": false },

        "date_of_birth": { "data_type": "string", "is_required": true },

        "gender": { "data_type": "string", "is_required": true, "allowed_values": ["MALE", "FEMALE", "NOT_APPLICABLE", "NOT_KNOWN"] },

        "nationality": { "data_type": "string", "is_required": true },

        "state_of_birth": { "data_type": "string", "is_required": true }

      }

    },

    {

      "name": "
KYC_ADDRESS",

      "description": "Work Address",

      "type": "USER",

      "data": {

        "work_address": {

          "line1": { "data_type": "string", "is_required": true },

          "line2": { "data_type": "string", "is_required": false },

          "city": { "data_type": "string", "is_required": true },

          "state": { "data_type": "string", "is_required": true },

          "zip": { "data_type": "string", "is_required": true },

          "country": { "data_type": "string", "is_required": true }

        }

      }

    },

   ]

}

Each checkpoint is self-contained: it knows what data it requires, what the data types are, what fields are optional, and what constraints apply.


Real Use Cases We Designed For

During the design phase, we mapped out the actual scenarios we'd encounter:

Use Case 1: Regional Flow Variants

 Multiple workflow definitions.

PP_BALANCE_MX_ACCOUNT_SETUP_PERSONAL_ACCOUNT-workflow.json

PP_BALANCE_MX_ACCOUNT_SETUP_BUSINESS_ACCOUNT-workflow.json

Same endpoints, different workflow configurations. The service loads the right one based on the request headers (entity, type, category).

Use Case 2: Adding/Removing Fields Without Code Changes

A requirement comes to add “maternal_last_name” to KYC_INFO:

1. Add field to the KYC_INFO checkpoint schema in the JSON

2. Validate that incoming requests include this field

3. Done

4. We can keep the required: false and wait for upstream to integrate so nothing breaks even if we push the code and later once the system is stable change it to required: true

No code changes. No deployment. Just a configuration update.

Use Case 3: Conditional Fields

Sometimes a field should only be required if another field has a certain value. Instead of hardcoding this in Java, we express it in the schema:

"upper_bound": { "data_type": "integer" }

This field is optional. The checkpoint handler can decide whether to require it based on the actual data received. The schema defines possibilities; the handler defines logic.

Use Case 4: Agreement Versioning

Legal needs to update the agreement template. They can't just overwrite it—regulatory audits require tracking which version each customer accepted. We handle this with version fields:

"major_version": { "data_type": "integer", "is_required": false },

"minor_version": { "data_type": "integer", "is_required": false }

When a customer reaches the legal agreement checkpoint, they see the current version. We store which version they accepted.


Architecture: How It All Works Together

The system has three main layers:

1. Workflow Configuration Layer (JSON Files)

These are the source of truth. They define checkpoints, sequences, and data schemas. They're versioned in Git like any configuration.

2. Validation Layer (Schema-Based)

When a request comes in for a checkpoint, the validator:

·       Loads the workflow definition

·       Finds the specific checkpoint

·       Validates the request against that checkpoint's schema

·       Returns detailed errors if validation fails

This happens synchronously for every request.

3. Execution Layer (Checkpoint Handlers)

Once validated, the request goes to a handler. Each checkpoint type has a handler that knows how to call downstream services:

CV_SEND_EMAIL_OTP → ContactVerificationHandler → contactVerificationClient.sendOTP()

KYC_LIVE_DOC_SCAN → DocumentScanHandler → documentLifeCycleClient.scan()

LEGAL_AGREEMENT → AgreementHandler → generateAgreement() + SignatureUtil.sign()

The handlers are stable. We add new handlers rarely. Most work is configuration changes.

Here's how the validation works in practice:

@Component

public class CheckpointRequestValidator implements RequestValidator<CheckpointRequest, Void> {

    private final ValidationConfig validationConfig;

    private final ValidationUtil validationUtil;



    @Override

    public Void validate(CheckpointRequest request, Map<String, String> headers)

            throws ValidationException {

        List<ErrorDetail> errors = new ArrayList<>();



        String entity = headers.get(ENTITY);

        String userId = headers.get(USER_ID);

        String transactionId = headers.get(TRANSACTION_ID);

        String checkpointId = request.getCheckpointId();



        validateEntity(entity, errors);

        validateUserId(userId, errors);

        validateTransactionId(transactionId, errors);

        validateCheckpointType(checkpointId, errors);



        if (!errors.isEmpty()) {

            throw new ValidationException(INVALID_REQUEST.getCode(),

                                        INVALID_REQUEST.getMessage(), null, errors);

        }

        return null;

    }

}

And the flow schema validation ensures the workflow itself is valid:

@Component

public class OnboardingFlowSchemaValidator implements RequestValidator<Void, Void> {

    private final ValidationConfig validationConfig;



    @Override

    public Void validate(Void request, Map<String, String> headers)

            throws ValidationException {

        String entity = headers.get(ENTITY);

        String type = headers.get(TYPE);

        String category = headers.get(CATEGORY);



        List<ErrorDetail> errors = new ArrayList<>();



        // Validate that this combination of entity/type/category is supported

        // Validate that the JSON workflow file exists and is valid

        // Validate that all required fields are present in the schema



        if (!errors.isEmpty()) {

            throw new ValidationException(INVALID_REQUEST.getCode(),

                                        INVALID_REQUEST.getMessage(), null, errors);

        }

        return null;

    }

}


Key Design Decisions

Decision 1: Two-Layer Validation

We validate at two points:

·       Flow-level validation: Is this workflow combination supported?

·       Checkpoint-level validation: Does this request match the checkpoint's schema?

This catches configuration errors early and prevents bad data from reaching handlers.

Decision 2: Checkpoint Discovery by Naming Convention

Checkpoints are discovered by name, not registration:

Checkpoint name: "CV_VALIDATE_EMAIL"

Handler class: "ContactVerificationHandler" (derived from checkpoint prefix)

This reduces boilerplate. Add a checkpoint to the JSON, and if a matching handler exists, it works. If not, we fail with a clear error.

Decision 3: Immutable Workflow Versions

When we start an onboarding transaction, we load and store the workflow version it uses. If we update the JSON later, existing transactions continue with their original workflow.

This prevents a customer halfway through the process from suddenly hitting new fields or reordered checkpoints.

Decision 4: Duplicate Data processing

In distributed systems, requests get retried. If a checkpoint execution succeeds but the response is lost, the customer might resubmit. We handle this with by pulling the data from onboarding data.

This prevents duplicate processing. If a customer verifies their email twice, the second request returns the same response as the first without calling the verification service again.

Decision 5: Factory Pattern for Handlers

We use a factory to instantiate the correct handler:

DocumentScanHandlerFactory → DocumentScanHandler

LegalAgreementHandlerFactory → AgreementHandler

This allows:

·       Adding new handlers without modifying core logic

·       Testing handlers in isolation

·       Swapping implementations based on configuration


Implementation:

1. Schema-Based Validation

By expressing constraints in JSON instead of code, we catch configuration errors immediately. If someone updates the schema to require a new field, the next request that hits that checkpoint fails validation with a clear error message, long before the handler tries to process it.

2. Multi-cloud Support

We use Maven profiles to support AWS (DynamoDB), GCP (Spanner), and local deployments:

mvn clean install -Paws     # AWS DynamoDB

mvn clean install -Pgcp     # GCP Spanner

mvn clean install -Plocal   # Local testing

The workflow engine is cloud-agnostic. Different backends, same logic.

3. Comprehensive Logging

Every checkpoint execution is logged:

·       Input data

·       Validation results

·       Handler response

·       Execution time

·       Errors

This audit trail is essential. When something goes wrong, you need to know exactly what was received and what was returned.

4. Version Tracking for Agreements

We track major/minor versions for legal agreements. If the legal team updates an agreement template, new customers get the new version. Old customers continue with the version they accepted.


The Constraints Worth Knowing

Best fit when:

·       Workflows are business-driven (not engineering-driven)

·       The set of possible checkpoints is relatively stable

·       You have many workflow variants

·       Non-engineers need to modify workflows

·       Change velocity is high


Lessons Learned

1. Documentation is mandatory.

When workflow logic lives in JSON, your documentation is your source of truth. We maintain:

·       Checkpoint specifications (what each checkpoint does, what services it calls)

·       Schema documentation (what fields exist, when they're required)

·       Workflow diagrams (visual representation of checkpoint sequences)

·       Examples of common configurations

2. Validation needs to be comprehensive.

Because you can't rely on compile-time checking, runtime validation has to be solid. We validate:

·       Workflow structure (all required fields present)

·       Checkpoint definitions (valid checkpoint names, valid handler mappings)

·       Request data (matches the checkpoint schema)

·       State transitions (valid progression through checkpoints)

3. Version everything.

Workflows change. Schemas evolve. Checkpoint handlers get updated. Without versioning:

·       You can't rollback

·       You can't debug historical transactions

·       You can't support multiple variants

·       You can't do safe migrations

We version workflows with the file name and schemas within the JSON.


Conclusion:

This architecture is built on a simple idea: if it changes frequently and is decided by non-engineers, it should be configuration, not code.

Workflows fit that description perfectly. KYC requirements, legal agreements, compliance checks—these are business decisions. They shouldn't live in Java code.

By extracting workflows into JSON:

·       We gave decision-making power to the experts (compliance, legal, product)

·       We removed engineering as a bottleneck for business iteration

·       We enabled rapid testing and rollback of new flows

·       We created a clear audit trail of who changed what and when

The initial investment was real, we had to build validation, handler factories, versioning, and comprehensive logging. But the payoff is a system that moves at the speed of business, not the speed of engineering sprints.


Key Takeaways:

1. Extract workflows to configuration when business drives changes, not engineering

2. Build comprehensive schema validation to compensate for lack of compile-time checking

3. Use checkpoint handlers as stable building blocks; treat them like public APIs

4. Version everything like workflows, schemas, transactions

5. Invest heavily in logging, monitoring, and documentation

6. Idempotency keys are essential for safe retries in distributed systems

7. Two-layer validation catches errors early (flow level + checkpoint level)

For Your Team:

·       Identify workflows that change frequently

·       Map your discrete steps (checkpoints)

·       Design a schema that expresses your variations

·       Build validators and handler factories

·       Start with one workflow type

·       Add comprehensive observability before going to production

·       Document thoroughly

 

Recommended