Module 3: Contracts Between Workflows

8. Project: a validated sub-workflow with a contract

Description

By the end of this lesson you'll have built check-credit end to end: a sub-workflow with its input and output contract documented, boundary validation that rejects invalid inputs with a clear message, a body that decides on the credit and returns the response in the contract's shape, and a second compatible version that adds a capability with no breakage. It's the deliverable that pulls everything from the module together into a single piece that works and that you can defend.

This matters because up to now you worked each concept separately —the contract in lesson 2, the schema in 3, the boundary in 4, validation in 5, versioning in 6, the agent face in 7—. A real system doesn't use those concepts one at a time; it uses them all together in the same sub-workflow. This project is where you see how they fit: the schema you designed gets declared in the trigger, validation protects the effect, the output shape fulfills the contract, and versioning lets you grow. Building it in full is what turns six loose ideas into a skill.

Connection to the module: this is the project that closes Module 3, and its exit skill is the whole module's: defining a contract, validating it at the boundary, and versioning it. It pulls together lesson 3's schema, lesson 4's boundary, lesson 5's validation, and lesson 6's versioning, and leaves it ready to be exposed as a tool (lesson 7) if you wanted. The deliverable —the sub-workflow plus its written contract— is defensible in a portfolio and in an interview: it shows you know how to build the promise between two workflows, not just connect them. After this, Module 4 gets into where the system's state lives, which is what check-credit still fakes having.

What you're going to build

The deliverable is check-credit: the sub-workflow order-triage calls to decide whether a customer has enough credit for an order. When you finish you'll have two things together —and both count as the deliverable—:

  1. The check-credit sub-workflow working, with its boundary declared, its three-layer validation, its decision body, and its output in the contract's shape.
  2. The written contract, in a Sticky Note glued to the workflow, with the input and output schema, the examples, and the list of who calls it.

The sub-workflow's final shape, in a diagram, is this:

check-credit
────────────
[Execute Sub-workflow Trigger]         ← the boundary: declares the input schema
        │
[Code: Validate input]                 ← the doorman: validates the three layers
        │
     [If: ok?]
     ├─ true  → [Code: Look up credit] → [Edit Fields: success envelope] → output
     └─ false → (the item already carries the contract error)          → output

[Sticky Note: the written contract]     ← glued to the canvas, documentation for humans

Let's build it in parts, each with what to expect once it's done. If you have an n8n instance handy, follow along step by step; if not, read it and build it later. Exact button and option names can vary by version —check them on your panel—.

Part 1 — The written contract, first

Before touching a node, write the contract. It's the order lesson 3 taught: the schema on paper before it's in n8n. This contract is the same one you designed in lesson 3, and it's going to be your reference for everything else —what you declare in the trigger, what you validate, what you return—.

Create the check-credit workflow, and put a Sticky Note on the canvas with this:

CONTRACT — check-credit  (v1)

INPUT
  customer_id : string   required   — whose credit is being checked
  order_id    : string   required   — which order this query belongs to
  amount      : number   required   — order total to compare against credit
  currency    : string   optional   — default "MXN"

OUTPUT (envelope with "ok" discriminator)
  SUCCESS → { ok: true,  customer_id, approved, available_credit }
  FAILURE → { ok: false, error: { code, message } }
           codes: "INVALID_INPUT", "CUSTOMER_NOT_FOUND"

EFFECTS
  none (read-only)

WHO CALLS ME
  order-triage

INPUT EXAMPLE
  { "customer_id": "CUST-118", "order_id": "ORD-2041", "amount": 1842.50 }
OUTPUT EXAMPLE (success)
  { "ok": true, "customer_id": "CUST-118", "approved": true, "available_credit": 3157.50 }

What to expect. There's still nothing to run, and that's the point: the contract exists before the implementation. From here on, every build decision is measured against this text. If at some step you find yourself returning a field that isn't in the contract, or validating something the contract doesn't ask for, the Sticky Note is the referee. Writing the contract first isn't bureaucracy; it's setting the target before you shoot.

Part 2 — The boundary: declaring the schema in the trigger

check-credit's first node is the Execute Sub-workflow Trigger (look for it as "Execute Sub-workflow Trigger" or, on the canvas, "When Executed by Another Workflow"). It's the building's reception: the single entry point.

Open it, set "Input data mode" to "Define using fields below," and declare your contract's four fields:

customer_id : string
order_id    : string
amount      : number
currency    : string

What to expect. The trigger shows those four fields as the sub-workflow's expected input. Later, when you connect order-triage, its Execute Sub-workflow node is going to show these same fields ready to fill in —the contract helping the caller—. Remember lesson 5: declaring these fields orients and helps, but it doesn't deeply validate. The real doorman comes in the next part.

Part 3 — The doorman: three-layer validation

Connect, right after the trigger, a Code node called "Validate input" in Run Once for Each Item mode. It's lesson 5's validation, with the three layers —presence, type, and local business rules—.

// Node: Code — "Validate input"
// Mode: Run Once for Each Item
// Validates the input item against the check-credit contract.
// Only READS the item fields: no HTTP, no files, respects n8n 2.0.

const input = $input.item.json;
const errors = [];

// --- Layer 1: required fields present ---
if (input.customer_id === undefined || input.customer_id === null || input.customer_id === '') {
  errors.push('customer_id is required');
}
if (input.order_id === undefined || input.order_id === null || input.order_id === '') {
  errors.push('order_id is required');
}
if (input.amount === undefined || input.amount === null) {
  errors.push('amount is required');
}

// --- Layer 2: correct types ---
if (input.customer_id !== undefined && typeof input.customer_id !== 'string') {
  errors.push('customer_id must be text');
}
if (input.amount !== undefined && typeof input.amount !== 'number') {
  errors.push('amount must be a number'); // "1842.50" as text falls here
}

// --- Layer 3: local business rules ---
if (typeof input.amount === 'number' && input.amount <= 0) {
  errors.push('amount must be greater than zero');
}

// --- Optional with a default ---
const currency = input.currency ?? 'MXN';

if (errors.length > 0) {
  // Does not comply: contract FAILURE shape, and does NOT pass bad data to the effect.
  return {
    json: {
      ok: false,
      error: { code: 'INVALID_INPUT', message: errors.join('; ') },
    },
  };
}

// Complies: pass the item forward, with the default applied and ok: true.
return {
  json: {
    ok: true,
    customer_id: input.customer_id,
    order_id: input.order_id,
    amount: input.amount,
    currency: currency,
  },
};

After the Code node, put an If checking {{ $json.ok }}: the true branch continues to the decision body (part 4), and the false branch goes straight to the output —the item already carries the contract's failure shape—.

What to expect. With this node, any input that doesn't comply with the contract takes the false branch and exits as an error without touching the credit logic. If you pass it { customer_id: "CUST-118", order_id: "ORD-2041", amount: "-50" }, the Code node produces { ok: false, error: { code: "INVALID_INPUT", message: "amount must be a number; amount must be greater than zero" } } and the If routes it down the failure branch. The doorman is doing its job.

Part 4 — The body: checking the credit and deciding

The If's true branch leads to the sub-workflow's body: the part that actually decides whether there's credit. In a real system, this step would query the customer's balance in a database with a native database node —and that real state is exactly what Module 4 builds—. For this project, and so it's self-contained, we use a Code node with a fixed credit table acting as a stand-in for that query. It's marked as a stand-in on purpose: it's honest about what it fakes.

// Node: Code — "Look up credit and decide"
// Mode: Run Once for Each Item
// STAND-IN for a real query to the customer database. In a real system,
// the credit limit would come from a native database node (Module 4).
// Here we use a fixed table just so the project works self-contained.

const input = $input.item.json; // already validated: customer_id, order_id, amount, currency

// Fake credit table (stand-in for the Cumbre customer database).
const creditLimits = {
  'CUST-118': 5000,
  'CUST-204': 12000,
  'CUST-330': 800,
};

const limit = creditLimits[input.customer_id];

// If the customer is not in the table, it is a failure with its own contract code.
if (limit === undefined) {
  return {
    json: {
      ok: false,
      error: {
        code: 'CUSTOMER_NOT_FOUND',
        message: `Customer ${input.customer_id} does not exist in the credit database.`,
      },
    },
  };
}

// The decision: how much credit is left after this order, and whether it is enough.
const availableCredit = limit - input.amount;
const approved = availableCredit >= 0;

return {
  json: {
    ok: true,
    customer_id: input.customer_id,
    approved: approved,                 // true if there is enough credit
    available_credit: availableCredit,  // what is left after the order
  },
};

Notice two things about this node. First, it returns the exact shape of the contract's success output —{ ok: true, customer_id, approved, available_credit }— because, as lesson 4 taught, what the caller receives is whatever the last node produces. Second, it handles its own business failure —CUSTOMER_NOT_FOUND— with the same error shape as the contract: not every failure is an input validation one; a customer that doesn't exist is a business failure, and it also deserves the promised error shape.

Connect this Code node to a final node that is the sub-workflow's output. Since the Code node already produces the contract's shape, you can leave it as the last node, or —if you prefer separating the calculation from the response— pass through an Edit Fields node that builds the final envelope. What matters is that this branch's last node produces exactly the contract's success shape.

What to expect. With a valid input for a customer that exists —{ customer_id: "CUST-118", order_id: "ORD-2041", amount: 1842.50 }—, the sub-workflow returns { ok: true, customer_id: "CUST-118", approved: true, available_credit: 3157.50 } (5000 limit minus the 1842.50 order). With an amount of 6000 for the same customer, it returns approved: false and available_credit: -1000. With a customer_id not in the table, it returns { ok: false, error: { code: "CUSTOMER_NOT_FOUND", ... } }. All three responses have the contract's shape.

Part 5 — Connecting the caller and testing end to end

Now test check-credit from a caller, the way order-triage would. Create a test workflow (or use order-triage if you already have it) with a Manual Trigger and an Edit Fields that builds a Cumbre order, followed by an Execute Sub-workflow node that calls check-credit in "Run once for each item" mode with "Wait for Sub-Workflow Completion" active.

Test these four cases, one by one:

CaseInputExpected output
Valid, with credit{ customer_id: "CUST-118", order_id: "ORD-2041", amount: 1842.50 }ok: true, approved: true, available_credit: 3157.50
Valid, no credit{ customer_id: "CUST-330", order_id: "ORD-2042", amount: 1000 }ok: true, approved: false, available_credit: -200
Invalid (amount as text){ customer_id: "CUST-118", order_id: "ORD-2043", amount: "1000" }ok: false, code: "INVALID_INPUT"
Nonexistent customer{ customer_id: "CUST-999", order_id: "ORD-2044", amount: 500 }ok: false, code: "CUSTOMER_NOT_FOUND"

What to expect. All four cases produce the contract's shape, and each takes its own path: the two valid ones go through the decision body and return ok: true with the corresponding approved; the one with amount as text is rejected by validation with INVALID_INPUT, never touching the logic; the one with the nonexistent customer passes input validation (the fields are well-formed) but fails in the body with CUSTOMER_NOT_FOUND. Notice the difference between the two failures: one is caught by the input doorman (the validation layer), the other by the body (a business rule needing to "query the world"). Both return the contract's error shape, but for different reasons. On the caller's side, all four responses read the same way: first ok, and based on that, approved or error.

If any case doesn't give the expected result, typical causes: the earlier node didn't run (check that Edit Fields ran and there's data), the Execute Sub-workflow's mode isn't "for each item," or some field was left unfilled in the inputs. The boundary is demanding on purpose.

Part 6 — The second, compatible version

Close the project by evolving the contract with a compatible change, applying lesson 6. Cumbre wants check-credit to be able to, optionally, also return the customer's total credit limit —but only when the caller asks for it—.

Since it's a compatible change, you don't need a parallel version or to migrate anyone: you do it on the same check-credit.

On the input, add an optional field to the trigger and to the validation: include_limit : boolean, optional, default false. In the validation Code node, add reading the default:

// New optional field, with its default (COMPATIBLE change)
const includeLimit = input.include_limit ?? false;
// ...and pass it forward in the success return, alongside the rest:
//   include_limit: includeLimit,

On the output, when include_limit is true, add a credit_limit field to the success response. In the "Look up credit and decide" Code node:

const result = {
  ok: true,
  customer_id: input.customer_id,
  approved: approved,
  available_credit: availableCredit,
};
if (input.include_limit === true) {
  result.credit_limit = limit;  // only appears if the caller asked for it
}
return { json: result };

Also update the contract's Sticky Note: mark the version as v1.1, add include_limit to the input and credit_limit to the output (noting it's optional), and note it's a compatible change.

What to expect. order-triage, which doesn't send include_limit and only reads approved, keeps working exactly the same, with you not touching it —it doesn't fail, doesn't change, doesn't even find out about the new field—. A caller that does want the limit sends include_limit: true and receives the extra credit_limit. You just evolved the contract with no coordination with anyone and no risk of breakage: the mark of a well-done compatible change. With this, your deliverable demonstrates both halves of versioning —the criterion for knowing the change is compatible, and the execution confirming it with the old caller untouched.

The deliverable: checklist

Your project is complete when you can check all of this:

  • The contract is written in a Sticky Note glued to check-credit: input, output (success and failure), effects, who calls it, and examples.
  • The Execute Sub-workflow Trigger declares the schema with "Define using fields below" and the contract's fields.
  • There's boundary validation (Code node, three layers) immediately after the trigger, before any effect.
  • An invalid input is rejected with the contract's error shape (ok: false, code, message), never reaching the body.
  • The body produces the success output with the contract's exact shape, and handles its own business failure (CUSTOMER_NOT_FOUND) with the same error shape.
  • The sub-workflow is tested end to end from a caller, with the four cases (valid with credit, valid without credit, invalid, nonexistent customer).
  • There's a second, compatible version (optional include_limit) that doesn't break the old caller.

If you can check all seven, you built this whole module's exit skill: a contract defined, validated at the boundary, and versioned. That's what separates someone who connects workflows from someone who owns the promise between them.

How to defend this deliverable

This project is the kind that serves in a portfolio and in a technical interview, and it's worth knowing how to present it, because what makes it valuable doesn't jump out from just opening the canvas. A check-credit that runs impresses nobody; what impresses is being able to explain the decisions behind it.

If you had to present it, here are the three things worth knowing how to answer, because they're the ones that show you understand the problem and not just the tool:

"What happens if the caller sends bad data?" Here you open the validation node and show the three layers, and explain why it's before the body: bad data gets rejected at the door with a clear message, never reaches the effect. If you can also say "and if this were issue-refund instead of check-credit, that validation is what prevents a refund on garbage data," you connected the contract with the effect, which is the guide's heart.

"What happens when you need to change the contract?" Here you show the compatible version you built in part 6, and explain the compatible-vs-breaking distinction: why adding optional include_limit didn't break order-triage, and what you would have done differently —parallel versions— if the change were renaming a field. It shows you know a contract lives to change, and that changing it wrong is the silent break this module exists to prevent.

"Where's the part that fakes it?" This is the one that builds the most trust, because it shows technical honesty. You point at the body's Code node and say: "this credit table is a stand-in; in a real system this data lives in a database and this query would be a native node. I left it marked as a stand-in on purpose, and I know exactly what would need to change to make it real." Knowing what your own system fakes, and saying so without being asked, is what distinguishes someone who understands what they built from someone who only copied it.

The underlying rule: the deliverable isn't the canvas, it's the reasoning you can defend about it. A check-credit with a written contract, a validation you know how to explain, and a compatible version you know how to justify says more about you than ten workflows that run but that you can't back up.

Common mistakes

Putting the body before the validation (practical). What happens: building top to bottom, someone connects the trigger straight to the "Look up credit" Code node and adds validation "afterward," ending up with the order inverted —the body runs before anything rejects bad input—. Why it happens: it's natural to build the main logic first, which is the interesting part, and leave validation for the end; but "at the end of the build" shouldn't mean "at the end of the flow." How to spot it: look at the node order; if the decision Code node can run with an input validation hasn't checked yet, they're reversed. How to fix it: the validation node goes immediately after the trigger, and the body goes after the If, on the true branch. The doorman before the inner door, always.

The last node not producing the contract's shape (practical). What happens: the body calculates the credit correctly but leaves internal fields in the output —or it's missing ok, or it returns available instead of available_credit—, and the caller gets something it can't read the way it expected. Why it happens: focusing on the calculation, it's easy to forget the shape of what the last node returns is what the contract promises, not just the correct value. How to spot it: compare the sub-workflow's actual output, field by field, against the contract's success output from the Sticky Note; any extra, missing, or differently-named field is the problem. How to fix it: make sure each branch's last node produces exactly the contract's shape —no extra internal field, no missing ok—. It's what lesson 4 flagged: the caller receives whatever the last node produces, whether or not it has the promised shape.

Making part 6's change breaking without realizing it (conceptual). What happens: when adding include_limit, someone declares it required instead of optional, or makes credit_limit always show up under a name overlapping another; the old caller, which doesn't send include_limit, starts failing validation, or the output change confuses it. Why it happens: "adding a field" sounds compatible by definition, and it's forgotten that adding a required input is breaking (lesson 6). How to spot it: apply the mental test —"does order-triage, which doesn't find out about the change, keep working the same?"—; if not, what you thought was compatible is breaking. How to fix it: the new input field goes optional with a default, and the new output field only shows up when requested; that way the old caller doesn't even find out. If you needed a genuinely breaking change, use the parallel-versions cycle, not an in-place change.

Exercises

Exercise 1 — Add a new business code. check-credit's contract has codes INVALID_INPUT and CUSTOMER_NOT_FOUND. Cumbre wants that, if a customer's credit is frozen (for late payment), check-credit doesn't approve them and returns a failure distinct from "no credit." Design the change: which new code do you add, where does the check go (input validation or body), and is it a compatible or breaking change?

See solution

The new code is something like CREDIT_FROZEN. The check goes in the body, not in input validation: whether the credit is frozen is a business rule depending on the customer's state ("querying the world"), not on the input's shape —customer_id arrived perfectly well-formed—. In the "Look up credit and decide" Code node, after finding the customer, you check whether they're frozen (in the stand-in, another table or field of the credit table) and, if so, return { ok: false, error: { code: "CREDIT_FROZEN", message: "..." } }.

It's a compatible change: adding a new failure code doesn't break order-triage, because order-triage already knew a failure arrives in the shape { ok: false, error: { code, message } }. A caller that handles the generic error envelope —"if ok is false, it's a failure"— handles the new code with no changes. Only a caller that had specific logic per code would need to find out, and even then, it doesn't break: it simply has no special branch for CREDIT_FROZEN until you add it.

Why this works: the exercise distinguishes two things. First, where each check goes: input shape → validation; business state → body. Second, that adding a new value to a set the caller already treated generically (the error codes) is compatible, while changing the error envelope's shape would be breaking. Designing the generic error envelope from the start (lesson 3) is what makes adding new codes cheap.

Exercise 2 — Adversarial test of the validation. Write three "bad" inputs trying to slip past check-credit's validation, and say what should happen with each. Think like someone who wants to break the doorman, not like someone who respects it.

See solution

Three examples (there are more):

  1. { customer_id: "CUST-118", order_id: "ORD-1", amount: 0 }amount of zero. Should be rejected by layer 3 (amount must be greater than zero). It's lesson 5's trap: a naive !input.amount would let it through or confuse it with "absent," which is why we validate with an explicit <= 0.

  2. { customer_id: "CUST-118", order_id: "ORD-2", amount: 1000, currency: 500 }currency with a number instead of text. With the validation as it stands in the project, this passes, because we don't check currency's type. It's a real gap: if currency mattered for the calculation, you'd need to add if (input.currency !== undefined && typeof input.currency !== 'string'). Discovering this gap is the exercise's point.

  3. { customer_id: " ", order_id: "ORD-3", amount: 1000 }customer_id with just spaces. With the current validation, " " isn't an empty string, so it passes layer 1 and reaches the body, where creditLimits[" "] is undefined and produces CUSTOMER_NOT_FOUND. It works, but by accident: if you wanted to reject it at the input with a clearer message, you'd add a .trim() to the presence check.

Why this works: thinking adversarially finds the gaps "friendly" tests never touch. A validation isn't "good" because it accepts good inputs; it's good because it rejects bad ones, including ones you didn't imagine when writing it. Cases 2 and 3 show the project's validation is correct but not exhaustive —and knowing where its limits are is part of owning the contract, not just writing it—.

Exercise 3 — Document a second caller. Another Cumbre workflow, bulk-order-import, starts calling check-credit. Update the contract's "WHO CALLS ME" section and explain why, from now on, any breaking change to check-credit is more expensive than before.

See solution

The section becomes:

WHO CALLS ME
  order-triage
  bulk-order-import

Any breaking change is more expensive now because there are two callers to migrate instead of one. With a single caller, a poorly-done breaking change breaks one workflow; with two, it breaks two. And lesson 6's parallel-versions cycle —creating check-credit-v2, migrating each caller, confirming v1's list is empty before deleting it— now has two workflows on the migration list, two end-to-end tests, and two chances of forgetting someone. The cost of a breaking change grows with the number of callers; that's why a stable contract (lesson 2) and a compatible change when possible (lesson 6) become worth more and more as a sub-workflow gains consumers.

Why this works: documenting the second caller isn't a formality; it's updating the map of who you can break. The "who calls me" list is what turns a breaking change from a blind bet into a concrete migration plan. Every caller you add to that list raises the value of keeping the contract stable —and lowers the temptation to rename a field "because it sounds better."

Summary and next step

In this lesson you built check-credit end to end and pulled the whole module together into a single deliverable. You wrote the contract first, in a Sticky Note, setting the target before shooting. You declared the schema in the Execute Sub-workflow Trigger with "Define using fields below." You put the doorman —three-layer validation— immediately after the trigger, guaranteeing no invalid input reaches the body. You built the body that checks the credit (with an honest stand-in for the real database that arrives in Module 4) and returns the contract's exact shape, handling both success and the CUSTOMER_NOT_FOUND business failure. You tested the sub-workflow end to end from a caller, with the four cases, seeing how each takes its own path and all return the contract's shape. And you closed with a second, compatible version —optional include_limit— that evolved the contract with no impact on the old caller. The deliverable, the sub-workflow plus its written contract, is this whole module's exit skill, and it's portfolio-defensible.

Before closing the module you should be able to: build a sub-workflow with a documented contract, boundary validation, and output in the promised shape; test it with valid and invalid cases; and make a compatible change to it with no breakage to callers.

There's one piece this project faked, and we named it honestly: check-credit "checked" credit from a fixed table inside a Code node, a stand-in for the real customer database. In a real system, that credit lives somewhere —in a database, in a record that survives across executions—, and knowing where the system's truth lives, how to design that state, and how to deduplicate against it is Module 4's topic. You went from "a single workflow that doesn't duplicate" (Module 2) to "two workflows that understand each other through a contract" (this module); Module 4 gives you the third pillar: where the state they both share lives.

Resources