Migrating an existing API
Move an existing API onto Kizuna without breaking deployed clients, using the additive pattern and the escape hatch for the rest.
You have an API in production with clients you cannot redeploy. Moving it onto Kizuna is mostly mechanical: routes, schemas, and success responses port across unchanged, and your URLs and status codes stay exactly where they are.
Errors are the one place Kizuna has an opinion. Every error response takes the RFC 9457 Problem Details shape, type, title, status, detail, plus any extra fields you add. Whether your deployed clients notice depends on what your errors look like today, which is the first thing to work out.
Do I need a new API version?
Usually not. Your endpoints and status codes never change, so the only question is whether a deployed client can still read the error body it gets back. Find your errors in the left column and do what the right one says.
| If your errors today are | You need to |
|---|---|
A JSON object, like { message, code } | Nothing. Keep every field as an extension member and clients read the same keys they read now |
A bare string, or a wrapper like { ok, error } | Add formatError, because the body shape changes and extension members cannot rewrite it |
Read by a client that asserts application/json | Add formatError to stamp the old content type, since errors now go out as application/problem+json |
All three keep your existing URLs and status codes, so none of them needs a /v2. Save a new version for an API that is incompatible for its own reasons.
Success responses are unchanged
Only error statuses (>= 400) are constrained. A 2xx/3xx response can be any shape, so port those exactly as they are. Everything below is about errors.
Errors are Problem Details
A route that declares an error with a bespoke shape no longer typechecks, and the handler's error body resolves to never:
responses: {
200: UserSchema,
404: z.object({ message: z.string() }), // compile error at the handler
}Use ProblemDetailsSchema. The handler supplies detail; type/title/status are filled in for you:
import { ProblemDetailsSchema } from 'kizunajs/schemas';
responses: {
200: UserSchema,
404: ProblemDetailsSchema,
}The additive pattern: keep your old fields
This is the path for nearly every migration. If your errors carry fields beyond a message, such as an application code or a request ID, keep them as RFC 9457 extension members with .extend():
import { z } from 'zod';
import { ProblemDetailsSchema } from 'kizunajs/schemas';
responses: {
404: ProblemDetailsSchema.extend({
errorCode: z.string(),
requestId: z.string(),
}),
}return throwError({
status: 404,
body: {
detail: 'No such user',
errorCode: 'USER_NOT_FOUND',
requestId: 'abc123',
},
});The body is now valid Problem Details and still has errorCode / requestId as top-level keys:
{
"type": "about:blank",
"title": "Not Found",
"status": 404,
"detail": "No such user",
"errorCode": "USER_NOT_FOUND",
"requestId": "abc123"
}A client reading errorCode keeps working untouched, and a new client reads the standard fields off the same body. When every client has moved to the standard fields, drop the extras.
The content type
Errors go out as application/problem+json; success stays application/json. That is still JSON, so a client calling response.json() parses it identically. Only a client that strictly asserts Content-Type: application/json notices.
When extension members aren't enough
Extension members only add top-level fields. They cannot nest, rename, or replace the body, so they can't reproduce a wrapper like { ok: false, error: {...} } or an error body that is a bare string. For that, and for the content type above, use formatError.
formatError, the escape hatch
formatError decides the bytes for an error response. Use it to stamp a different content type or reshape the body into a legacy structure. Because it receives the request, you can serve old and new clients at once. Your routes, types, and OpenAPI spec stay pure Problem Details; only the wire output changes.
Say old clients expect this body, as application/json:
{
"ok": false,
"error": {
"code": 404,
"message": "No such user",
"errorCode": "USER_NOT_FOUND"
}
}Branch on whatever signal tells your clients apart, such as a version header, a build header, or Accept, and reshape to the legacy body only for the old ones:
api.mount(app, {
// `problem` is the full Problem Details object (envelope + extension members).
formatError: (problem, { request }) => {
// new clients opt in via a signal you control, here a version header
if (request.headers.get('x-api-version') === '2') {
return {
contentType: 'application/problem+json',
body: problem,
};
}
// old clients keep their existing shape
return {
contentType: 'application/json',
body: {
ok: false,
error: {
code: problem.status,
message: problem.detail,
errorCode: problem.errorCode, // extension member, carried through
},
},
};
},
});This applies to every error: the built-in 404/405/415, validation 400s, guard denials, and anything your handlers pass to throwError. Every first-party adapter takes it. Remove the branch once the old clients are gone.
Doing a coordinated cutover instead? Skip the branch, always return the legacy shape, and delete formatError on flip day.
Forwarding errors your handlers already throw
If your handlers throw domain errors (or you rely on framework error middleware), map them into Problem Details at the boundary instead of rewriting each handler. On Next.js that's the onError option; on Express, Fastify, and Hono, build the response with problemDetails() in the framework's error handler.
import { problemDetails } from 'kizunajs';
export default defineConfig({
adapter: nextAdapter({
onError: (caught) => {
if (caught instanceof NotFoundError) {
return NextResponse.json(problemDetails(404, caught.message), {
status: 404,
headers: {
'content-type': 'application/problem+json',
},
});
}
// return nothing to fall through to the default 500
},
}),
routes,
});Errors that aren't JSON
For an error that genuinely isn't JSON, such as proxying an upstream body or an HTML error page, adapters expose a raw-response result you return directly. It bypasses validation and the spec, so use it only when neither extension members nor formatError fit.
A typical migration, in order
- Move error responses to
ProblemDetailsSchema, using.extend({...})to keep any extra fields. Most APIs stop here. - Only if a client strictly checks the content type, or needs a structurally different body: add
formatError. - Serving old and new clients simultaneously? Branch inside
formatErroron a signal your API has. - Map any errors your handlers throw into Problem Details at the boundary.
- As clients adopt the standard shape, drop the extras and remove
formatError.