Say Goodbye to Integration Hell: Hands-On with Ballet, the Code-First Workflow Automation Engine

Hey everyone, Alex here. Welcome back to another edition of Coding with Alex at sysseder.com.

If you've been in the software engineering game for more than a minute, you know there is one universal, soul-crushing truth we all eventually face: integration hell.

We've all been there. You need to connect a Stripe payment webhook to a Slack notification, sync HubSpot contacts to a PostgreSQL database, or trigger a custom GitHub Action when a Jira ticket moves to "In Review." On paper, it sounds like a weekend project. In reality, you end up drowned in OAuth2 token refresh logic, dealing with undocumented API rate limits, parsing inconsistent JSON payloads, and managing fragile cron jobs on a random EC2 instance.

Sure, we have low-code platforms like Zapier, Make, or n8n. They are great for marketing teams, but as developers, they make us sweat. Drag-and-drop builders lack version control, make CI/CD pipelines nearly impossible, offer zero type safety, and vendor lock-in is a constant shadow. We want to write code. We want Git, tests, and local debugging.

That is why a new open-source developer tool caught my eye on Hacker News this morning: Ballet. It’s pitched as a workflow automation engine that lets you write integrations against any API using pure, clean TypeScript, while handling all the state management, retries, and execution scheduling behind the scenes.

Today, we are going to dive deep into what Ballet is, how its architecture works, and how you can write and deploy your first Ballet workflow.

What is Ballet and Why Should Developers Care?

At its core, Ballet is a code-first, developer-centric workflow orchestration engine. Instead of drawing boxes and arrows in a web browser, you write workflows in TypeScript. Ballet takes care of the heavy lifting that makes integration code notoriously difficult to maintain:

  • Stateful Durability: If an API call fails halfway through a complex workflow, Ballet remembers where it was. It doesn't just crash; it retries with exponential backoff or pauses until human intervention occurs.
  • Universal API Adaptability: It doesn't rely on pre-built, proprietary connectors. If an service has an HTTP endpoint, you can write an integration for it using standard fetch calls, wrapped in Ballet's type-safe SDK.
  • Local-First Development: You can run, test, and debug your entire workflow on your local machine before pushing it to production.
  • Git-Driven Workflows: Because your workflows are just TypeScript files, they live in your git repository. You get peer reviews, pull requests, and semantic versioning out of the box.

The Ballet Architecture

To understand why Ballet is so resilient, we need to look under the hood. Ballet uses an event-sourcing paradigm similar to Temporal or Cadence, but optimized specifically for API integration patterns.

+-------------------------------------------------------------+
|                     Ballet CLI / UI                         |
|      (Local development, logs, visual execution paths)       |
+-------------------------------------------------------------+
                              |
                              v
+-------------------------------------------------------------+
|                     Ballet Orchestrator                     |
|  - Manages State & History Database (PostgreSQL/SQLite)      |
|  - Schedules Cron Triggers & Webhook Listeners              |
|  - Distributes Tasks to Workers                             |
+-------------------------------------------------------------+
                              |
                              v
+-------------------------------------------------------------+
|                        Ballet Worker                        |
|  - Executes your TypeScript Workflow Code                   |
|  - Safely sandboxes third-party API calls                   |
|  - Reports Step Results back to Orchestrator                |
+-------------------------------------------------------------+

When you write a Ballet workflow, you define Steps. Each step is an idempotent block of execution. If your worker crashes during step 4, the Ballet Orchestrator spins up a new worker, reads the state history database, skips steps 1 through 3 (because it already has their cached results), and resumes execution exactly at step 4. This is a game-changer for unreliable third-party APIs.

Hands-On: Building a User Onboarding Workflow

Let's get our hands dirty. We are going to build a common but tricky integration workflow: User Onboarding and Provisioning.

Imagine a user signs up on our website. We need to:

  1. Create a customer record in Stripe.
  2. Send them a personalized welcome email via Resend.
  3. Post a notification in our engineering team's Slack channel.

If any of these APIs fail or rate-limit us, we want automatic retries. Let's see how we write this in Ballet.

Step 1: Setting up the Project

First, make sure you have Node.js installed. Let's initialize a new Ballet project:

npm init -y
npm install @ballet-hq/sdk
npm install -D typescript @types/node ts-node
npx tsc --init

Step 2: Defining the Integration Workflow

Create a file named onboardingWorkflow.ts. This is where we will define our types, the API interaction logic, and the workflow structure.

import { Workflow, Step, IntegrationError } from '@ballet-hq/sdk';

// Define the input schema for our workflow
interface OnboardingInput {
  userId: string;
  email: string;
  fullName: string;
}

export const onboardingWorkflow = new Workflow<OnboardingInput>({
  id: 'user-onboarding-flow',
  name: 'User Onboarding Pipeline',
  
  // Set global retry policy for this workflow
  defaultRetryPolicy: {
    initialIntervalSeconds: 2,
    backoffCoefficient: 2,
    maximumAttempts: 5,
  },
});

// Step 1: Create Stripe Customer
const createStripeCustomer = Step.define<OnboardingInput, { stripeId: string }>({
  id: 'create-stripe-customer',
  execute: async ({ input }) => {
    const response = await fetch('https://api.stripe.com/v1/customers', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${process.env.STRIPE_SECRET_KEY}`,
        'Content-Type': 'application/x-www-form-urlencoded',
      },
      body: new URLSearchParams({
        email: input.email,
        name: input.fullName,
        'metadata[user_id]': input.userId,
      }),
    });

    if (!response.ok) {
      const errorData = await response.json();
      throw new IntegrationError(`Stripe API failed: ${JSON.stringify(errorData)}`, response.status);
    }

    const data = await response.json();
    return { stripeId: data.id };
  },
});

// Step 2: Send Welcome Email
const sendWelcomeEmail = Step.define<OnboardingInput & { stripeId: string }, { emailId: string }>({
  id: 'send-welcome-email',
  execute: async ({ input }) => {
    const response = await fetch('https://api.resend.com/emails', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${process.env.RESEND_API_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        from: 'Alex <alex@sysseder.com>',
        to: input.email,
        subject: 'Welcome to the platform!',
        html: `<strong>Hi ${input.fullName},</strong> we are excited to have you! Your account is active.`,
      }),
    });

    if (!response.ok) {
      throw new Error(`Resend API failed with status ${response.status}`);
    }

    const data = await response.json();
    return { emailId: data.id };
  },
});

// Step 3: Notify Slack
const notifySlack = Step.define<OnboardingInput, { success: boolean }>({
  id: 'notify-slack',
  execute: async ({ input }) => {
    const response = await fetch(process.env.SLACK_WEBHOOK_URL!, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        text: `🚀 New User Joined: *${input.fullName}* (${input.email})!`,
      }),
    });

    return { success: response.ok };
  },
});

Step 3: Orchestrating the Steps

Now that our steps are defined as modular, isolated functions, we chain them together in our workflow definition. Ballet allows you to pass data from one step to another seamlessly while ensuring type safety across boundaries.

// Chain the workflow steps together
onboardingWorkflow
  .addStep(createStripeCustomer)
  .addStep(sendWelcomeEmail, {
    // We can map outputs of previous steps to inputs of this step
    inputMapper: (workflowInput, previousOutputs) => ({
      ...workflowInput,
      stripeId: previousOutputs['create-stripe-customer'].stripeId,
    }),
  })
  .addStep(notifySlack);

export default onboardingWorkflow;

Why This Design is Superior to Traditional Approaches

Let's look at what we didn't have to write in the code above:

  • No database connection management: We didn't have to write code to save the fact that Stripe was successfully created, so that we don't accidentally charge the user or create duplicate records if the email step fails. Ballet handles the state persistence.
  • No retry loops: If Resend is having an outage, Ballet's engine automatically handles the backoff. If it still fails after 5 attempts, the workflow moves to a PAUSED state, allowing us to fix the API key or trigger a manual replay once the service is back up.
  • Clean separation of concerns: Each step is an isolated unit of logic. We can unit test createStripeCustomer completely independently of the rest of the workflow.

Deploying and Running Ballet

Ballet can be run as a lightweight daemon inside a Docker container, making it extremely cloud-friendly. It plays nicely with Kubernetes, AWS ECS, or your standard VPS setup.

You can run your worker locally using the Ballet CLI:

# Install the CLI globally
npm install -g @ballet-hq/cli

# Start the Ballet local orchestrator (uses SQLite for local state)
ballet dev:start

# Run your workflow worker
ballet worker:run --workflow ./onboardingWorkflow.ts

To trigger your workflow programmatically from your main web application (for instance, inside your Next.js API route or Express signup controller), you simply use the Ballet client client SDK:

import { BalletClient } from '@ballet-hq/sdk';

const ballet = new BalletClient({ apiKey: process.env.BALLET_API_KEY });

async function handleUserSignup(newUser: any) {
  // Trigger the durable workflow
  await ballet.workflows.trigger('user-onboarding-flow', {
    userId: newUser.id,
    email: newUser.email,
    fullName: newUser.name,
  });
}

Final Thoughts

The "low-code" movement promised to democratize integrations, but for engineers, it introduced more friction than it solved. Tools like Ballet represent a necessary paradigm shift back to code-first automation. It treats integrations like software: version-controlled, testable, and deeply robust, while giving us the visual monitoring and managed state engines we've grown to love in modern orchestration tools.

If you're tired of writing boilerplate error handling and cron runners for your API integrations, I highly recommend checking out Ballet.

What are your thoughts? Are you still building custom integration scripts, using heavy enterprise tools like Temporal, or leaning on low-code platforms? Let me know in the comments below, or hit me up on Twitter/X at @sysseder.

Until next time, happy coding!

— Alex

Post a Comment

Previous Post Next Post