From 2ac4e853f1380a9dd15b6b8deba1db75a9e34d89 Mon Sep 17 00:00:00 2001 From: stack72 Date: Sun, 8 Mar 2026 02:25:06 +0000 Subject: [PATCH] docs: document factory pattern for model reuse (#650) Add documentation for the input-driven factory pattern where one model definition creates multiple named instances via workflow steps. Covers model definition with inputs schema, the name-to-data-name connection, create/delete workflow snippets, and how globalArgument expressions are selectively evaluated (not all inputs required for every method). Co-Authored-By: Claude Opus 4.6 --- .claude/skills/swamp-model/SKILL.md | 4 + .../swamp-model/references/scenarios.md | 223 ++++++++++++++++++ .../references/data-chaining.md | 108 +++++++++ 3 files changed, 335 insertions(+) diff --git a/.claude/skills/swamp-model/SKILL.md b/.claude/skills/swamp-model/SKILL.md index 787eb3a9..cc3cefdc 100644 --- a/.claude/skills/swamp-model/SKILL.md +++ b/.claude/skills/swamp-model/SKILL.md @@ -170,6 +170,10 @@ methods: Inputs are provided at runtime with `--input` or `--input-file` and referenced in globalArguments using `${{ inputs. }}` expressions. +**Factory pattern:** Use inputs to create multiple instances from one model +definition — see +[references/scenarios.md#scenario-5](references/scenarios.md#scenario-5-factory-pattern-for-model-reuse). + ## Edit a Model **Recommended:** Use `swamp model get --json` to get the file path, then diff --git a/.claude/skills/swamp-model/references/scenarios.md b/.claude/skills/swamp-model/references/scenarios.md index 0b1d97f1..99f3ebb6 100644 --- a/.claude/skills/swamp-model/references/scenarios.md +++ b/.claude/skills/swamp-model/references/scenarios.md @@ -8,6 +8,7 @@ End-to-end scenarios showing how to build models for common use cases. - [Scenario 2: Chained AWS Lookups](#scenario-2-chained-aws-lookups) - [Scenario 3: Model with Runtime Inputs](#scenario-3-model-with-runtime-inputs) - [Scenario 4: Multi-Environment Configuration](#scenario-4-multi-environment-configuration) +- [Scenario 5: Factory Pattern for Model Reuse](#scenario-5-factory-pattern-for-model-reuse) --- @@ -431,3 +432,225 @@ swamp workflow run deploy-api --input '{"environment": "production"}' --json | api-client-staging | `vault.get("staging-secrets", "API_KEY")` | `staging-key-67890` | | api-client-prod | `vault.get("prod-secrets", "API_KEY")` | `prod-key-secure` | | All models | `self.name` | Model name | + +--- + +## Scenario 5: Factory Pattern for Model Reuse + +### User Request + +> "I need to create 4 subnets (public-a, public-b, private-a, private-b) but +> they all have the same schema. I don't want to maintain 4 separate model +> definitions." + +### What You'll Build + +- 1 model definition (`prod-subnet`) called 4 times with different inputs +- 4 distinct data instances keyed by `instanceName` + +### Decision Tree + +``` +Multiple instances of the same resource type? → Factory pattern + Same schema, different parameters? → Yes → One model + inputs + Different schemas or behaviors? → No → Separate models +``` + +### When to Use Factory vs Separate Models + +| Situation | Approach | +| --------------------------------------------- | ----------------- | +| 4 subnets with different CIDRs/AZs | Factory (1 model) | +| 2 EIPs with different tags | Factory (1 model) | +| A VPC and a subnet (different resource types) | Separate models | +| Resources with different method signatures | Separate models | + +### Step-by-Step + +**1. Create the model** + +```bash +swamp model create @user/aws-subnet prod-subnet --json +``` + +**2. Configure with inputs schema** + +Edit `models/prod-subnet/input.yaml`: + +```yaml +name: prod-subnet +version: 1 +tags: {} +inputs: + properties: + instanceName: + type: string + description: Unique name for this subnet instance (becomes the data name) + cidrBlock: + type: string + description: CIDR block for the subnet + availabilityZone: + type: string + description: AWS availability zone + required: ["instanceName", "cidrBlock", "availabilityZone"] +globalArguments: + name: ${{ inputs.instanceName }} + VpcId: ${{ data.latest("prod-vpc", "main").attributes.VpcId }} + CidrBlock: ${{ inputs.cidrBlock }} + AvailabilityZone: ${{ inputs.availabilityZone }} + Tags: + - Key: Name + Value: ${{ inputs.instanceName }} +methods: + create: + arguments: {} + delete: + arguments: {} +``` + +**3. The `name` and data name connection** + +The `name: ${{ inputs.instanceName }}` in globalArguments is critical. It sets +the **data instance name**, so when you call the model with +`instanceName: "public-a"`, the output data is stored as `public-a`. This means +downstream models can access it with: + +```yaml +subnetId: ${{ data.latest("prod-subnet", "public-a").attributes.SubnetId }} +``` + +Each call with a different `instanceName` creates a separate data instance under +the same model definition. + +**4. Create workflow — call the model multiple times** + +```yaml +name: create-subnets +version: 1 +jobs: + - name: create-subnets + description: Create all 4 subnets in parallel + steps: + - name: create-public-a + task: + type: model_method + modelIdOrName: prod-subnet + methodName: create + inputs: + instanceName: public-a + cidrBlock: "10.0.1.0/24" + availabilityZone: us-east-1a + - name: create-public-b + task: + type: model_method + modelIdOrName: prod-subnet + methodName: create + inputs: + instanceName: public-b + cidrBlock: "10.0.2.0/24" + availabilityZone: us-east-1b + - name: create-private-a + task: + type: model_method + modelIdOrName: prod-subnet + methodName: create + inputs: + instanceName: private-a + cidrBlock: "10.0.3.0/24" + availabilityZone: us-east-1a + - name: create-private-b + task: + type: model_method + modelIdOrName: prod-subnet + methodName: create + inputs: + instanceName: private-b + cidrBlock: "10.0.4.0/24" + availabilityZone: us-east-1b +``` + +Steps within a job run in parallel, so all 4 subnets are created concurrently. + +**5. Delete workflow — provide inputs the method actually uses** + +Delete steps must provide `instanceName` because +`name: ${{ inputs.instanceName }}` determines which data instance to read and +delete. Other inputs are only needed if the delete method implementation +accesses those globalArguments. + +The system **selectively evaluates** globalArgument expressions — inputs that +aren't provided are skipped. A runtime error only occurs if the method code +actually tries to access an unresolved globalArgument. + +```yaml +name: delete-subnets +version: 1 +jobs: + - name: delete-subnets + description: Delete all 4 subnets + steps: + - name: delete-public-a + task: + type: model_method + modelIdOrName: prod-subnet + methodName: delete + inputs: + instanceName: public-a + cidrBlock: "10.0.1.0/24" + availabilityZone: us-east-1a + identifier: ${{ data.latest("prod-subnet", "public-a").attributes.SubnetId }} + - name: delete-public-b + task: + type: model_method + modelIdOrName: prod-subnet + methodName: delete + inputs: + instanceName: public-b + cidrBlock: "10.0.2.0/24" + availabilityZone: us-east-1b + identifier: ${{ data.latest("prod-subnet", "public-b").attributes.SubnetId }} + - name: delete-private-a + task: + type: model_method + modelIdOrName: prod-subnet + methodName: delete + inputs: + instanceName: private-a + cidrBlock: "10.0.3.0/24" + availabilityZone: us-east-1a + identifier: ${{ data.latest("prod-subnet", "private-a").attributes.SubnetId }} + - name: delete-private-b + task: + type: model_method + modelIdOrName: prod-subnet + methodName: delete + inputs: + instanceName: private-b + cidrBlock: "10.0.4.0/24" + availabilityZone: us-east-1b + identifier: ${{ data.latest("prod-subnet", "private-b").attributes.SubnetId }} +``` + +### Understanding Input Requirements for Delete + +The system handles unresolved globalArguments gracefully: + +| Input | Needed for delete? | Why | +| ------------------ | -------------------------- | ---------------------------------------------- | +| `instanceName` | **Always** | Keys the data instance (`name` globalArgument) | +| `identifier` | **Always** | The resource ID to delete | +| `cidrBlock` | Only if method accesses it | Skipped if not provided and not used by method | +| `availabilityZone` | Only if method accesses it | Skipped if not provided and not used by method | + +If your delete method implementation only reads `globalArgs.name` and +`args.identifier`, you can omit `cidrBlock` and `availabilityZone` from the +delete step inputs. Unresolved expressions are skipped — the system only throws +an error if the method code actually tries to access an unresolved value. + +### CEL Paths Used + +| Data | CEL Path | +| --------------------- | ------------------------------------------------------------- | +| Subnet ID (public-a) | `data.latest("prod-subnet", "public-a").attributes.SubnetId` | +| Subnet ID (private-b) | `data.latest("prod-subnet", "private-b").attributes.SubnetId` | +| VPC ID (dependency) | `data.latest("prod-vpc", "main").attributes.VpcId` | diff --git a/.claude/skills/swamp-workflow/references/data-chaining.md b/.claude/skills/swamp-workflow/references/data-chaining.md index fb7e5616..ce3468a2 100644 --- a/.claude/skills/swamp-workflow/references/data-chaining.md +++ b/.claude/skills/swamp-workflow/references/data-chaining.md @@ -416,3 +416,111 @@ jobs: | Create | Forward (VPC → subnets → RT) | Write new data via `writeResource()` | | Update | Forward (VPC → subnets → RT) | Read stored data, modify, write via `writeResource()` | | Delete | Reverse (RT → subnets → VPC) | Read stored data, clean up, return empty handles | + +## Factory Model Patterns + +The factory pattern uses one model definition with `inputs` to create multiple +named instances. Instead of maintaining 4 separate subnet model definitions, you +define one `prod-subnet` model and call it 4 times with different inputs. + +For a complete walkthrough, see the +[swamp-model scenarios](../../swamp-model/references/scenarios.md#scenario-5-factory-pattern-for-model-reuse). + +### Calling One Model Multiple Times + +Steps within a job run in parallel. Each step calls the same `modelIdOrName` +with different inputs: + +```yaml +jobs: + - name: create-subnets + steps: + - name: create-public-a + task: + type: model_method + modelIdOrName: prod-subnet + methodName: create + inputs: + instanceName: public-a + cidrBlock: "10.0.1.0/24" + availabilityZone: us-east-1a + - name: create-public-b + task: + type: model_method + modelIdOrName: prod-subnet + methodName: create + inputs: + instanceName: public-b + cidrBlock: "10.0.2.0/24" + availabilityZone: us-east-1b +``` + +The `instanceName` input flows into `name: ${{ inputs.instanceName }}` in the +model's globalArguments, which sets the data instance name. This is how one +model produces separately addressable data instances. + +### Referencing Factory Instance Data Downstream + +Each factory call creates a distinct data instance keyed by `instanceName`. Use +`data.latest()` with the instance name to reference specific outputs: + +```yaml +jobs: + - name: create-route-tables + dependsOn: + - job: create-subnets + condition: + type: succeeded + steps: + - name: create-public-rt + task: + type: model_method + modelIdOrName: prod-route-table + methodName: create + inputs: + instanceName: public-rt + subnetId: ${{ data.latest("prod-subnet", "public-a").attributes.SubnetId }} +``` + +### Delete Steps for Factory Models + +Delete steps must provide `instanceName` because the `name` globalArgument +(`name: ${{ inputs.instanceName }}`) determines which data instance to read and +delete. Other inputs are only required if the delete method's implementation +accesses those globalArguments at runtime. + +The system **selectively evaluates** globalArgument expressions — inputs that +aren't provided are skipped, and a runtime error only occurs if the method code +actually tries to access an unresolved value. + +**What breaks — missing `instanceName`:** + +```yaml +# WRONG: No instanceName, so the system can't resolve which data instance to use +- name: delete-public-a + task: + type: model_method + modelIdOrName: prod-subnet + methodName: delete + inputs: + identifier: ${{ data.latest("prod-subnet", "public-a").attributes.SubnetId }} +``` + +**What works — `instanceName` provided:** + +```yaml +# CORRECT: instanceName resolves the name globalArgument and keys the data instance +- name: delete-public-a + task: + type: model_method + modelIdOrName: prod-subnet + methodName: delete + inputs: + instanceName: public-a + identifier: ${{ data.latest("prod-subnet", "public-a").attributes.SubnetId }} +``` + +Whether you also need `cidrBlock`, `availabilityZone`, or other create-time +inputs depends on your delete method implementation. If the method code accesses +`globalArgs.CidrBlock`, you must provide `cidrBlock`. If it doesn't, the +unresolved expression is silently skipped.