hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses API versioning and evolution with @hectoday/http

Why versioning

  • Breaking changes
  • The versioning contract
  • Project setup

Versioning strategies

  • URL path versioning
  • Header versioning
  • Query parameter versioning
  • Choosing a strategy

Building versioned APIs

  • Side-by-side versions
  • Version routers
  • Validation per version

Evolving without breaking

  • Additive changes
  • Deprecation
  • Field renaming and removal
  • Changing response shapes

Lifecycle management

  • Sunset policies
  • Monitoring version usage
  • Checklist

Additive changes

The safe path

Back in the first lesson, we established a rule: additive changes are safe. This lesson shows what that looks like in practice. You can evolve an API within a version, adding real features, without breaking any existing clients.

Adding a field to the response

A client asks: “Can you include the book’s ISBN?” This is a new field. No existing client reads it. Adding it is completely safe.

// Before
{ "id": "book-1", "title": "Kindred", "genre": "science-fiction" }

// After (isbn added)
{ "id": "book-1", "title": "Kindred", "genre": "science-fiction", "isbn": "978-0807083697" }

The existing clients read id, title, and genre. The new isbn field sits there, and they ignore it. No crash, no error, no change needed on their side.

To implement this, we just update the transformer:

export function formatBookV2(row: BookRow) {
  return {
    id: row.id,
    title: row.title,
    author: { id: row.author_id, name: row.author_name },
    genre: row.genre,
    isbn: row.isbn, // new field, existing clients ignore it
    ratings: { average: row.avg_rating, count: row.review_count },
    createdAt: row.created_at,
  };
}

No version bump needed. v2 clients that know about isbn use it. Older v2 clients that were built before this field existed simply ignore it.

[!NOTE] This is why the Zod for Beginners course explained that Zod strips unknown fields by default. A well-written client using Zod to parse the response ignores new fields automatically. They get stripped during parsing. This makes additive changes safe on both sides.

Adding a new endpoint

A client asks: “Can you add a search endpoint?” New endpoints don’t affect existing ones at all:

// New endpoint. Does not affect existing /v2/books or /v2/books/:id
route.get("/v2/books/search", {
  request: {
    query: z.object({ q: z.string().default("") }),
  },
  resolve: (c) => {
    if (!c.input.ok) return Response.json({ error: c.input.issues }, { status: 400 });
    const books = searchBooks(c.input.query.q);
    return Response.json(formatBooksV2(books));
  },
});

Existing clients never call /v2/books/search, so they’re completely unaffected. The new endpoint just sits there, available for any client that wants it.

Adding an optional parameter

A client asks: “Can we filter books by publication year?” Adding an optional query parameter is safe because existing requests don’t include it:

// Before: GET /v2/books?genre=fiction
// After:  GET /v2/books?genre=fiction&year=2024  (year is optional)

const BookQueryV2 = z.object({
  genre: z
    .enum([
      /* ... */
    ])
    .optional(),
  year: z.coerce.number().int().min(1900).max(2100).optional(), // new, optional
  sort: z.enum(["title", "createdAt", "rating"]).default("title"),
  page: z.coerce.number().int().min(1).default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
});

Existing clients call /v2/books?genre=fiction. The new year parameter is optional. When it’s missing, it means “no filter,” which is the exact same behavior as before.

Adding an optional request body field

A client asks: “Can we include tags when creating a book?” Adding an optional field to the request body is safe:

// Before
export const CreateBookV2 = z.object({
  title: z.string().trim().min(1).max(200),
  authorId: z.string().uuid(),
  genre: z.enum([
    /* ... */
  ]),
  description: z.string().max(5000).optional(),
});

// After (tags added as optional)
export const CreateBookV2 = z.object({
  title: z.string().trim().min(1).max(200),
  authorId: z.string().uuid(),
  genre: z.enum([
    /* ... */
  ]),
  description: z.string().max(5000).optional(),
  tags: z.array(z.string().min(1)).max(10).optional(), // new, optional
});

Existing clients don’t send tags. The field is optional, so requests without it still pass validation. New clients can include tags if they want. Both old and new requests work.

What is NOT an additive change

These look tempting but are not safe:

Making an optional field required. Existing clients don’t send it. Their requests now fail validation.

Changing a field’s type. Even “upgrading” from number to { value: number, label: string } breaks clients that expect a number.

Changing the meaning of a value. If status: "active" previously meant “published” and now means “draft,” clients interpret the data incorrectly even though the shape hasn’t changed.

The rule holds: you can add things, but you can’t change or remove things that already exist.

Additive changes handle the easy cases. But what about when you actually need to remove something? You can’t just delete it. The next lesson covers deprecation: how to warn clients that something is going away before you pull it.

Exercises

Exercise 1: Add a new field (isbn) to the v2 book response. Verify existing v2 test calls still pass.

Exercise 2: Add a new optional query parameter. Verify existing calls without the parameter still work.

Exercise 3: Add a new optional field to the create request body. Verify existing create calls still succeed.

Why is adding an optional field to the request body safe?

← Validation per version Deprecation →

© 2026 hectoday. All rights reserved.