[{"data":1,"prerenderedAt":595},["ShallowReactive",2],{"case-study:eliminating-api-schema-drift":3},{"id":4,"title":5,"body":6,"description":565,"draft":345,"extension":566,"industry":567,"meta":568,"metrics":569,"navigation":345,"order":103,"path":578,"role":579,"seo":580,"stack":581,"stem":587,"tags":588,"year":593,"__hash__":594},"caseStudies\u002Fcase-studies\u002Feliminating-api-schema-drift.md","Killing API schema drift with a generated, type-safe client",{"type":7,"value":8,"toc":552},"minimark",[9,14,18,22,30,33,37,54,57,61,68,73,83,261,276,280,290,294,300,472,476,483,487,506,510,536,543,548],[10,11,13],"h2",{"id":12},"in-one-line","In one line",[15,16,17],"p",{},"I made the backend's OpenAPI spec the single source of truth for the API and\nbuilt a pipeline that compiles it into a typed TypeScript SDK, published\nautomatically on every change - so a breaking API change becomes a red build in\nthe frontend instead of a bug a merchant finds.",[10,19,21],{"id":20},"the-problem","The problem",[15,23,24,25,29],{},"When a backend and a frontend live in separate repositories, they drift. A field\ngets renamed on the server, the frontend doesn't hear about it, and you find out\nin production - or in a long afternoon of \"why is this ",[26,27,28],"code",{},"undefined","?\"",[15,31,32],{},"On a payments platform under active development across two repos, that isn't a\nnuisance, it's a steady tax: hand-written request\u002Fresponse types that quietly\nlie, runtime errors that should have been compile errors, and a frontend team\nthat can't fully trust the API it's building against. Multiply that by dozens of\nendpoints under active migration and it becomes a real drag on velocity.",[10,34,36],{"id":35},"the-insight","The insight",[15,38,39,40,43,44,48,49,53],{},"The backend already knew the exact shape of every endpoint. Handlers are\nannotated with OpenAPI attributes, and a generator turns those into an\n",[26,41,42],{},"openapi.yml"," spec. The spec ",[45,46,47],"em",{},"was"," an accurate contract. The problem was that\nnothing ",[50,51,52],"strong",{},"forced"," the frontend to stay aligned with it - alignment was a manual,\nhuman, forgettable step.",[15,55,56],{},"So I removed the human from the loop and made the contract flow downhill,\nautomatically, from one source.",[10,58,60],{"id":59},"what-i-built","What I built",[15,62,63,64,67],{},"A ",[50,65,66],{},"code-first OpenAPI pipeline",", end to end.",[69,70,72],"h3",{"id":71},"_1-the-spec-is-generated-from-the-code-that-serves-the-request","1. The spec is generated from the code that serves the request",[15,74,75,76,79,80,82],{},"Handlers carry OpenAPI attributes; a command (",[26,77,78],{},"zircote\u002Fswagger-php",") scans them\nand emits ",[26,81,42],{},". The spec is a build output, not a document someone\nmaintains by hand and forgets to update.",[84,85,90],"pre",{"className":86,"code":87,"language":88,"meta":89,"style":89},"language-php shiki shiki-themes one-dark-pro","#[OA\\Get(\n    path: '\u002Fcustomers',\n    operationId: 'fetchAllCustomer', \u002F\u002F ← becomes the SDK function name\n    tags: ['Customers'],\n    responses: [\n        new OA\\Response(\n            response: 200,\n            description: 'OK',\n            content: new OA\\JsonContent(\n                type: 'array',\n                items: new OA\\Items(ref: '#\u002Fcomponents\u002Fschemas\u002FCustomer')\n            )\n        ),\n    ],\n)]\n","php","",[26,91,92,101,114,130,142,148,165,177,188,204,215,237,243,249,255],{"__ignoreMap":89},[93,94,97],"span",{"class":95,"line":96},"line",1,[93,98,100],{"class":99},"sn6KH","#[OA\\Get(\n",[93,102,104,107,111],{"class":95,"line":103},2,[93,105,106],{"class":99},"    path: ",[93,108,110],{"class":109},"subq3","'\u002Fcustomers'",[93,112,113],{"class":99},",\n",[93,115,117,120,123,126],{"class":95,"line":116},3,[93,118,119],{"class":99},"    operationId: ",[93,121,122],{"class":109},"'fetchAllCustomer'",[93,124,125],{"class":99},", ",[93,127,129],{"class":128},"sV9Aq","\u002F\u002F ← becomes the SDK function name\n",[93,131,133,136,139],{"class":95,"line":132},4,[93,134,135],{"class":99},"    tags: [",[93,137,138],{"class":109},"'Customers'",[93,140,141],{"class":99},"],\n",[93,143,145],{"class":95,"line":144},5,[93,146,147],{"class":99},"    responses: [\n",[93,149,151,155,158,162],{"class":95,"line":150},6,[93,152,154],{"class":153},"seHd6","        new",[93,156,157],{"class":99}," OA\\",[93,159,161],{"class":160},"sU0A5","Response",[93,163,164],{"class":99},"(\n",[93,166,168,171,175],{"class":95,"line":167},7,[93,169,170],{"class":99},"            response:",[93,172,174],{"class":173},"sVC51"," 200",[93,176,113],{"class":99},[93,178,180,183,186],{"class":95,"line":179},8,[93,181,182],{"class":99},"            description:",[93,184,185],{"class":109}," 'OK'",[93,187,113],{"class":99},[93,189,191,194,197,199,202],{"class":95,"line":190},9,[93,192,193],{"class":99},"            content:",[93,195,196],{"class":153}," new",[93,198,157],{"class":99},[93,200,201],{"class":160},"JsonContent",[93,203,164],{"class":99},[93,205,207,210,213],{"class":95,"line":206},10,[93,208,209],{"class":99},"                type:",[93,211,212],{"class":109}," 'array'",[93,214,113],{"class":99},[93,216,218,221,223,225,228,231,234],{"class":95,"line":217},11,[93,219,220],{"class":99},"                items:",[93,222,196],{"class":153},[93,224,157],{"class":99},[93,226,227],{"class":160},"Items",[93,229,230],{"class":99},"(ref:",[93,232,233],{"class":109}," '#\u002Fcomponents\u002Fschemas\u002FCustomer'",[93,235,236],{"class":99},")\n",[93,238,240],{"class":95,"line":239},12,[93,241,242],{"class":99},"            )\n",[93,244,246],{"class":95,"line":245},13,[93,247,248],{"class":99},"        ),\n",[93,250,252],{"class":95,"line":251},14,[93,253,254],{"class":99},"    ],\n",[93,256,258],{"class":95,"line":257},15,[93,259,260],{"class":99},")]\n",[15,262,263,264,267,268,271,272,275],{},"That ",[26,265,266],{},"operationId"," is not a detail - it's the contract for the generated client.\nWith it you get a clean ",[26,269,270],{},"fetchAllCustomer()","; without it the generator emits\nhash-based garbage like ",[26,273,274],{},"getA3f8b2c1()",". Which is exactly why I didn't leave it to\ndiscipline (see below).",[69,277,279],{"id":278},"_2-the-contract-is-linted","2. The contract is linted",[15,281,282,283,286,287,289],{},"The spec is checked with ",[50,284,285],{},"Spectral"," against a custom ruleset before it's\nallowed downstream - every operation must have an ",[26,288,266],{},", schemas must\nlive on DTOs (not entities), responses must be typed. The contract stays\nmachine-consistent instead of slowly rotting.",[69,291,293],{"id":292},"_3-the-spec-compiles-to-a-typed-sdk","3. The spec compiles to a typed SDK",[15,295,296,299],{},[26,297,298],{},"@hey-api\u002Fopenapi-ts"," turns the spec into a fully typed TypeScript client - every\nendpoint, every request and response type, an Axios-based client with auth\ninterceptors. No interfaces written by hand. The frontend imports functions and\ntypes straight from the package:",[84,301,305],{"className":302,"code":303,"language":304,"meta":89,"style":89},"language-ts shiki shiki-themes one-dark-pro","import { fetchAllCustomer, findCustomerById, type Customer } from '@org\u002Fapi-client'\n\n\u002F\u002F Fully typed: params, query shape, and the Customer return type all come\n\u002F\u002F from the backend's OpenAPI spec. Rename a field server-side and this stops\n\u002F\u002F compiling.\nconst { data } = await fetchAllCustomer({\n  query: { pageIndex: 1, resultsPerPage: 10, search: 'acme' },\n})\nconst customer: Customer = await findCustomerById({ path: { id: 123 } })\n","ts",[26,306,307,341,347,352,357,362,388,428,433],{"__ignoreMap":89},[93,308,309,312,315,319,321,324,326,329,332,335,338],{"class":95,"line":96},[93,310,311],{"class":153},"import",[93,313,314],{"class":99}," { ",[93,316,318],{"class":317},"sVyAn","fetchAllCustomer",[93,320,125],{"class":99},[93,322,323],{"class":317},"findCustomerById",[93,325,125],{"class":99},[93,327,328],{"class":153},"type",[93,330,331],{"class":317}," Customer",[93,333,334],{"class":99}," } ",[93,336,337],{"class":153},"from",[93,339,340],{"class":109}," '@org\u002Fapi-client'\n",[93,342,343],{"class":95,"line":103},[93,344,346],{"emptyLinePlaceholder":345},true,"\n",[93,348,349],{"class":95,"line":116},[93,350,351],{"class":128},"\u002F\u002F Fully typed: params, query shape, and the Customer return type all come\n",[93,353,354],{"class":95,"line":132},[93,355,356],{"class":128},"\u002F\u002F from the backend's OpenAPI spec. Rename a field server-side and this stops\n",[93,358,359],{"class":95,"line":144},[93,360,361],{"class":128},"\u002F\u002F compiling.\n",[93,363,364,367,369,372,374,378,381,385],{"class":95,"line":150},[93,365,366],{"class":153},"const",[93,368,314],{"class":99},[93,370,371],{"class":160},"data",[93,373,334],{"class":99},[93,375,377],{"class":376},"sjrmR","=",[93,379,380],{"class":153}," await",[93,382,384],{"class":383},"sVbv2"," fetchAllCustomer",[93,386,387],{"class":99},"({\n",[93,389,390,393,396,399,402,405,407,410,412,415,417,420,422,425],{"class":95,"line":167},[93,391,392],{"class":317},"  query",[93,394,395],{"class":99},": { ",[93,397,398],{"class":317},"pageIndex",[93,400,401],{"class":99},": ",[93,403,404],{"class":173},"1",[93,406,125],{"class":99},[93,408,409],{"class":317},"resultsPerPage",[93,411,401],{"class":99},[93,413,414],{"class":173},"10",[93,416,125],{"class":99},[93,418,419],{"class":317},"search",[93,421,401],{"class":99},[93,423,424],{"class":109},"'acme'",[93,426,427],{"class":99}," },\n",[93,429,430],{"class":95,"line":179},[93,431,432],{"class":99},"})\n",[93,434,435,437,440,442,445,448,450,453,456,459,461,464,466,469],{"class":95,"line":190},[93,436,366],{"class":153},[93,438,439],{"class":160}," customer",[93,441,401],{"class":99},[93,443,444],{"class":160},"Customer",[93,446,447],{"class":376}," =",[93,449,380],{"class":153},[93,451,452],{"class":383}," findCustomerById",[93,454,455],{"class":99},"({ ",[93,457,458],{"class":317},"path",[93,460,395],{"class":99},[93,462,463],{"class":317},"id",[93,465,401],{"class":99},[93,467,468],{"class":173},"123",[93,470,471],{"class":99}," } })\n",[69,473,475],{"id":474},"_4-publishing-is-automated-on-change","4. Publishing is automated on change",[15,477,478,479,482],{},"A GitHub Actions pipeline regenerates the client and ",[50,480,481],{},"diffs it against what's\ncommitted","; if it differs, it publishes a new version to a private registry,\nwith the version bump derived from conventional commits. The frontend consumes it\nlike any other dependency - no one runs a manual \"regenerate the types\" ritual,\nbecause there isn't one.",[10,484,486],{"id":485},"closing-the-last-gap-enforcing-the-rule-that-makes-it-work","Closing the last gap: enforcing the rule that makes it work",[15,488,489,490,492,493,496,497,499,500,505],{},"The whole pipeline hinges on every endpoint having a correct ",[26,491,266],{}," - a\nthing humans forget. So rather than rely on review to catch it, I made it a\n",[50,494,495],{},"custom static-analysis rule"," in the backend: a handler without a proper\n",[26,498,266],{}," fails CI. The convention that the automation depends on is itself\nautomated. (More on that approach in\n",[501,502,504],"a",{"href":503},"\u002Fcase-studies\u002Fengineering-standards-and-tooling","Making architecture enforce itself",".)",[10,507,509],{"id":508},"the-payoff","The payoff",[511,512,513,524,530],"ul",{},[514,515,516,519,520,523],"li",{},[50,517,518],{},"Drift becomes structurally impossible."," The frontend's types ",[45,521,522],{},"are"," the\nbackend's contract, so it cannot silently fall behind.",[514,525,526,529],{},[50,527,528],{},"A breaking change becomes a red build."," Rename a field on the server and the\nnext client regen turns every affected call site into a TypeScript error -\ncaught before merge, not by a merchant.",[514,531,532,535],{},[50,533,534],{},"The boring, error-prone work disappeared."," The frontend team stopped writing\nand babysitting request types and started trusting the SDK. Feature work got\nfaster because an entire category of bug stopped existing.",[15,537,538,539],{},"This is the kind of infrastructure that never shows up in a demo and quietly pays\noff on every endpoint added after it. I wrote about the general approach in\n",[501,540,542],{"href":541},"\u002Fblog\u002Fauto-generating-typescript-api-client","Auto-generating a TypeScript API client",[511,544,545],{},[514,546,547],{},"this is what it looks like wired into a real platform, with the linting and CI\nthat make it trustworthy.",[549,550,551],"style",{},"html pre.shiki code .sn6KH, html code.shiki .sn6KH{--shiki-default:#ABB2BF}html pre.shiki code .subq3, html code.shiki .subq3{--shiki-default:#98C379}html pre.shiki code .sV9Aq, html code.shiki .sV9Aq{--shiki-default:#7F848E;--shiki-default-font-style:italic}html pre.shiki code .seHd6, html code.shiki .seHd6{--shiki-default:#C678DD}html pre.shiki code .sU0A5, html code.shiki .sU0A5{--shiki-default:#E5C07B}html pre.shiki code .sVC51, html code.shiki .sVC51{--shiki-default:#D19A66}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sVyAn, html code.shiki .sVyAn{--shiki-default:#E06C75}html pre.shiki code .sjrmR, html code.shiki .sjrmR{--shiki-default:#56B6C2}html pre.shiki code .sVbv2, html code.shiki .sVbv2{--shiki-default:#61AFEF}",{"title":89,"searchDepth":103,"depth":103,"links":553},[554,555,556,557,563,564],{"id":12,"depth":103,"text":13},{"id":20,"depth":103,"text":21},{"id":35,"depth":103,"text":36},{"id":59,"depth":103,"text":60,"children":558},[559,560,561,562],{"id":71,"depth":116,"text":72},{"id":278,"depth":116,"text":279},{"id":292,"depth":116,"text":293},{"id":474,"depth":116,"text":475},{"id":485,"depth":103,"text":486},{"id":508,"depth":103,"text":509},"Two codebases, one API contract, and a constant tax of \"the frontend thinks this field is a string.\" I made the OpenAPI spec the single source of truth and wired a pipeline that turns it into a typed TypeScript SDK - so drift becomes a compile error, not a production surprise.","md","Payments \u002F Fintech",{},[570,572,575],{"value":404,"label":571},"source of truth - the OpenAPI spec, generated from code",{"value":573,"label":574},"0","hand-written API types on the frontend",{"value":576,"label":577},"Auto","SDK regenerated, versioned & published on every change","\u002Fcase-studies\u002Feliminating-api-schema-drift","Senior Full-Stack Developer",{"title":5,"description":565},[582,583,298,78,285,584,585,586],"OpenAPI","TypeScript","GitHub Actions","PHP 8.4","Mezzio","case-studies\u002Feliminating-api-schema-drift",[589,590,591,592],"Developer Experience","API Design","Automation","CI\u002FCD","2024–present","lf3btujOhYnHu78bcjmGDI5_N8m-Ehg6zQwjP9CPqfA",1781646389925]