| 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 Location | Purpose |
|---|
shared/api/{resource}/changes/V{X}_{Y}_{Name}.ts | Response transforms (top-level resources) |
shared/api/{resource}/requestChanges/V{X}_{Y}_{Name}.ts | Request transforms |
shared/api/{resource}/previousVersions/ | Old schema definitions |
shared/api/versionUtils/versionChangeRegistry.ts | Register all changes |
Creating a Version Change
Step 1: Define Schemas
Create/identify both schemas in shared/api/{resource}/:
export const ApiCustomerSchema = z.object({
subscriptions: z.array(ApiSubscriptionSchema),
balances: z.record(ApiBalanceSchema),
});
export const ApiCustomerV3Schema = z.object({
products: z.array(ApiCusProductV3Schema),
features: z.record(ApiCusFeatureV3Schema),
});
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,
oldVersion: ApiVersion.V1_Beta,
description: [
"Products renamed to subscriptions",
"Features renamed to balances",
],
affectedResources: [AffectedResource.Customer],
newSchema: ApiCustomerSchema,
oldSchema: ApiCustomerV3Schema,
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,
];
export function registerAllVersionChanges() {
VersionChangeRegistryClass.register({
version: ApiVersion.V2_0,
changes: V2_CHANGES,
});
}
Nested Models (NOT Registered)
For nested models like ApiSubscription inside ApiCustomer:
- Create the change file with an exported transform function
- DO NOT register in
versionChangeRegistry.ts
- Call the function from the parent's transform
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),
};
}
export const V1_2_CusPlanChange = defineVersionChange({
transformResponse: transformSubscriptionToCusProductV3,
});
Then call from parent:
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:
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,
affectsResponse: false,
transformRequest: ({ input }) => {
return {
...input,
expand: [...(input.expand || []), CusExpand.SubscriptionsPlan],
};
},
});
Handler Usage
Response Versioning (Automatic)
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");
},
});
When Business Logic Needs Old Format
Sometimes business logic is tied to an old model version. Convert in the handler:
const body = c.req.valid("json");
const v1_2Body = ctx.apiVersion.gte(new ApiVersionClass(ApiVersion.V2_0))
? planToProductV2({ plan: body as ApiPlan, features: ctx.features })
: (body as UpdateProductV2Params);
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,
newSchema: NoOpSchema,
oldSchema: NoOpSchema,
transformResponse: ({ input }) => input,
});
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
| Pattern | Example | Purpose |
|---|
V{X}.{Y}_{Resource}Change.ts | V1.2_CustomerChange.ts | Main resource transform |
V{X}.{Y}_{Description}.ts | V1.1_FeaturesArrayToObject.ts | Specific change |
V{X}.{Y}_{Resource}QueryChange.ts | V1.2_CustomerQueryChange.ts | Request transform |
Name after the TARGET version (the older version we're transforming TO).
Key Principles
- Build latest, transform backwards - Handlers always work with latest format
- Single-step transforms - Each change file maps ONE version to the PREVIOUS
- Register top-level only - Nested model transforms are called manually
- Use
satisfies - Ensure return types match schema: return {...} satisfies z.infer<Schema>
References