Back to list
useautumn

api-versioning

by useautumn

Autumn is an open-source pricing & billing platform

2,309🍴 178📅 Jan 22, 2026

Use Cases

Work Efficiency

Streamline daily tasks and improve productivity.

📋

Project Management

Assist with task management and project tracking.

👥

Team Collaboration

Improve team communication and collaboration.

SKILL.md


name: api-versioning description: Create and maintain API version changes. Use when adding breaking changes to API responses/requests, creating version change files, transforming data between versions, or handling backward compatibility.

API Versioning

Create version change files that transform API data between versions.

Core Concept

Always build latest format, transform backwards automatically.

User on V1.2 ← transformResponse ← Latest (V2.0)
User on V1.2 → transformRequest  → Latest (V2.0)
  • Response transforms: Go BACKWARDS (new → old). User expects old format.
  • Request transforms: Go FORWARDS (old → new). Handler expects latest format.

Version Flow

V2.0 (latest)
  ↓ V1_2_CustomerChange.transformResponse()
V1_Beta
  ↓ V1_1_FeaturesArrayToObject.transformResponse()
V1.2
  ↓ V0_2_CustomerChange.transformResponse()
V1.1
  ↓ ...
V0.1

Each change file is a single-step mapping from one version to the previous.

Quick Reference

File LocationPurpose
shared/api/{resource}/changes/V{X}_{Y}_{Name}.tsResponse transforms (top-level resources)
shared/api/{resource}/requestChanges/V{X}_{Y}_{Name}.tsRequest transforms
shared/api/{resource}/previousVersions/Old schema definitions
shared/api/versionUtils/versionChangeRegistry.tsRegister all changes

Creating a Version Change

Step 1: Define Schemas

Create/identify both schemas in shared/api/{resource}/:

// Latest schema (shared/api/customers/apiCustomer.ts)
export const ApiCustomerSchema = z.object({
  subscriptions: z.array(ApiSubscriptionSchema),  // V2.0: renamed from "products"
  balances: z.record(ApiBalanceSchema),           // V2.0: renamed from "features"
});

// Old schema (shared/api/customers/previousVersions/apiCustomerV3.ts)
export const ApiCustomerV3Schema = z.object({
  products: z.array(ApiCusProductV3Schema),  // V1.2 name
  features: z.record(ApiCusFeatureV3Schema), // V1.2 name
});

Step 2: Create the Change File

File: shared/api/customers/changes/V1.2_CustomerChange.ts

import { ApiVersion } from "@api/versionUtils/ApiVersion.js";
import {
  AffectedResource,
  defineVersionChange,
} from "@api/versionUtils/versionChangeUtils/VersionChange.js";
import { ApiCustomerSchema } from "../apiCustomer.js";
import { ApiCustomerV3Schema } from "../previousVersions/apiCustomerV3.js";

export const V1_2_CustomerChange = defineVersionChange({
  newVersion: ApiVersion.V2_0,    // Version where breaking change was introduced
  oldVersion: ApiVersion.V1_Beta, // Version we're transforming TO
  description: [
    "Products renamed to subscriptions",
    "Features renamed to balances",
  ],
  affectedResources: [AffectedResource.Customer],
  newSchema: ApiCustomerSchema,
  oldSchema: ApiCustomerV3Schema,

  // Response: V2.0 → V1.2 (transform TO older format)
  transformResponse: ({ input, legacyData, ctx }) => {
    return {
      ...input,
      products: input.subscriptions.map(sub => 
        transformSubscriptionToCusProductV3({ input: sub, ctx })
      ),
      features: Object.fromEntries(
        Object.entries(input.balances).map(([id, bal]) => [
          id, 
          transformBalanceToCusFeatureV3({ input: bal })
        ])
      ),
    };
  },
});

Step 3: Register the Change

File: shared/api/versionUtils/versionChangeUtils/versionChangeRegistry.ts

import { V1_2_CustomerChange } from "@api/customers/changes/V1.2_CustomerChange.js";

export const V2_CHANGES: VersionChangeConstructor[] = [
  V1_2_CustomerChange,  // Registered at V2.0, transforms TO V1.2
  // ... other V2.0 changes
];

export function registerAllVersionChanges() {
  VersionChangeRegistryClass.register({
    version: ApiVersion.V2_0,
    changes: V2_CHANGES,
  });
  // ... other versions
}

Nested Models (NOT Registered)

For nested models like ApiSubscription inside ApiCustomer:

  1. Create the change file with an exported transform function
  2. DO NOT register in versionChangeRegistry.ts
  3. Call the function from the parent's transform
// shared/api/customers/cusPlans/changes/V1.2_CusPlanChange.ts

// Export the function for reuse
export function transformSubscriptionToCusProductV3({
  input,
  legacyData,
  ctx,
}: {
  input: ApiSubscription;
  legacyData?: CusProductLegacyData;
  ctx: VersionContext;
}): ApiCusProductV3 {
  return {
    id: input.plan_id,
    name: input.plan?.name ?? null,
    status: mapStatus(input),
    // ... transform fields
  };
}

// Define the change (for reference, not registered)
export const V1_2_CusPlanChange = defineVersionChange({
  // ...
  transformResponse: transformSubscriptionToCusProductV3,
});

Then call from parent:

// V1_2_CustomerChange.ts
import { transformSubscriptionToCusProductV3 } from "../cusPlans/changes/V1.2_CusPlanChange.js";

transformResponse: ({ input, ctx }) => {
  return {
    products: input.subscriptions.map(sub => 
      transformSubscriptionToCusProductV3({ input: sub, ctx })
    ),
  };
}

Request Transformations (Old → New)

For transforming incoming requests to latest format:

// shared/api/customers/requestChanges/V1.2_CustomerQueryChange.ts

export const V1_2_CustomerQueryChange = defineVersionChange({
  newVersion: ApiVersion.V2_0,
  oldVersion: ApiVersion.V1_Beta,
  description: "Auto-expand plans.plan for V1.2 clients",
  affectedResources: [AffectedResource.Customer],
  newSchema: GetCustomerQuerySchema,
  oldSchema: GetCustomerQuerySchema,

  affectsRequest: true,   // Enable request transform
  affectsResponse: false, // Disable response transform

  // Request: V1.2 → V2.0 (transform TO newer format)
  transformRequest: ({ input }) => {
    return {
      ...input,
      expand: [...(input.expand || []), CusExpand.SubscriptionsPlan],
    };
  },
});

Handler Usage

Response Versioning (Automatic)

// Most handlers use getApiCustomer() which handles versioning internally
const customer = await getApiCustomer({ ctx, fullCustomer });
return c.json(customer);

Manual Response Versioning

import { applyResponseVersionChanges, AffectedResource } from "@autumn/shared";

const planResponse = await getPlanResponse({ product, features });

const versionedResponse = applyResponseVersionChanges<ApiPlan>({
  input: planResponse,
  targetVersion: ctx.apiVersion,
  resource: AffectedResource.Product,
  legacyData: { features: ctx.features },
  ctx,
});

return c.json(versionedResponse);

Versioned Request Validation

export const handleUpdatePlan = createRoute({
  versionedBody: {
    latest: UpdatePlanParamsSchema,
    [ApiVersion.V1_Beta]: UpdateProductV2ParamsSchema,
  },
  versionedQuery: {
    latest: UpdatePlanQuerySchema,
    [ApiVersion.V1_Beta]: UpdateProductQuerySchema,
  },
  resource: AffectedResource.Product,
  handler: async (c) => {
    const body = c.req.valid("json"); // Always latest format!
    // ...
  },
});

When Business Logic Needs Old Format

Sometimes business logic is tied to an old model version. Convert in the handler:

// server/src/internal/products/handlers/handleUpdatePlan.ts

const body = c.req.valid("json"); // Latest format (UpdatePlanParams)

// Convert to old format for existing business logic
const v1_2Body = ctx.apiVersion.gte(new ApiVersionClass(ApiVersion.V2_0))
  ? planToProductV2({ plan: body as ApiPlan, features: ctx.features })
  : (body as UpdateProductV2Params);

// Use v1_2Body with existing business functions
await handleUpdateProductDetails({
  newProduct: UpdateProductSchema.parse(v1_2Body),
  // ...
});

Side Effect Changes

For changes that affect behavior (not just data shape):

export const V0_2_InvoicesAlwaysExpanded = defineVersionChange({
  newVersion: ApiVersion.V1_1,
  oldVersion: ApiVersion.V0_2,
  description: "Invoices always expanded in V0_2",
  affectedResources: [AffectedResource.Customer],
  hasSideEffects: true, // Marks as side-effect only
  newSchema: NoOpSchema,
  oldSchema: NoOpSchema,
  transformResponse: ({ input }) => input, // No-op
});

Check in handler:

import { backwardsChangeActive, V0_2_InvoicesAlwaysExpanded } from "@autumn/shared";

if (backwardsChangeActive({
  apiVersion: ctx.apiVersion,
  versionChange: V0_2_InvoicesAlwaysExpanded,
})) {
  expand.push(CusExpand.Invoices);
}

File Naming Convention

PatternExamplePurpose
V{X}.{Y}_{Resource}Change.tsV1.2_CustomerChange.tsMain resource transform
V{X}.{Y}_{Description}.tsV1.1_FeaturesArrayToObject.tsSpecific change
V{X}.{Y}_{Resource}QueryChange.tsV1.2_CustomerQueryChange.tsRequest transform

Name after the TARGET version (the older version we're transforming TO).

Key Principles

  1. Build latest, transform backwards - Handlers always work with latest format
  2. Single-step transforms - Each change file maps ONE version to the PREVIOUS
  3. Register top-level only - Nested model transforms are called manually
  4. Use satisfies - Ensure return types match schema: return {...} satisfies z.infer<Schema>

References

Score

Total Score

80/100

Based on repository quality metrics

SKILL.md

SKILL.mdファイルが含まれている

+20
LICENSE

ライセンスが設定されている

+10
説明文

100文字以上の説明がある

0/10
人気

GitHub Stars 1000以上

+15
最近の活動

3ヶ月以内に更新

+5
フォーク

10回以上フォークされている

+5
Issue管理

オープンIssueが50未満

+5
言語

プログラミング言語が設定されている

+5
タグ

1つ以上のタグが設定されている

0/5

Reviews

💬

Reviews coming soon