[{"data":1,"prerenderedAt":2796},["ShallowReactive",2],{"case-studies:list":3},[4,626,975,1583,1796,2316,2623],{"id":5,"title":6,"body":7,"description":587,"draft":588,"extension":589,"industry":590,"meta":591,"metrics":592,"navigation":209,"order":131,"path":602,"role":603,"seo":604,"stack":605,"stem":618,"tags":619,"year":624,"__hash__":625},"caseStudies\u002Fcase-studies\u002Freplatforming-a-legacy-payments-system.md","Re-platforming a payments system: four legacy codebases into one",{"type":8,"value":9,"toc":573},"minimark",[10,15,19,23,31,34,38,46,83,86,90,97,102,117,370,381,385,392,396,411,475,479,488,492,524,528,539,551,555,566,569],[11,12,14],"h2",{"id":13},"in-one-line","In one line",[16,17,18],"p",{},"I designed the target architecture for the ground-up rebuild of a payments\ncompany's internal platform - and as the primary developer I've written roughly\n66% of it (2,846 commits across seven repositories), turning four tangled legacy\nsystems into one modular, type-safe platform that a team can actually extend.",[11,20,22],{"id":21},"the-context","The context",[16,24,25,26,30],{},"This is the internal platform behind a payments business - the system staff use\nto manage thousands of merchants and terminals: onboarding, product and\nPIN\u002Fserial stock, reporting, settlement, content management. It works. It also\ncan't keep up with what the business needs next, because of ",[27,28,29],"em",{},"how"," it's built.",[16,32,33],{},"When correctness is tied to real money, \"rewrite it\" is the easy sentence and the\nhard job. The hard job is replacing the engine while the plane is flying - and\ndoing it without betting the business on a single switch-over.",[11,35,37],{"id":36},"the-mess-i-inherited","The mess I inherited",[16,39,40,41,45],{},"The platform wasn't one legacy system. It was ",[42,43,44],"strong",{},"four",", layered up over a decade:",[47,48,49,60,66,76],"ul",{},[50,51,52,53,56,57],"li",{},"A ",[42,54,55],{},"Zend Framework 1"," backend exposing ",[42,58,59],{},"SOAP and JSON-RPC",[50,61,52,62,65],{},[42,63,64],{},"Zend Framework 2"," backend exposing REST on Doctrine 2.5",[50,67,52,68,71,72,75],{},[42,69,70],{},"ZF2 + AngularJS 1.2"," hybrid - server-rendered pages ",[27,73,74],{},"and"," a SPA",[50,77,78,79,82],{},"A separate ",[42,80,81],{},"Vue 3 SPA monorepo"," of ~11 apps stitched into that frontend",[16,84,85],{},"Four runtimes, three eras of frontend, two RPC styles, and business logic\nsmeared across all of them. A single feature could touch several codebases; no\none could say where a rule actually lived; and onboarding a developer meant\nteaching four architectures at once. Worse, the data layer was full of traps the\nnew system would have to keep working with - the old database doesn't get to be\nrewritten on the same schedule as the code.",[11,87,89],{"id":88},"the-architecture-i-designed","The architecture I designed",[16,91,92,93,96],{},"I designed a clean ",[42,94,95],{},"three-tier system"," to replace all four, along with the\npatterns the rebuild is built on.",[98,99,101],"h3",{"id":100},"backend-a-modular-monolith-mezzio-php-84-doctrine-orm","Backend - a modular monolith (Mezzio \u002F PHP 8.4, Doctrine ORM)",[16,103,104,105,108,109,112,113,116],{},"One deployable, but internally split into domain modules - auth, customers,\nmerchants, terminals, payment, reporting, PIN management, content. Every request\nflows through a strict ",[42,106,107],{},"Handler → Service → Repository"," layering, and that\nboundary is ",[27,110,111],{},"enforced in CI"," (not just documented), so the modular structure\ncan't quietly erode into a ball of mud. Services return ",[42,114,115],{},"DTOs, never entities",",\nwhich keeps the public API contract decoupled from the database schema - the\nschema can stay ugly and legacy while the contract stays clean.",[118,119,124],"pre",{"className":120,"code":121,"language":122,"meta":123,"style":123},"language-php shiki shiki-themes one-dark-pro","\u002F\u002F A handler stays thin: parse input, call a service, return a DTO.\n\u002F\u002F Business logic never leaks up into HTTP, and persistence never leaks up\n\u002F\u002F into business logic.\nfinal class FetchCustomerStoresHandler implements RequestHandlerInterface\n{\n    public function __construct(private StoreService $stores) {}\n\n    public function handle(ServerRequestInterface $request): ResponseInterface\n    {\n        $customerId = (int) $request->getAttribute('id');\n        $criteria   = SearchCriteria::fromRequest($request); \u002F\u002F page, size, search, sort\n\n        \u002F\u002F Service returns DTOs; the handler never sees a Doctrine entity.\n        return new JsonResponse($this->stores->listForCustomer($customerId, $criteria));\n    }\n}\n","php","",[125,126,127,136,142,148,168,175,204,211,236,242,278,306,311,317,358,364],"code",{"__ignoreMap":123},[128,129,132],"span",{"class":130,"line":131},"line",1,[128,133,135],{"class":134},"sV9Aq","\u002F\u002F A handler stays thin: parse input, call a service, return a DTO.\n",[128,137,139],{"class":130,"line":138},2,[128,140,141],{"class":134},"\u002F\u002F Business logic never leaks up into HTTP, and persistence never leaks up\n",[128,143,145],{"class":130,"line":144},3,[128,146,147],{"class":134},"\u002F\u002F into business logic.\n",[128,149,151,155,158,162,165],{"class":130,"line":150},4,[128,152,154],{"class":153},"seHd6","final",[128,156,157],{"class":153}," class",[128,159,161],{"class":160},"sU0A5"," FetchCustomerStoresHandler",[128,163,164],{"class":153}," implements",[128,166,167],{"class":160}," RequestHandlerInterface\n",[128,169,171],{"class":130,"line":170},5,[128,172,174],{"class":173},"sn6KH","{\n",[128,176,178,181,184,188,191,194,197,201],{"class":130,"line":177},6,[128,179,180],{"class":153},"    public",[128,182,183],{"class":153}," function",[128,185,187],{"class":186},"sjrmR"," __construct",[128,189,190],{"class":173},"(",[128,192,193],{"class":153},"private",[128,195,196],{"class":160}," StoreService",[128,198,200],{"class":199},"sVyAn"," $stores",[128,202,203],{"class":173},") {}\n",[128,205,207],{"class":130,"line":206},7,[128,208,210],{"emptyLinePlaceholder":209},true,"\n",[128,212,214,216,218,222,224,227,230,233],{"class":130,"line":213},8,[128,215,180],{"class":153},[128,217,183],{"class":153},[128,219,221],{"class":220},"sVbv2"," handle",[128,223,190],{"class":173},[128,225,226],{"class":160},"ServerRequestInterface",[128,228,229],{"class":199}," $request",[128,231,232],{"class":173},"): ",[128,234,235],{"class":160},"ResponseInterface\n",[128,237,239],{"class":130,"line":238},9,[128,240,241],{"class":173},"    {\n",[128,243,245,248,251,254,257,260,263,266,269,271,275],{"class":130,"line":244},10,[128,246,247],{"class":199},"        $customerId",[128,249,250],{"class":186}," =",[128,252,253],{"class":173}," (",[128,255,256],{"class":153},"int",[128,258,259],{"class":173},") ",[128,261,262],{"class":199},"$request",[128,264,265],{"class":173},"->",[128,267,268],{"class":220},"getAttribute",[128,270,190],{"class":173},[128,272,274],{"class":273},"subq3","'id'",[128,276,277],{"class":173},");\n",[128,279,281,284,287,290,293,296,298,300,303],{"class":130,"line":280},11,[128,282,283],{"class":199},"        $criteria",[128,285,286],{"class":186},"   =",[128,288,289],{"class":160}," SearchCriteria",[128,291,292],{"class":173},"::",[128,294,295],{"class":220},"fromRequest",[128,297,190],{"class":173},[128,299,262],{"class":199},[128,301,302],{"class":173},"); ",[128,304,305],{"class":134},"\u002F\u002F page, size, search, sort\n",[128,307,309],{"class":130,"line":308},12,[128,310,210],{"emptyLinePlaceholder":209},[128,312,314],{"class":130,"line":313},13,[128,315,316],{"class":134},"        \u002F\u002F Service returns DTOs; the handler never sees a Doctrine entity.\n",[128,318,320,323,326,329,331,334,336,339,341,344,346,349,352,355],{"class":130,"line":319},14,[128,321,322],{"class":153},"        return",[128,324,325],{"class":153}," new",[128,327,328],{"class":160}," JsonResponse",[128,330,190],{"class":173},[128,332,333],{"class":160},"$this",[128,335,265],{"class":173},[128,337,338],{"class":199},"stores",[128,340,265],{"class":173},[128,342,343],{"class":220},"listForCustomer",[128,345,190],{"class":173},[128,347,348],{"class":199},"$customerId",[128,350,351],{"class":173},",",[128,353,354],{"class":199}," $criteria",[128,356,357],{"class":173},"));\n",[128,359,361],{"class":130,"line":360},15,[128,362,363],{"class":173},"    }\n",[128,365,367],{"class":130,"line":366},16,[128,368,369],{"class":173},"}\n",[16,371,372,373,376,377,380],{},"The request pipeline itself is an explicit, ordered middleware stack - error\nhandling, env validation, body parsing, routing, banned-request filtering,\n",[42,374,375],{},"authentication",", route verification, forced-action checks (e.g. mandatory\npassword change), ",[42,378,379],{},"authorization (ACL)",", then dispatch. Auth is pluggable\nthrough adapters (username\u002Fpassword against DB or LDAP, SSO via Google \u002F Azure\nAD, and JWT bearer-token validation for every subsequent request), with MFA\n(email OTP or TOTP) enforceable globally or per role.",[98,382,384],{"id":383},"middleware-a-thin-proxy-tier-slim-4","Middleware - a thin proxy tier (Slim 4)",[16,386,387,388,391],{},"It would have been simpler to let the SPA call the API directly. The proxy earns\nits place: it owns session and token validation, injects auth headers, handles\nCORS, and ",[42,389,390],{},"caches static lookups in Redis"," - so the API stays focused on\nbusiness logic and the frontend talks to exactly one front door. Backend ↔\nmiddleware traffic runs over an internal network, never the public edge.",[98,393,395],{"id":394},"frontend-a-layered-nuxt-4-vue-3-spa-vuetify-3","Frontend - a layered Nuxt 4 \u002F Vue 3 SPA (Vuetify 3)",[16,397,398,399,402,403,406,407,410],{},"Organised into layers (base, auth, administration, customers, …) with\nserver-side pagination, full i18n, and a ",[42,400,401],{},"database-driven ACL"," surfaced through\na single ",[125,404,405],{},"useAcl()"," composable. On login the API returns the user's allowed\nresource names; the frontend renders tabs, buttons and actions against that list\ninstead of scattering permission logic through dozens of ",[125,408,409],{},"v-if","s:",[118,412,416],{"className":413,"code":414,"language":415,"meta":123,"style":123},"language-ts shiki shiki-themes one-dark-pro","const { isAllowed } = useAcl()\n\u002F\u002F One source of truth for \"can this user see\u002Fdo this\", driven by backend ACL.\nconst canEditCustomer = computed(() => isAllowed('ui\u002FeditCustomer'))\n","ts",[125,417,418,441,446],{"__ignoreMap":123},[128,419,420,423,426,429,432,435,438],{"class":130,"line":131},[128,421,422],{"class":153},"const",[128,424,425],{"class":173}," { ",[128,427,428],{"class":160},"isAllowed",[128,430,431],{"class":173}," } ",[128,433,434],{"class":186},"=",[128,436,437],{"class":220}," useAcl",[128,439,440],{"class":173},"()\n",[128,442,443],{"class":130,"line":138},[128,444,445],{"class":134},"\u002F\u002F One source of truth for \"can this user see\u002Fdo this\", driven by backend ACL.\n",[128,447,448,450,453,455,458,461,464,467,469,472],{"class":130,"line":144},[128,449,422],{"class":153},[128,451,452],{"class":160}," canEditCustomer",[128,454,250],{"class":186},[128,456,457],{"class":220}," computed",[128,459,460],{"class":173},"(() ",[128,462,463],{"class":153},"=>",[128,465,466],{"class":220}," isAllowed",[128,468,190],{"class":173},[128,470,471],{"class":273},"'ui\u002FeditCustomer'",[128,473,474],{"class":173},"))\n",[98,476,478],{"id":477},"contract-a-generated-type-safe-sdk","Contract - a generated, type-safe SDK",[16,480,481,482,487],{},"The frontend never hand-writes API types; it consumes a TypeScript client\ngenerated from the backend's OpenAPI spec, so the two repositories cannot drift\napart. That pipeline is its own story →\n",[483,484,486],"a",{"href":485},"\u002Fcase-studies\u002Feliminating-api-schema-drift","Killing API schema drift",".",[11,489,491],{"id":490},"decisions-that-mattered-and-the-tradeoffs","Decisions that mattered (and the tradeoffs)",[47,493,494,500,506],{},[50,495,496,499],{},[42,497,498],{},"Modular monolith, not microservices."," A small team replacing four legacy\nsystems does not need a distributed-systems problem layered on top of a domain\nproblem. One deployable with hard internal boundaries (enforced by static\nanalysis) gives most of the modularity benefit and almost none of the\noperational tax. The boundaries are real; the network hops aren't. If a module\never needs to split out later, the seams already exist.",[50,501,502,505],{},[42,503,504],{},"A dedicated middleware tier."," The cost is an extra hop and another service\nto run. The payoff is one home for session handling, caching, and cross-cutting\nconcerns - and a seam where responses can be shaped or cached without touching\nbusiness logic in the API.",[50,507,508,511,512,515,516,519,520,523],{},[42,509,510],{},"Patterns over heroics for the legacy data."," The old database is full of\ntraps: polymorphic tables keyed by a type-discriminator column, ",[125,513,514],{},"0000-00-00","\nused as a \"not deleted\" sentinel, raw HTML stored in free-text fields. Rather\nthan let each developer rediscover these the hard way, I encoded them as\nreusable patterns - a shared polymorphic entity with per-domain services, a\n",[125,517,518],{},"SoftDeleteable"," interface, a centralized HTML purifier injected wherever\nuser text is stored, and a ",[125,521,522],{},"findOrFail()"," on the base repository so existence\nchecks are one consistent line instead of ten hand-rolled null checks. New\ndomains reuse them instead of re-solving them, which is what keeps five\ndevelopers writing code that looks like it came from one.",[11,525,527],{"id":526},"migrating-without-a-big-bang","Migrating without a big bang",[16,529,530,531,534,535,538],{},"There is ",[42,532,533],{},"no flip-the-switch cutover",", by design. The work is organised into\nphases, and ",[42,536,537],{},"each feature is independently deployable"," - it goes live when it\npasses QA and a legacy-parity check, not at the end of a phase. Each migration\nfollows the same disciplined flow: analyze the legacy implementation, rebuild the\nbackend (entity, repository, service, DTO, handlers, tests), regenerate the typed\nSDK, wire the middleware route, build the frontend, then verify against the\nlegacy system for parity. That keeps risk small and continuous instead of\nhoarding it all for one terrifying weekend.",[16,540,541,542,545,546,550],{},"It also means migration is a chance to ",[27,543,544],{},"question"," the old design, not just copy\nit. (One such moment - a query the legacy system had quietly been running for\nyears - turned into a ",[483,547,549],{"href":548},"\u002Fcase-studies\u002Foptimizing-a-32-join-query","660× optimization",".)",[11,552,554],{"id":553},"where-it-stands","Where it stands",[16,556,557,558,561,562,565],{},"I designed this platform from an empty repository and, as its primary developer,\nauthored roughly ",[42,559,560],{},"66% of all commits (2,846 across seven\nrepositories)",", working within a six-developer team. The new stack runs and is\nvalidated in a ",[42,563,564],{},"test environment",", with features rebuilt and signed off module\nby module against the legacy system; production rollout is phased.",[16,567,568],{},"The honest summary: this isn't a finished migration I'm taking a victory lap on -\nit's a large, in-flight modernization where I shaped the architecture, set the\npatterns, and wrote most of the code. The win I care about is structural: the\nreplacement is something a team can safely extend, instead of four systems\neveryone was afraid to touch.",[570,571,572],"style",{},"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 .sn6KH, html code.shiki .sn6KH{--shiki-default:#ABB2BF}html pre.shiki code .sjrmR, html code.shiki .sjrmR{--shiki-default:#56B6C2}html pre.shiki code .sVyAn, html code.shiki .sVyAn{--shiki-default:#E06C75}html pre.shiki code .sVbv2, html code.shiki .sVbv2{--shiki-default:#61AFEF}html pre.shiki code .subq3, html code.shiki .subq3{--shiki-default:#98C379}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);}",{"title":123,"searchDepth":138,"depth":138,"links":574},[575,576,577,578,584,585,586],{"id":13,"depth":138,"text":14},{"id":21,"depth":138,"text":22},{"id":36,"depth":138,"text":37},{"id":88,"depth":138,"text":89,"children":579},[580,581,582,583],{"id":100,"depth":144,"text":101},{"id":383,"depth":144,"text":384},{"id":394,"depth":144,"text":395},{"id":477,"depth":144,"text":478},{"id":490,"depth":138,"text":491},{"id":526,"depth":138,"text":527},{"id":553,"depth":138,"text":554},"A payments platform had grown into four separate legacy codebases nobody could safely change. I designed the modern replacement - a three-tier, type-safe, modular architecture - and, as its primary developer, am rebuilding it feature by feature, with no big-bang cutover.",false,"md","Payments \u002F Fintech",{},[593,596,599],{"value":594,"label":595},"66%","of all commits, authored personally (2,846 across seven repos)",{"value":597,"label":598},"4 → 1","legacy codebases consolidated into one modular platform",{"value":600,"label":601},"5","repositories designed and built end to end","\u002Fcase-studies\u002Freplatforming-a-legacy-payments-system","Senior Full-Stack Developer",{"title":6,"description":587},[606,607,608,609,610,611,612,613,614,615,616,617],"PHP 8.4","Mezzio \u002F Laminas","Doctrine ORM","Slim 4","Redis","Nuxt 4","Vue 3","Vuetify 3","TypeScript","OpenAPI","MariaDB","DDEV","case-studies\u002Freplatforming-a-legacy-payments-system",[620,621,622,623],"Architecture","Legacy Modernization","Fintech","Modular Monolith","2024–present","9M9kz2dstJCpDwB7Rs3JkbUCZcK66LqCFy9vsrLqGj4",{"id":627,"title":628,"body":629,"description":942,"draft":588,"extension":589,"industry":590,"meta":943,"metrics":944,"navigation":209,"order":138,"path":954,"role":603,"seo":955,"stack":956,"stem":967,"tags":968,"year":973,"__hash__":974},"caseStudies\u002Fcase-studies\u002Fai-development-infrastructure.md","Making AI safe for a team: skills, MCP servers, and hard gates",{"type":8,"value":630,"toc":932},[631,633,636,640,643,646,652,666,669,673,680,705,721,728,732,739,742,750,754,761,795,798,805,809,812,827,834,841,845,856,859,890,901,905,911,917,923,929],[11,632,14],{"id":13},[16,634,635],{},"I built the tooling that makes AI assistance safe and repeatable for a whole\nteam - executable skills, four MCP servers, and hooks that block the dangerous\ncommand rather than trusting anyone to avoid it - and packaged it as a plugin\nanyone installs with two commands.",[11,637,639],{"id":638},"the-problem","The problem",[16,641,642],{},"The migration meant moving business logic out of four legacy codebases into a\nnew stack with strict conventions. That work is a bad fit for a human on a\nTuesday afternoon: the logic for one feature is often split across a SOAP\nservice, a JSON-RPC webservice, a BFF layer and an AngularJS UI, and the\nconventions on the receiving end are unforgiving.",[16,644,645],{},"It is a good fit for an AI assistant, which can hold four legacy repositories in\nview at once and apply a convention consistently across hundreds of files. But\nthat only holds if two problems are solved first, and neither is solved by\nprompting.",[16,647,648,651],{},[42,649,650],{},"The output has to be trustworthy."," An assistant that produces plausible code\nwhich quietly violates the architecture is worse than no assistant, because the\nviolation arrives faster than review can catch it.",[16,653,654,657,658,661,662,665],{},[42,655,656],{},"The blast radius has to be bounded."," The moment you give a tool the ability to\nrun commands, you have given it the ability to run ",[125,659,660],{},"git push --force",", to drop a\ntable, or to \"fix\" a failing test by editing ",[125,663,664],{},"vendor\u002F",". One careless session can\nundo a week.",[16,667,668],{},"So I built the infrastructure before scaling the AI work out to the team.",[11,670,672],{"id":671},"guardrails-first","Guardrails first",[16,674,675,676,679],{},"The hooks came before anything else, because everything else is only safe once\nthey exist. A ",[125,677,678],{},"PreToolUse"," guard runs in every session, and the rules are\ncommand-pattern gated so each one fires only when relevant - a guard that\ninterrupts constantly gets disabled, which defeats the point.",[16,681,682,685,686,689,690,693,694,697,698,701,702,487],{},[125,683,684],{},"guard-bash.sh"," blocks nine classes of command: force-pushes and hard resets,\nblanket ",[125,687,688],{},"SKIP_PRECOMMIT",", committing pnpm-link artifacts, manual pushes (the\nsanctioned path is the ",[125,691,692],{},"publish"," skill), recursive style fixes across ",[125,695,696],{},"src\u002F",",\ndatabase-modifying SQL, and - my favourite - any ",[125,699,700],{},"SELECT"," without a ",[125,703,704],{},"LIMIT",[16,706,707,710,711,713,714,716,717,720],{},[125,708,709],{},"guard-files.sh"," blocks edits to ",[125,712,664],{},", and blocks adding ",[125,715,664],{}," to\n",[125,718,719],{},".gitignore",", which is the move an assistant reaches for when it wants the\nfirst block to go away.",[16,722,723,724,727],{},"The important property is that these are enforced ",[42,725,726],{},"outside the model",". They are\nnot instructions the assistant is asked to respect; they are a process that\nreturns a non-zero exit code. No amount of confident reasoning gets around them.",[11,729,731],{"id":730},"skills-the-workflow-as-an-executable-artifact","Skills: the workflow as an executable artifact",[16,733,734,735,738],{},"A skill is a workflow written down in a form the assistant executes rather than\nimprovises. The one that mattered most is ",[125,736,737],{},"migrate-feature",": seven phases and a\nroughly thirty-item checklist spanning three repositories, from legacy analysis\nthrough backend implementation, SDK regeneration, middleware routing, frontend\nwork, verification and publication.",[16,740,741],{},"Before it existed, every migration was a fresh negotiation about what \"done\"\nmeant. After it, the fifth migration runs like the first.",[16,743,744,745,749],{},"Thirteen more cover the rest of the cycle - endpoint and service scaffolding,\nPHP and Vue review rules, per-repo quality checks, the publish flow, and the JIRA\nlifecycle. They encode the same conventions the\n",[483,746,748],{"href":747},"\u002Fcase-studies\u002Fengineering-standards-and-tooling","static-analysis rules"," enforce,\nwhich means the assistant is aiming at the target the CI is measuring.",[11,751,753],{"id":752},"mcp-servers-giving-the-assistant-real-access-narrowly","MCP servers: giving the assistant real access, narrowly",[16,755,756,757,760],{},"Four Model Context Protocol servers, written from scratch in Node, exposing 43\ntools. The design principle throughout was to expose the ",[27,758,759],{},"specific"," capability\nrather than a general one, because a narrow tool cannot be misused the way a\nshell can.",[47,762,763,771,779,787],{},[50,764,765,770],{},[42,766,767],{},[125,768,769],{},"quality"," (15 tools) - PHPStan, Psalm, Deptrac, ECS, test runs,\nchanged files, commit, push, PR create\u002Fedit\u002Fdiff\u002Fcomment\u002Flist, JIRA transition.",[50,772,773,778],{},[42,774,775],{},[125,776,777],{},"workflow"," (16 tools) - migration checklists, phased context\nloading, cross-repository tracking, document validators.",[50,780,781,786],{},[42,782,783],{},[125,784,785],{},"database"," (6 tools) - schema exploration: connections, tables,\ndescribe, foreign keys, indexes, and a query tool that is read-only with an\nautomatic row limit.",[50,788,789,794],{},[42,790,791],{},[125,792,793],{},"logs"," (6 tools) - remote API and webserver logs over SSH: list,\ntail, grep, since, context, errors.",[16,796,797],{},"The database server is the clearest illustration. The assistant genuinely needs\nto inspect schemas - guessing at column names produces confidently wrong code.\nBut it does not need write access, and it does not need to pull a million rows\ninto context. So the tool grants exactly the useful half of the capability, and\nthe dangerous half is not reachable.",[16,799,800,801,804],{},"The workflow server is the unusual one, because it holds ",[42,802,803],{},"gates",". A migration\ncannot be marked publishable until its checklist items are genuinely satisfied.\nThe assistant cannot skip a step by asserting it was done, because the gate is\nstate held outside the conversation.",[11,806,808],{"id":807},"the-loop-that-makes-it-work","The loop that makes it work",[16,810,811],{},"The pieces compose into something none of them achieve alone:",[813,814,815,818,821,824],"ol",{},[50,816,817],{},"The assistant writes code, aiming at conventions encoded in a skill.",[50,819,820],{},"The pre-commit hook runs the same PHPStan, Psalm and Deptrac checks a human\ncommit faces.",[50,822,823],{},"A check fails. The assistant reads the actual error, fixes it, and retries.",[50,825,826],{},"Two or three iterations later, it converges.",[16,828,829,830,833],{},"This is the whole thesis in one loop. The assistant is not trusted to be correct;\nit is placed in a system where being incorrect is ",[27,831,832],{},"detected mechanically and\nimmediately",", and where the feedback is specific enough to act on. The quality\ngates I built for human developers turned out to be exactly the infrastructure\nthat makes machine output trustworthy - the same rules, doing double duty.",[16,835,836,837,840],{},"The ",[483,838,839],{"href":485},"generated SDK pipeline"," closes\nthe same loop across the language boundary: a PHP field change becomes a\nTypeScript compile error, so an assistant working on the frontend cannot drift\nfrom what the backend actually returns.",[11,842,844],{"id":843},"from-personal-setup-to-team-platform","From personal setup to team platform",[16,846,847,848,851,852,855],{},"For a while this was per-repository configuration, which meant it was really just\nmy setup. Claude Code loads configuration from your user config and the ",[27,849,850],{},"one","\nproject you launched in - repositories attached as extra working directories get\nfile access but not their ",[125,853,854],{},".claude\u002F"," config. During full-stack work, where a\nsingle ticket touches three repositories, a skill defined in the API repo was\nsimply invisible from the frontend.",[16,857,858],{},"I resolved it by packaging everything - skills, hooks, MCP servers - as a\nuser-level plugin with its own on-premise marketplace:",[118,860,864],{"className":861,"code":862,"language":863,"meta":123,"style":123},"language-bash shiki shiki-themes one-dark-pro","\u002Fplugin marketplace add git@github.example.com:your-org\u002Fdev-suite.git\n\u002Fplugin install dev-suite@dev-suite\n","bash",[125,865,866,880],{"__ignoreMap":123},[128,867,868,871,874,877],{"class":130,"line":131},[128,869,870],{"class":220},"\u002Fplugin",[128,872,873],{"class":273}," marketplace",[128,875,876],{"class":273}," add",[128,878,879],{"class":273}," git@github.example.com:your-org\u002Fdev-suite.git\n",[128,881,882,884,887],{"class":130,"line":138},[128,883,870],{"class":220},[128,885,886],{"class":273}," install",[128,888,889],{"class":273}," dev-suite@dev-suite\n",[16,891,892,893,896,897,900],{},"Two commands, works in every repository, versioned like any other dependency.\nThat packaging step is what turned it from ",[27,894,895],{},"my tooling"," into ",[27,898,899],{},"the team's\nplatform"," - the difference between a colleague admiring a setup and a colleague\nrunning it.",[11,902,904],{"id":903},"what-i-would-tell-someone-starting-this","What I would tell someone starting this",[16,906,907,910],{},[42,908,909],{},"Build the guardrails first."," They are what let you say yes to giving the whole\nteam these tools, rather than keeping them to the one person trusted to be\ncareful.",[16,912,913,916],{},[42,914,915],{},"Expose narrow tools, not broad ones."," Read-only with a row limit beats database\naccess plus a warning in the prompt, every time.",[16,918,919,922],{},[42,920,921],{},"Put the gates outside the model."," Anything enforced only by instruction is\nenforced only by luck.",[16,924,925,928],{},[42,926,927],{},"Your existing quality gates are the AI infrastructure."," The most useful thing I\nhad already built was the pre-commit toolchain. It is what makes the loop\nconverge - without it there is no signal, and the assistant is just producing\nconfident text.",[570,930,931],{},"html pre.shiki code .sVbv2, html code.shiki .sVbv2{--shiki-default:#61AFEF}html pre.shiki code .subq3, html code.shiki .subq3{--shiki-default:#98C379}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);}",{"title":123,"searchDepth":138,"depth":138,"links":933},[934,935,936,937,938,939,940,941],{"id":13,"depth":138,"text":14},{"id":638,"depth":138,"text":639},{"id":671,"depth":138,"text":672},{"id":730,"depth":138,"text":731},{"id":752,"depth":138,"text":753},{"id":807,"depth":138,"text":808},{"id":843,"depth":138,"text":844},{"id":903,"depth":138,"text":904},"AI assistance that is fast for one careful developer is a liability for a team of six. I built the infrastructure that makes it the other way round - executable workflow skills, four MCP servers, and guardrail hooks that block the destructive command before it runs - packaged as an installable plugin.",{},[945,948,951],{"value":946,"label":947},"43","tools exposed across 4 purpose-built MCP servers",{"value":949,"label":950},"14","executable workflow skills, installable in any repo",{"value":952,"label":953},"9","guardrail rules that block destructive commands before they run","\u002Fcase-studies\u002Fai-development-infrastructure",{"title":628,"description":942},[957,958,959,960,961,962,963,964,965,966],"Claude Code","Model Context Protocol","Node.js","Bash","PHPStan","Psalm","Deptrac","Doctrine DBAL","JIRA","GitHub","case-studies\u002Fai-development-infrastructure",[969,970,971,972],"AI-Augmented Dev","Developer Experience","Tooling","MCP","2026","3abIZ9fIYZ0C3jakmfc1jEzbXqaSbGalc4cUbw5cOa4",{"id":976,"title":977,"body":978,"description":1563,"draft":588,"extension":589,"industry":590,"meta":1564,"metrics":1565,"navigation":209,"order":144,"path":548,"role":603,"seo":1574,"stack":1575,"stem":1577,"tags":1578,"year":973,"__hash__":1582},"caseStudies\u002Fcase-studies\u002Foptimizing-a-32-join-query.md","From 23 seconds to 2: killing a 32-join view query",{"type":8,"value":979,"toc":1553},[980,982,989,991,1002,1013,1017,1023,1026,1073,1099,1107,1113,1174,1184,1191,1195,1202,1208,1324,1329,1375,1381,1387,1433,1437,1499,1502,1506,1547,1550],[11,981,14],{"id":13},[16,983,984,985,988],{},"A support page everyone used took 23 seconds to load. I traced it to a\nclean-named database view hiding 32 joins across ~3.9M rows - run ",[27,986,987],{},"twice"," per\nrequest just to get a list of IDs - and brought the page to ~2 seconds with\nsmarter querying and a one-line cache. No new infrastructure.",[11,990,639],{"id":638},[16,992,993,994,997,998,1001],{},"A support agent reported that the customer ",[42,995,996],{},"store-listing"," page was painfully\nslow. Locally it took ",[42,999,1000],{},"23.3 seconds"," - and this is a page support staff open\ndozens of times a day, on a platform handling real-time payment operations. \"Slow\"\nhere is a queue of agents waiting on a spinner.",[16,1003,1004,1005,1008,1009,1012],{},"The endpoint was a paginated list of a customer's stores, filtered by the\nlogged-in user's access restrictions. Two things about that sentence turned out to\nmatter: ",[27,1006,1007],{},"paginated"," (so it runs two queries - data + count) and ",[27,1010,1011],{},"filtered by\naccess restrictions"," (where the real cost was hiding).",[11,1014,1016],{"id":1015},"where-the-time-went","Where the time went",[16,1018,1019,1020],{},"Both the data query and the count query passed through the same access-control\nfilter, which layered two checks. One was cheap. The other called a method that\nasked a deceptively simple question: ",[27,1021,1022],{},"\"which customer IDs are visible to this\nuser, given their provider permissions?\"",[16,1024,1025],{},"The query behind it looked harmless:",[118,1027,1031],{"className":1028,"code":1029,"language":1030,"meta":123,"style":123},"language-sql shiki shiki-themes one-dark-pro","SELECT DISTINCT pm.retailerId\nFROM view_product_details_retailer_mapping pm\nWHERE 1 = 1;  -- user has global rights → no extra filter\n","sql",[125,1032,1033,1047,1055],{"__ignoreMap":123},[128,1034,1035,1038,1042,1044],{"class":130,"line":131},[128,1036,1037],{"class":153},"SELECT DISTINCT",[128,1039,1041],{"class":1040},"sVC51"," pm",[128,1043,487],{"class":173},[128,1045,1046],{"class":1040},"retailerId\n",[128,1048,1049,1052],{"class":130,"line":138},[128,1050,1051],{"class":153},"FROM",[128,1053,1054],{"class":173}," view_product_details_retailer_mapping pm\n",[128,1056,1057,1060,1063,1065,1067,1070],{"class":130,"line":144},[128,1058,1059],{"class":153},"WHERE",[128,1061,1062],{"class":1040}," 1",[128,1064,250],{"class":186},[128,1066,1062],{"class":1040},[128,1068,1069],{"class":173},";  ",[128,1071,1072],{"class":134},"-- user has global rights → no extra filter\n",[16,1074,1075,1076,1079,1080,1083,1084,1091,1092,1095,1096,487],{},"But ",[125,1077,1078],{},"view_product_details_retailer_mapping"," was not a table. It was a view built\non top of another view - ",[125,1081,1082],{},"view_product_details"," - which itself was ",[42,1085,1086,1087,1090],{},"32 ",[125,1088,1089],{},"LEFT JOIN","s",": products joined to providers, two currencies, countries, product type \u002F\nline \u002F brand \u002F category \u002F subcategory, validity, voucher and PIN types, ten\nattribute tables, assignment groups, divisions, customers… To answer \"give me a\nlist of distinct IDs,\" the database was materializing 32 joins across ",[42,1093,1094],{},"3.9\nmillion rows"," - and, because the page is paginated, doing it ",[42,1097,1098],{},"twice per\nrequest",[11,1100,1102,1103,1106],{"id":1101},"investigation-explain-told-the-whole-story","Investigation: ",[125,1104,1105],{},"EXPLAIN"," told the whole story",[16,1108,1109,1110,1112],{},"The fix started with proving where the cost was. ",[125,1111,1105],{}," on the two ways of\ngetting the same list of IDs:",[1114,1115,1116,1135],"table",{},[1117,1118,1119],"thead",{},[1120,1121,1122,1126,1129,1132],"tr",{},[1123,1124,1125],"th",{},"Query",[1123,1127,1128],{},"Plan rows",[1123,1130,1131],{},"Strategy",[1123,1133,1134],{},"Time",[1136,1137,1138,1156],"tbody",{},[1120,1139,1140,1147,1150,1153],{},[1141,1142,1143,1146],"td",{},[125,1144,1145],{},"DISTINCT"," through the 32-join view",[1141,1148,1149],{},"15",[1141,1151,1152],{},"full scan + temporary table",[1141,1154,1155],{},"~6.6s",[1120,1157,1158,1163,1166,1171],{},[1141,1159,1160,1162],{},[125,1161,1145],{}," on the base table",[1141,1164,1165],{},"1",[1141,1167,1168],{},[42,1169,1170],{},"using index for group-by",[1141,1172,1173],{},"~0.01s",[16,1175,1176,1177,1180,1181,1183],{},"Same 2,848 IDs. Same result. ",[42,1178,1179],{},"~660× difference"," - because the base-table query\nis fully answered by a composite index and never touches row data, while the view\nforces a full scan and a temporary table for the ",[125,1182,1145],{},". Run that twice per\nrequest, add the actual store queries and network overhead, and you land on 23\nseconds.",[16,1185,1186,1187,1190],{},"(A small but real detail: I was inspecting the view through a custom MCP database\nserver that let me query via the app's Doctrine connection - and found its output\nwas truncating ",[125,1188,1189],{},"SHOW CREATE VIEW"," at 80 characters, hiding the very joins I needed\nto see. Fixing that truncation is what unblocked the investigation. Tooling that\nlies to you costs more than slow tooling.)",[11,1192,1194],{"id":1193},"the-fix-branch-by-what-the-caller-actually-needs","The fix: branch by what the caller actually needs",[16,1196,1197,1198,1201],{},"The expensive query treated every user the same. But the ",[27,1199,1200],{},"restriction type","\ndetermines how much work is genuinely required, so I split it into three paths:",[16,1203,1204,1207],{},[42,1205,1206],{},"Path 1 - global rights (the common case): drop to DBAL on the base table.","\nMost users can see all providers, so the join was pure waste. For them, skip\nDoctrine's DQL and the view entirely and hit the indexed base table directly:",[118,1209,1211],{"className":120,"code":1210,"language":122,"meta":123,"style":123},"if ($this->providerRestrictions->hasGlobalRights()) {\n    $rows = $this->connection\n        ->executeQuery('SELECT DISTINCT id_customer FROM product_retailer_mapping')\n        ->fetchFirstColumn();\n\n    return $this->cachedIds = array_map(intval(...), $rows);\n}\n",[125,1212,1213,1235,1250,1276,1286,1290,1320],{"__ignoreMap":123},[128,1214,1215,1218,1220,1222,1224,1227,1229,1232],{"class":130,"line":131},[128,1216,1217],{"class":153},"if",[128,1219,253],{"class":173},[128,1221,333],{"class":160},[128,1223,265],{"class":173},[128,1225,1226],{"class":199},"providerRestrictions",[128,1228,265],{"class":173},[128,1230,1231],{"class":220},"hasGlobalRights",[128,1233,1234],{"class":173},"()) {\n",[128,1236,1237,1240,1242,1245,1247],{"class":130,"line":138},[128,1238,1239],{"class":199},"    $rows",[128,1241,250],{"class":186},[128,1243,1244],{"class":160}," $this",[128,1246,265],{"class":173},[128,1248,1249],{"class":199},"connection\n",[128,1251,1252,1255,1258,1260,1263,1265,1268,1270,1273],{"class":130,"line":144},[128,1253,1254],{"class":173},"        ->",[128,1256,1257],{"class":220},"executeQuery",[128,1259,190],{"class":173},[128,1261,1262],{"class":273},"'",[128,1264,1037],{"class":153},[128,1266,1267],{"class":273}," id_customer ",[128,1269,1051],{"class":153},[128,1271,1272],{"class":273}," product_retailer_mapping'",[128,1274,1275],{"class":173},")\n",[128,1277,1278,1280,1283],{"class":130,"line":150},[128,1279,1254],{"class":173},[128,1281,1282],{"class":220},"fetchFirstColumn",[128,1284,1285],{"class":173},"();\n",[128,1287,1288],{"class":130,"line":170},[128,1289,210],{"emptyLinePlaceholder":209},[128,1291,1292,1295,1297,1299,1302,1304,1307,1309,1312,1315,1318],{"class":130,"line":177},[128,1293,1294],{"class":153},"    return",[128,1296,1244],{"class":160},[128,1298,265],{"class":173},[128,1300,1301],{"class":199},"cachedIds",[128,1303,250],{"class":186},[128,1305,1306],{"class":186}," array_map",[128,1308,190],{"class":173},[128,1310,1311],{"class":186},"intval",[128,1313,1314],{"class":173},"(...),",[128,1316,1317],{"class":199}," $rows",[128,1319,277],{"class":173},[128,1321,1322],{"class":130,"line":206},[128,1323,369],{"class":173},[16,1325,1326],{},[42,1327,1328],{},"Path 2 - no rights: return nothing, for free.",[118,1330,1332],{"className":120,"code":1331,"language":122,"meta":123,"style":123},"if (!$this->providerRestrictions->hasRights()) {\n    return $this->cachedIds = [];\n}\n",[125,1333,1334,1356,1371],{"__ignoreMap":123},[128,1335,1336,1338,1340,1343,1345,1347,1349,1351,1354],{"class":130,"line":131},[128,1337,1217],{"class":153},[128,1339,253],{"class":173},[128,1341,1342],{"class":186},"!",[128,1344,333],{"class":160},[128,1346,265],{"class":173},[128,1348,1226],{"class":199},[128,1350,265],{"class":173},[128,1352,1353],{"class":220},"hasRights",[128,1355,1234],{"class":173},[128,1357,1358,1360,1362,1364,1366,1368],{"class":130,"line":138},[128,1359,1294],{"class":153},[128,1361,1244],{"class":160},[128,1363,265],{"class":173},[128,1365,1301],{"class":199},[128,1367,250],{"class":186},[128,1369,1370],{"class":173}," [];\n",[128,1372,1373],{"class":130,"line":144},[128,1374,369],{"class":173},[16,1376,1377,1380],{},[42,1378,1379],{},"Path 3 - specific restrictions (rare): keep the view."," Only the genuinely\nrestricted users fall back to the original DQL-on-view query - the one case that\nactually needs the joined data.",[16,1382,1383,1386],{},[42,1384,1385],{},"Then: a per-request cache, for free."," Because the page runs both a data and a\ncount query, the lookup happened twice. The repository instance is shared within a\nrequest by the DI container, so memoizing on a private property eliminates the\nsecond call with zero infrastructure:",[118,1388,1390],{"className":120,"code":1389,"language":122,"meta":123,"style":123},"if ($this->cachedIds !== null) {\n    return $this->cachedIds; \u002F\u002F second caller (the count query) pays nothing\n}\n",[125,1391,1392,1413,1429],{"__ignoreMap":123},[128,1393,1394,1396,1398,1400,1402,1404,1407,1410],{"class":130,"line":131},[128,1395,1217],{"class":153},[128,1397,253],{"class":173},[128,1399,333],{"class":160},[128,1401,265],{"class":173},[128,1403,1301],{"class":199},[128,1405,1406],{"class":186}," !==",[128,1408,1409],{"class":1040}," null",[128,1411,1412],{"class":173},") {\n",[128,1414,1415,1417,1419,1421,1423,1426],{"class":130,"line":138},[128,1416,1294],{"class":153},[128,1418,1244],{"class":160},[128,1420,265],{"class":173},[128,1422,1301],{"class":199},[128,1424,1425],{"class":173},"; ",[128,1427,1428],{"class":134},"\u002F\u002F second caller (the count query) pays nothing\n",[128,1430,1431],{"class":130,"line":144},[128,1432,369],{"class":173},[11,1434,1436],{"id":1435},"the-result","The result",[1114,1438,1439,1455],{},[1117,1440,1441],{},[1120,1442,1443,1446,1449,1452],{},[1123,1444,1445],{},"Metric",[1123,1447,1448],{},"Before",[1123,1450,1451],{},"After",[1123,1453,1454],{},"Improvement",[1136,1456,1457,1473,1487],{},[1120,1458,1459,1462,1465,1468],{},[1141,1460,1461],{},"Page load (local)",[1141,1463,1464],{},"23.3s",[1141,1466,1467],{},"2.3s",[1141,1469,1470],{},[42,1471,1472],{},"~10×",[1120,1474,1475,1478,1481,1484],{},[1141,1476,1477],{},"Expensive view queries \u002F req",[1141,1479,1480],{},"2",[1141,1482,1483],{},"0",[1141,1485,1486],{},"eliminated",[1120,1488,1489,1492,1494,1496],{},[1141,1490,1491],{},"DB plan rows",[1141,1493,1149],{},[1141,1495,1165],{},[1141,1497,1498],{},"15× simpler",[16,1500,1501],{},"No new dependencies. No schema changes. No Redis, no hardware. Just not doing 32\njoins when you only need a list of IDs.",[11,1503,1505],{"id":1504},"why-this-is-an-architecture-story-not-just-a-sql-story","Why this is an architecture story, not just a SQL story",[47,1507,1508,1521,1531,1537],{},[50,1509,1510,1513,1514,1517,1518,487],{},[42,1511,1512],{},"The legacy system had the exact same query."," It was simply masked by fewer\nconcurrent users and different caching. When you migrate a feature, replicating\nthe old behavior faithfully also replicates its mistakes - so migration has to\nmean ",[27,1515,1516],{},"questioning"," the old design, not just porting it. That mindset is built\ninto how I run the ",[483,1519,1520],{"href":602},"whole re-platforming",[50,1522,1523,1526,1527,1530],{},[42,1524,1525],{},"Views hide complexity behind a friendly name."," ",[125,1528,1529],{},"view_product_details_…","\nreads as harmless in a query. Always check what a view actually expands to\nbefore you build on it.",[50,1532,1533,1536],{},[42,1534,1535],{},"The ORM doesn't know about your indexes."," DQL is the right default; when a\nhot path needs an index the ORM won't reach, drop to DBAL for that path and keep\nDQL for the complex-but-rare cases.",[50,1538,1539,1542,1543,1546],{},[42,1540,1541],{},"The cheapest cache is the one you don't have to operate."," A ",[125,1544,1545],{},"private ?array","\nkilled half the cost before any infrastructure was involved.",[16,1548,1549],{},"Performance work isn't always about adding caches or scaling hardware. Often it's\nabout removing work that never needed to happen.",[570,1551,1552],{},"html pre.shiki code .seHd6, html code.shiki .seHd6{--shiki-default:#C678DD}html pre.shiki code .sVC51, html code.shiki .sVC51{--shiki-default:#D19A66}html pre.shiki code .sn6KH, html code.shiki .sn6KH{--shiki-default:#ABB2BF}html pre.shiki code .sjrmR, html code.shiki .sjrmR{--shiki-default:#56B6C2}html pre.shiki code .sV9Aq, html code.shiki .sV9Aq{--shiki-default:#7F848E;--shiki-default-font-style:italic}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 .sU0A5, html code.shiki .sU0A5{--shiki-default:#E5C07B}html pre.shiki code .sVyAn, html code.shiki .sVyAn{--shiki-default:#E06C75}html pre.shiki code .sVbv2, html code.shiki .sVbv2{--shiki-default:#61AFEF}html pre.shiki code .subq3, html code.shiki .subq3{--shiki-default:#98C379}",{"title":123,"searchDepth":138,"depth":138,"links":1554},[1555,1556,1557,1558,1560,1561,1562],{"id":13,"depth":138,"text":14},{"id":638,"depth":138,"text":639},{"id":1015,"depth":138,"text":1016},{"id":1101,"depth":138,"text":1559},"Investigation: EXPLAIN told the whole story",{"id":1193,"depth":138,"text":1194},{"id":1435,"depth":138,"text":1436},{"id":1504,"depth":138,"text":1505},"A store-listing page took 23 seconds to load. The culprit was an innocent-looking database view hiding 32 joins across 3.9 million rows - queried twice per request, just to fetch a list of IDs. Here is how I found it and made it 10× faster with no new infrastructure.",{},[1566,1569,1572],{"value":1567,"label":1568},"10×","faster page load (23.3s → 2.3s)",{"value":1570,"label":1571},"660×","faster on the core access-control query",{"value":1483,"label":1573},"new infrastructure, dependencies, or schema changes",{"title":977,"description":1563},[606,608,964,616,1576,1105],"SQL","case-studies\u002Foptimizing-a-32-join-query",[1579,1576,1580,1581],"Performance","Doctrine","Database","A5v28MDnn1-UmkwyC7z7LwUj6kNFTsvURJfqfRIb3l4",{"id":1584,"title":1585,"body":1586,"description":1772,"draft":588,"extension":589,"industry":590,"meta":1773,"metrics":1774,"navigation":209,"order":150,"path":1783,"role":603,"seo":1784,"stack":1785,"stem":1791,"tags":1792,"year":973,"__hash__":1795},"caseStudies\u002Fcase-studies\u002Fdocumenting-a-system-nobody-understood.md","Documenting a system nobody fully understood",{"type":8,"value":1587,"toc":1760},[1588,1590,1593,1595,1598,1605,1608,1612,1615,1619,1626,1687,1693,1697,1700,1704,1711,1715,1718,1722,1728,1734,1740,1742,1748,1754],[11,1589,14],{"id":13},[16,1591,1592],{},"I turned a legacy platform that lived only in the heads of whoever had touched it\nlast into a 138-page reference that mapped every legacy action to its\nreplacement, so the migration was archaeology done once and written down, not\nre-discovered by every developer who touched a module.",[11,1594,639],{"id":638},[16,1596,1597],{},"The legacy platform had effectively no documentation. What existed was a handful\nof stale wiki pages, scattered and contradictory, plus tribal knowledge that left\nthe building whenever someone did. The business logic for a single feature was\nroutinely spread across four codebases: a first-generation SOAP webservice, a\nsecond-generation JSON-RPC service, an aging frontend, and a separate UI app.",[16,1599,1600,1601,1604],{},"That is a bad place to start a migration from. Every module a developer picked up\nbegan the same way: hours of grep-driven archaeology across four repositories to\nanswer one question - ",[27,1602,1603],{},"what does this actually do today, and where?"," The same\nexcavation happened again the next time someone touched an adjacent feature,\nbecause the answer was never written down.",[16,1606,1607],{},"The migration could not be estimated, sequenced, or parallelised while the ground\ntruth lived only in code nobody wanted to read.",[11,1609,1611],{"id":1610},"what-i-built","What I built",[16,1613,1614],{},"A documentation portal (Nuxt Content \u002F Docus, Mermaid diagrams), sole author,\nthat grew to 138 pages across five sections: general reference and glossary,\nbackend, middleware, frontend, and the migration itself. It was not a wiki of\nprose. It was structured to answer the questions the migration actually asked.",[98,1616,1618],{"id":1617},"_1-a-gap-analysis-that-mapped-the-whole-legacy-surface","1. A gap analysis that mapped the whole legacy surface",[16,1620,1621,1622,1625],{},"The centrepiece. A domain-by-domain comparison of legacy versus new, covering all\n14 feature domains, that quantified the entire legacy surface: roughly ",[42,1623,1624],{},"820\nlegacy actions",", against which the new stack's endpoints were tracked. For every\nfeature it recorded the exact legacy source and its new-stack replacement:",[1114,1627,1628,1641],{},[1117,1629,1630],{},[1120,1631,1632,1635,1638],{},[1123,1633,1634],{},"Feature",[1123,1636,1637],{},"Legacy source",[1123,1639,1640],{},"New stack",[1136,1642,1643,1657,1670],{},[1120,1644,1645,1648,1654],{},[1141,1646,1647],{},"Login \u002F logout",[1141,1649,1650,1651],{},"V2 ",[125,1652,1653],{},"Auth\u002FAcl",[1141,1655,1656],{},"Auth module, 11 endpoints",[1120,1658,1659,1662,1667],{},[1141,1660,1661],{},"MFA (TOTP, email OTP)",[1141,1663,1650,1664],{},[125,1665,1666],{},"Acl.activateMultiFactorAuthentication",[1141,1668,1669],{},"dedicated endpoints",[1120,1671,1672,1675,1684],{},[1141,1673,1674],{},"Password reset",[1141,1676,1650,1677,1680,1681],{},[125,1678,1679],{},"Acl.forgotPassword"," \u002F ",[125,1682,1683],{},"resetPassword",[1141,1685,1686],{},"3 endpoints",[16,1688,1689,1690],{},"Multiply that across 14 domains and hundreds of actions. The result answered, on\none page, the question every developer had been re-deriving by hand: ",[27,1691,1692],{},"this is what\nexists, this is where it lives, this is what replaces it, and this is how much is\nleft.",[98,1694,1696],{"id":1695},"_2-a-live-migration-tracker","2. A live migration tracker",[16,1698,1699],{},"Every feature across API, middleware and frontend, with a status legend\n(complete \u002F in progress \u002F documented \u002F not started \u002F partial \u002F skipped) so anyone\ncould see the true state of the migration without asking. Phases were planned\nagainst it; ownership was assigned against it.",[98,1701,1703],{"id":1702},"_3-a-migration-document-per-module","3. A migration document per module",[16,1705,1706,1707,1710],{},"Each module got its own document written ",[27,1708,1709],{},"before"," implementation began: what was\nbeing extracted, from which legacy sources, into which new module boundary, with\nthe file-by-file breakdown of the new structure. The document was the plan and,\nafterwards, the record.",[98,1712,1714],{"id":1713},"_4-the-reference-layer","4. The reference layer",[16,1716,1717],{},"A 70-entry glossary that pinned down the domain vocabulary (the same term meant\ndifferent things in different legacy apps), plus architecture, tooling and\nconvention pages, so a decision made once did not get relitigated in every review.",[11,1719,1721],{"id":1720},"why-it-mattered","Why it mattered",[16,1723,1724,1727],{},[42,1725,1726],{},"It changed the unit of migration work from \"excavate, then build\" to just\n\"build.\""," The most expensive part of each migration - reconstructing what the\nlegacy system did and where - was done once, by me, and written down. Every\nsubsequent developer, and every AI-assisted session, started from the map instead\nof the grep.",[16,1729,1730,1733],{},[42,1731,1732],{},"It made the migration estimable and parallelisable."," You cannot sequence work\nyou cannot see. The gap analysis turned \"the legacy system is a black box\" into\n\"627 of 820 actions remain, here are the hard ones, here is the order\" - which is\nthe difference between a plan and a hope.",[16,1735,1736,1739],{},[42,1737,1738],{},"It survived its author."," The whole point of writing down where every piece of\nlegacy logic lives is that the knowledge stops walking out of the door. The\nplatform went from \"ask the person who last touched it\" to \"read the docs\" for\nevery future hire.",[11,1741,904],{"id":903},[16,1743,1744,1747],{},[42,1745,1746],{},"Document the questions, not the code."," Nobody reads a wiki that mirrors the\nsource. They read the one page that answers \"where does this live and what\nreplaces it,\" so build the reference around the decisions people actually make.",[16,1749,1750,1753],{},[42,1751,1752],{},"The map is worth more than the territory when the territory is four codebases.","\nThe single highest-leverage artefact was not any module's documentation; it was\nthe one table that reconciled four legacy sources into one view. Build that first.",[16,1755,1756,1759],{},[42,1757,1758],{},"Write the migration doc before the migration."," A plan written down is a plan\nthat can be reviewed, estimated, and handed off. Written afterwards, it is just a\nchangelog.",{"title":123,"searchDepth":138,"depth":138,"links":1761},[1762,1763,1764,1770,1771],{"id":13,"depth":138,"text":14},{"id":638,"depth":138,"text":639},{"id":1610,"depth":138,"text":1611,"children":1765},[1766,1767,1768,1769],{"id":1617,"depth":144,"text":1618},{"id":1695,"depth":144,"text":1696},{"id":1702,"depth":144,"text":1703},{"id":1713,"depth":144,"text":1714},{"id":1720,"depth":138,"text":1721},{"id":903,"depth":138,"text":904},"A legacy platform with no documentation and business logic split across four codebases. I reverse-engineered it into a 138-page reference that mapped roughly 820 legacy actions to their replacements, so the migration ran against a map instead of guesswork.",{},[1775,1778,1781],{"value":1776,"label":1777},"138","pages, sole author, from scattered wiki notes",{"value":1779,"label":1780},"~820","legacy actions mapped to their replacements",{"value":597,"label":1782},"source codebases reconciled into one reference","\u002Fcase-studies\u002Fdocumenting-a-system-nobody-understood",{"title":1585,"description":1772},[1786,1787,1788,1789,1790],"Nuxt Content","Docus","Markdown","Mermaid","Information Architecture","case-studies\u002Fdocumenting-a-system-nobody-understood",[1793,1794,970,1790],"Documentation","Migration","ZIhnag5mh_aav7AunRwNRiN5sOG2_IApd-IaeIGyxpg",{"id":1797,"title":1798,"body":1799,"description":2296,"draft":588,"extension":589,"industry":590,"meta":2297,"metrics":2298,"navigation":209,"order":170,"path":485,"role":603,"seo":2306,"stack":2307,"stem":2310,"tags":2311,"year":624,"__hash__":2315},"caseStudies\u002Fcase-studies\u002Feliminating-api-schema-drift.md","Killing API schema drift with a generated, type-safe client",{"type":8,"value":1800,"toc":2283},[1801,1803,1806,1808,1815,1818,1822,1837,1840,1842,1848,1852,1862,2011,2026,2030,2040,2044,2050,2209,2213,2220,2224,2240,2244,2268,2275,2280],[11,1802,14],{"id":13},[16,1804,1805],{},"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.",[11,1807,639],{"id":638},[16,1809,1810,1811,1814],{},"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 ",[125,1812,1813],{},"undefined","?\"",[16,1816,1817],{},"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.",[11,1819,1821],{"id":1820},"the-insight","The insight",[16,1823,1824,1825,1828,1829,1832,1833,1836],{},"The backend already knew the exact shape of every endpoint. Handlers are\nannotated with OpenAPI attributes, and a generator turns those into an\n",[125,1826,1827],{},"openapi.yml"," spec. The spec ",[27,1830,1831],{},"was"," an accurate contract. The problem was that\nnothing ",[42,1834,1835],{},"forced"," the frontend to stay aligned with it - alignment was a manual,\nhuman, forgettable step.",[16,1838,1839],{},"So I removed the human from the loop and made the contract flow downhill,\nautomatically, from one source.",[11,1841,1611],{"id":1610},[16,1843,52,1844,1847],{},[42,1845,1846],{},"code-first OpenAPI pipeline",", end to end.",[98,1849,1851],{"id":1850},"_1-the-spec-is-generated-from-the-code-that-serves-the-request","1. The spec is generated from the code that serves the request",[16,1853,1854,1855,1858,1859,1861],{},"Handlers carry OpenAPI attributes; a command (",[125,1856,1857],{},"zircote\u002Fswagger-php",") scans them\nand emits ",[125,1860,1827],{},". The spec is a build output, not a document someone\nmaintains by hand and forgets to update.",[118,1863,1865],{"className":120,"code":1864,"language":122,"meta":123,"style":123},"#[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",[125,1866,1867,1872,1883,1897,1908,1913,1927,1937,1947,1961,1971,1991,1996,2001,2006],{"__ignoreMap":123},[128,1868,1869],{"class":130,"line":131},[128,1870,1871],{"class":173},"#[OA\\Get(\n",[128,1873,1874,1877,1880],{"class":130,"line":138},[128,1875,1876],{"class":173},"    path: ",[128,1878,1879],{"class":273},"'\u002Fcustomers'",[128,1881,1882],{"class":173},",\n",[128,1884,1885,1888,1891,1894],{"class":130,"line":144},[128,1886,1887],{"class":173},"    operationId: ",[128,1889,1890],{"class":273},"'fetchAllCustomer'",[128,1892,1893],{"class":173},", ",[128,1895,1896],{"class":134},"\u002F\u002F ← becomes the SDK function name\n",[128,1898,1899,1902,1905],{"class":130,"line":150},[128,1900,1901],{"class":173},"    tags: [",[128,1903,1904],{"class":273},"'Customers'",[128,1906,1907],{"class":173},"],\n",[128,1909,1910],{"class":130,"line":170},[128,1911,1912],{"class":173},"    responses: [\n",[128,1914,1915,1918,1921,1924],{"class":130,"line":177},[128,1916,1917],{"class":153},"        new",[128,1919,1920],{"class":173}," OA\\",[128,1922,1923],{"class":160},"Response",[128,1925,1926],{"class":173},"(\n",[128,1928,1929,1932,1935],{"class":130,"line":206},[128,1930,1931],{"class":173},"            response:",[128,1933,1934],{"class":1040}," 200",[128,1936,1882],{"class":173},[128,1938,1939,1942,1945],{"class":130,"line":213},[128,1940,1941],{"class":173},"            description:",[128,1943,1944],{"class":273}," 'OK'",[128,1946,1882],{"class":173},[128,1948,1949,1952,1954,1956,1959],{"class":130,"line":238},[128,1950,1951],{"class":173},"            content:",[128,1953,325],{"class":153},[128,1955,1920],{"class":173},[128,1957,1958],{"class":160},"JsonContent",[128,1960,1926],{"class":173},[128,1962,1963,1966,1969],{"class":130,"line":244},[128,1964,1965],{"class":173},"                type:",[128,1967,1968],{"class":273}," 'array'",[128,1970,1882],{"class":173},[128,1972,1973,1976,1978,1980,1983,1986,1989],{"class":130,"line":280},[128,1974,1975],{"class":173},"                items:",[128,1977,325],{"class":153},[128,1979,1920],{"class":173},[128,1981,1982],{"class":160},"Items",[128,1984,1985],{"class":173},"(ref:",[128,1987,1988],{"class":273}," '#\u002Fcomponents\u002Fschemas\u002FCustomer'",[128,1990,1275],{"class":173},[128,1992,1993],{"class":130,"line":308},[128,1994,1995],{"class":173},"            )\n",[128,1997,1998],{"class":130,"line":313},[128,1999,2000],{"class":173},"        ),\n",[128,2002,2003],{"class":130,"line":319},[128,2004,2005],{"class":173},"    ],\n",[128,2007,2008],{"class":130,"line":360},[128,2009,2010],{"class":173},")]\n",[16,2012,2013,2014,2017,2018,2021,2022,2025],{},"That ",[125,2015,2016],{},"operationId"," is not a detail - it's the contract for the generated client.\nWith it you get a clean ",[125,2019,2020],{},"fetchAllCustomer()","; without it the generator emits\nhash-based garbage like ",[125,2023,2024],{},"getA3f8b2c1()",". Which is exactly why I didn't leave it to\ndiscipline (see below).",[98,2027,2029],{"id":2028},"_2-the-contract-is-linted","2. The contract is linted",[16,2031,2032,2033,2036,2037,2039],{},"The spec is checked with ",[42,2034,2035],{},"Spectral"," against a custom ruleset before it's\nallowed downstream - every operation must have an ",[125,2038,2016],{},", schemas must\nlive on DTOs (not entities), responses must be typed. The contract stays\nmachine-consistent instead of slowly rotting.",[98,2041,2043],{"id":2042},"_3-the-spec-compiles-to-a-typed-sdk","3. The spec compiles to a typed SDK",[16,2045,2046,2049],{},[125,2047,2048],{},"@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:",[118,2051,2053],{"className":413,"code":2052,"language":415,"meta":123,"style":123},"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",[125,2054,2055,2086,2090,2095,2100,2105,2127,2166,2171],{"__ignoreMap":123},[128,2056,2057,2060,2062,2065,2067,2070,2072,2075,2078,2080,2083],{"class":130,"line":131},[128,2058,2059],{"class":153},"import",[128,2061,425],{"class":173},[128,2063,2064],{"class":199},"fetchAllCustomer",[128,2066,1893],{"class":173},[128,2068,2069],{"class":199},"findCustomerById",[128,2071,1893],{"class":173},[128,2073,2074],{"class":153},"type",[128,2076,2077],{"class":199}," Customer",[128,2079,431],{"class":173},[128,2081,2082],{"class":153},"from",[128,2084,2085],{"class":273}," '@org\u002Fapi-client'\n",[128,2087,2088],{"class":130,"line":138},[128,2089,210],{"emptyLinePlaceholder":209},[128,2091,2092],{"class":130,"line":144},[128,2093,2094],{"class":134},"\u002F\u002F Fully typed: params, query shape, and the Customer return type all come\n",[128,2096,2097],{"class":130,"line":150},[128,2098,2099],{"class":134},"\u002F\u002F from the backend's OpenAPI spec. Rename a field server-side and this stops\n",[128,2101,2102],{"class":130,"line":170},[128,2103,2104],{"class":134},"\u002F\u002F compiling.\n",[128,2106,2107,2109,2111,2114,2116,2118,2121,2124],{"class":130,"line":177},[128,2108,422],{"class":153},[128,2110,425],{"class":173},[128,2112,2113],{"class":160},"data",[128,2115,431],{"class":173},[128,2117,434],{"class":186},[128,2119,2120],{"class":153}," await",[128,2122,2123],{"class":220}," fetchAllCustomer",[128,2125,2126],{"class":173},"({\n",[128,2128,2129,2132,2135,2138,2141,2143,2145,2148,2150,2153,2155,2158,2160,2163],{"class":130,"line":206},[128,2130,2131],{"class":199},"  query",[128,2133,2134],{"class":173},": { ",[128,2136,2137],{"class":199},"pageIndex",[128,2139,2140],{"class":173},": ",[128,2142,1165],{"class":1040},[128,2144,1893],{"class":173},[128,2146,2147],{"class":199},"resultsPerPage",[128,2149,2140],{"class":173},[128,2151,2152],{"class":1040},"10",[128,2154,1893],{"class":173},[128,2156,2157],{"class":199},"search",[128,2159,2140],{"class":173},[128,2161,2162],{"class":273},"'acme'",[128,2164,2165],{"class":173}," },\n",[128,2167,2168],{"class":130,"line":213},[128,2169,2170],{"class":173},"})\n",[128,2172,2173,2175,2178,2180,2183,2185,2187,2190,2193,2196,2198,2201,2203,2206],{"class":130,"line":238},[128,2174,422],{"class":153},[128,2176,2177],{"class":160}," customer",[128,2179,2140],{"class":173},[128,2181,2182],{"class":160},"Customer",[128,2184,250],{"class":186},[128,2186,2120],{"class":153},[128,2188,2189],{"class":220}," findCustomerById",[128,2191,2192],{"class":173},"({ ",[128,2194,2195],{"class":199},"path",[128,2197,2134],{"class":173},[128,2199,2200],{"class":199},"id",[128,2202,2140],{"class":173},[128,2204,2205],{"class":1040},"123",[128,2207,2208],{"class":173}," } })\n",[98,2210,2212],{"id":2211},"_4-publishing-is-automated-on-change","4. Publishing is automated on change",[16,2214,2215,2216,2219],{},"A GitHub Actions pipeline regenerates the client and ",[42,2217,2218],{},"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.",[11,2221,2223],{"id":2222},"closing-the-last-gap-enforcing-the-rule-that-makes-it-work","Closing the last gap: enforcing the rule that makes it work",[16,2225,2226,2227,2229,2230,2233,2234,2236,2237,550],{},"The whole pipeline hinges on every endpoint having a correct ",[125,2228,2016],{}," - a\nthing humans forget. So rather than rely on review to catch it, I made it a\n",[42,2231,2232],{},"custom static-analysis rule"," in the backend: a handler without a proper\n",[125,2235,2016],{}," fails CI. The convention that the automation depends on is itself\nautomated. (More on that approach in\n",[483,2238,2239],{"href":747},"Making architecture enforce itself",[11,2241,2243],{"id":2242},"the-payoff","The payoff",[47,2245,2246,2256,2262],{},[50,2247,2248,2251,2252,2255],{},[42,2249,2250],{},"Drift becomes structurally impossible."," The frontend's types ",[27,2253,2254],{},"are"," the\nbackend's contract, so it cannot silently fall behind.",[50,2257,2258,2261],{},[42,2259,2260],{},"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.",[50,2263,2264,2267],{},[42,2265,2266],{},"The boring, error-prone work disappeared."," The frontend stopped hand-writing\nand babysitting request types and consumed the generated SDK instead. An entire\ncategory of bug - the API returns X, the frontend expects Y - stopped being\npossible, because the types are generated from the same spec the API serves.",[16,2269,2270,2271],{},"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",[483,2272,2274],{"href":2273},"\u002Fblog\u002Fauto-generating-typescript-api-client","Auto-generating a TypeScript API client",[47,2276,2277],{},[50,2278,2279],{},"this is what it looks like wired into a real platform, with the linting and CI\nthat make it trustworthy.",[570,2281,2282],{},"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":123,"searchDepth":138,"depth":138,"links":2284},[2285,2286,2287,2288,2294,2295],{"id":13,"depth":138,"text":14},{"id":638,"depth":138,"text":639},{"id":1820,"depth":138,"text":1821},{"id":1610,"depth":138,"text":1611,"children":2289},[2290,2291,2292,2293],{"id":1850,"depth":144,"text":1851},{"id":2028,"depth":144,"text":2029},{"id":2042,"depth":144,"text":2043},{"id":2211,"depth":144,"text":2212},{"id":2222,"depth":138,"text":2223},{"id":2242,"depth":138,"text":2243},"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.",{},[2299,2301,2303],{"value":1165,"label":2300},"source of truth - the OpenAPI spec, generated from code",{"value":1483,"label":2302},"hand-written API types on the frontend",{"value":2304,"label":2305},"Auto","SDK regenerated, versioned & published on every change",{"title":1798,"description":2296},[615,614,2048,1857,2035,2308,606,2309],"GitHub Actions","Mezzio","case-studies\u002Feliminating-api-schema-drift",[970,2312,2313,2314],"API Design","Automation","CI\u002FCD","f7GFakWmyJjPCbVA_cEEgB-BhPRTy90b9nq5TiWgwos",{"id":2317,"title":2318,"body":2319,"description":2604,"draft":588,"extension":589,"industry":590,"meta":2605,"metrics":2606,"navigation":209,"order":177,"path":747,"role":603,"seo":2616,"stack":2617,"stem":2618,"tags":2619,"year":624,"__hash__":2622},"caseStudies\u002Fcase-studies\u002Fengineering-standards-and-tooling.md","Making architecture enforce itself: standards, tests, and AI-augmented delivery",{"type":8,"value":2320,"toc":2595},[2321,2323,2326,2328,2335,2338,2342,2345,2479,2483,2506,2510,2520,2531,2535,2562,2570,2577,2581,2589,2592],[11,2322,14],{"id":13},[16,2324,2325],{},"I built the guardrails - custom static-analysis rules, layer enforcement, a\n2,300+ test suite, a one-command environment, and an AI workflow that encodes the\narchitecture - so that a six-developer team stays on a clean design they'd never\nworked in, without a senior policing every pull request.",[11,2327,639],{"id":638},[16,2329,2330,2331,2334],{},"Designing a clean architecture is the easy half. The hard half is keeping a\nsix-developer team ",[27,2332,2333],{},"on"," it - on a stack (Mezzio, Doctrine, Nuxt 4, a generated\nSDK) none of them had worked in before, while migrating features out of four\ntangled legacy systems whose every shortcut is right there to copy.",[16,2336,2337],{},"Without a system around the code, three things go wrong fast: decisions lose\ntheir memory, \"quality\" becomes whatever a reviewer happens to notice, and the\narchitecture erodes one shortcut at a time. So I built the guardrails before the\nwork scaled out - to make the right way the path of least resistance, not a thing\nsomeone has to police.",[11,2339,2341],{"id":2340},"standards-that-enforce-themselves","Standards that enforce themselves",[16,2343,2344],{},"I refused to rely on humans remembering rules. The rules are executable.",[47,2346,2347,2361,2390,2457,2465],{},[50,2348,2349,2352,2353,2356,2357,2360],{},[42,2350,2351],{},"Static analysis at the gate."," PHPStan (level 5) and Psalm run in CI and in\npre-commit hooks. ",[42,2354,2355],{},"ECS"," and ",[42,2358,2359],{},"Rector"," handle style and PHP modernization\nautomatically, so nobody argues about formatting in review.",[50,2362,2363,2366,2367,2370,2371,2374,2375],{},[42,2364,2365],{},"Custom architecture rules."," Beyond the off-the-shelf checks, I wrote\n",[42,2368,2369],{},"custom PHPStan and Rector rules"," that enforce ",[27,2372,2373],{},"this platform's"," conventions",[47,2376,2377],{},[50,2378,2379,2380,2382,2383,2385,2386,2389],{},"handlers must be complete, classes ",[125,2381,154],{}," by default, endpoints must carry\nthe API annotations (including the ",[125,2384,2016],{}," the\n",[483,2387,2388],{"href":485},"SDK pipeline"," depends on). The\narchitecture polices itself instead of depending on me catching violations in\nreview.",[50,2391,2392,1526,2395,2397,2398],{},[42,2393,2394],{},"Layer boundaries, enforced.",[42,2396,963],{}," asserts the dependency direction so\na Repository can never reach up into a Handler. The modular structure I designed\ncan't quietly collapse:",[118,2399,2403],{"className":2400,"code":2401,"language":2402,"meta":123,"style":123},"language-yaml shiki shiki-themes one-dark-pro","# Deptrac: dependencies may only point downward through the layers\nruleset:\n  Handler: [Service]\n  Service: [Repository]\n  Repository: ~ # depends on nothing above it\n","yaml",[125,2404,2405,2410,2418,2432,2444],{"__ignoreMap":123},[128,2406,2407],{"class":130,"line":131},[128,2408,2409],{"class":134},"# Deptrac: dependencies may only point downward through the layers\n",[128,2411,2412,2415],{"class":130,"line":138},[128,2413,2414],{"class":199},"ruleset",[128,2416,2417],{"class":173},":\n",[128,2419,2420,2423,2426,2429],{"class":130,"line":144},[128,2421,2422],{"class":199},"  Handler",[128,2424,2425],{"class":173},": [",[128,2427,2428],{"class":273},"Service",[128,2430,2431],{"class":173},"]\n",[128,2433,2434,2437,2439,2442],{"class":130,"line":150},[128,2435,2436],{"class":199},"  Service",[128,2438,2425],{"class":173},[128,2440,2441],{"class":273},"Repository",[128,2443,2431],{"class":173},[128,2445,2446,2449,2451,2454],{"class":130,"line":170},[128,2447,2448],{"class":199},"  Repository",[128,2450,2140],{"class":173},[128,2452,2453],{"class":1040},"~",[128,2455,2456],{"class":134}," # depends on nothing above it\n",[50,2458,2459,1526,2462,2464],{},[42,2460,2461],{},"A linted API contract.",[42,2463,2035],{}," validates the OpenAPI spec against a\ncustom ruleset before it can generate a client.",[50,2466,2467,2470,2471,2474,2475,2478],{},[42,2468,2469],{},"A quality gate on top."," Conventional-commit enforcement, a pre-push test\nrun, and ",[42,2472,2473],{},"SonarQube"," ingesting the PHPStan\u002FPsalm reports - so every change\nclears the same bar regardless of who reviewed it. In practice, ",[42,2476,2477],{},"100% of\ncommits pass through these gates","; the build is the source of truth for \"is\nthis up to standard?\", not a person's memory.",[11,2480,2482],{"id":2481},"a-test-suite-the-team-can-trust","A test suite the team can trust",[16,2484,2485,2486,2489,2490,2493,2494,2497,2498,2501,2502,2505],{},"QA on the project is manual, so the automated suite ",[27,2487,2488],{},"is"," the regression safety\nnet - it has to catch what a person otherwise would. On the backend, tests are\nwritten with ",[42,2491,2492],{},"Pest",": unit tests for services, handlers, input filters and DTOs;\nintegration smoke tests for the DI container, Doctrine metadata, and route\nwiring; and ",[42,2495,2496],{},"ACL tests covering every non-public route",", so a permissions\nregression fails the build rather than shipping. The frontend is covered by\n",[42,2499,2500],{},"Vitest"," unit tests (stores, composables, components) and ",[42,2503,2504],{},"Playwright","\nend-to-end tests for the workflows that matter most. Well over a thousand backend\ntest cases run as part of normal delivery, with a unit\u002Fintegration split tuned\nfor fast feedback.",[11,2507,2509],{"id":2508},"one-command-to-a-running-stack","One command to a running stack",[16,2511,2512,2513,2515,2516,550],{},"The old onboarding ritual was a manual, multi-tool local setup that taught a new\ndeveloper four legacy architectures before they could run anything. I replaced it\nwith a ",[42,2514,617],{}," environment that brings the whole stack up - nginx-fpm, Redis,\nSwagger UI, the right PHP extensions, Xdebug - in essentially one command, with\nautomated certificate handling for the corporate proxy environment. (More on the\nenvironment shift in ",[483,2517,2519],{"href":2518},"\u002Fblog\u002Ffrom-xampp-to-ddev","From XAMPP to DDEV",[16,2521,2522,2523,2526,2527,2530],{},"I also built a ",[42,2524,2525],{},"scaffolding CLI"," that generates a new module's boilerplate to\nthe project's conventions - entity, repository, service, DTO, handler, and test\nstubs - so a new domain starts ",[27,2528,2529],{},"correct"," instead of copy-pasted from whatever was\nnearest. The fastest way to keep code consistent is to make the consistent\nversion the easiest one to produce.",[11,2532,2534],{"id":2533},"teaching-the-machine-the-conventions","Teaching the machine the conventions",[16,2536,2537,2538,2541,2542,1882,2544,1893,2547,1893,2550,1893,2553,1882,2556,1893,2559,2561],{},"The most forward-looking piece: I encoded the architecture into an\n",[42,2539,2540],{},"AI-augmented migration workflow",". Repo-local agent skills - ",[125,2543,737],{},[125,2545,2546],{},"api-endpoint-scaffold",[125,2548,2549],{},"create-service",[125,2551,2552],{},"register-route",[125,2554,2555],{},"quality-check",[125,2557,2558],{},"code-review",[125,2560,692],{}," - carry the project's own rules and drive a fixed\nseven-phase flow:",[118,2563,2568],{"className":2564,"code":2566,"language":2567,"meta":123},[2565],"language-text","1. Analyze legacy code → migration doc\n2. Backend: entity, repository, service, DTO, handlers, tests\n3. Regenerate the typed SDK from the OpenAPI spec\n4. Middleware: register proxy routes\n5. Frontend: Pinia stores, Nuxt pages, Vuetify components, tests\n6. Verify: cross-layer testing + legacy-parity check\n7. Publish: quality checks, PR, ticket transition\n","text",[125,2569,2566],{"__ignoreMap":123},[16,2571,2572,2573,2576],{},"The point isn't \"we use AI.\" It's that the conventions I designed are captured\n",[27,2574,2575],{},"where the work happens",", so an assisted migration comes out shaped like the rest\nof the platform instead of freestyling - and a developer reviews each phase\nbefore the next begins. In planning, that workflow was the difference between a\nmulti-year manual estimate and a timeline roughly half as long.",[11,2578,2580],{"id":2579},"what-changed","What changed",[16,2582,2583,2584,2588],{},"The architecture is now documented in ADRs (why that habit compounds is in\n",[483,2585,2587],{"href":2586},"\u002Fblog\u002Farchitecture-decision-records","Architecture Decision Records","), the\nstandards are automated rather than tribal, and a six-developer team ships\nconfidently on a stack they'd never touched - because the system catches the\nmistakes that used to need a senior reviewer to catch.",[16,2590,2591],{},"The throughline across everything I build: I'd rather spend a day on the rule or\nthe tool that prevents a whole class of problems than a week cleaning up after it.",[570,2593,2594],{},"html pre.shiki code .sV9Aq, html code.shiki .sV9Aq{--shiki-default:#7F848E;--shiki-default-font-style:italic}html pre.shiki code .sVyAn, html code.shiki .sVyAn{--shiki-default:#E06C75}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 .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);}",{"title":123,"searchDepth":138,"depth":138,"links":2596},[2597,2598,2599,2600,2601,2602,2603],{"id":13,"depth":138,"text":14},{"id":638,"depth":138,"text":639},{"id":2340,"depth":138,"text":2341},{"id":2481,"depth":138,"text":2482},{"id":2508,"depth":138,"text":2509},{"id":2533,"depth":138,"text":2534},{"id":2579,"depth":138,"text":2580},"A greenfield platform is only as good as the system around it. I built the standards, quality gates, and tooling that keep a six-developer team on the architecture on a stack none of them had used - including custom static-analysis rules and an AI workflow that encodes the architecture itself.",{},[2607,2610,2613],{"value":2608,"label":2609},"1,400+","backend tests (Pest), plus 900+ frontend unit & Playwright e2e",{"value":2611,"label":2612},"Custom","static-analysis rules that enforce the architecture",{"value":2614,"label":2615},"100%","of commits run through automated quality gates",{"title":2318,"description":2604},[961,962,2359,963,2355,2035,2492,2500,2504,2473,617,2308],"case-studies\u002Fengineering-standards-and-tooling",[2620,970,2621,969],"Engineering Culture","Testing","ZiVbFnYs2LXWuI9EBQnX81BliyfsJr86Q0mHuSuFw_g",{"id":2624,"title":2625,"body":2626,"description":2774,"draft":588,"extension":589,"industry":590,"meta":2775,"metrics":2776,"navigation":209,"order":206,"path":2785,"role":603,"seo":2786,"stack":2787,"stem":2789,"tags":2790,"year":2794,"__hash__":2795},"caseStudies\u002Fcase-studies\u002Fmulti-factor-auth-from-scratch.md","Multi-factor auth on a system that had none",{"type":8,"value":2627,"toc":2762},[2628,2630,2633,2635,2638,2641,2643,2646,2650,2653,2667,2671,2678,2682,2685,2721,2725,2728,2730,2736,2742,2748,2750,2756],[11,2629,14],{"id":13},[16,2631,2632],{},"I added multi-factor authentication to a payments platform that had only ever had\na password, building the whole feature as one clean vertical slice - entity,\nrepository, service, handler, route, email template and config - rather than\nbolting a second factor onto a login flow that was never designed for one.",[11,2634,639],{"id":638},[16,2636,2637],{},"The legacy platform authenticated users with a password and nothing else. For a\nsystem sitting in front of payments data, a single factor is the kind of gap that\nturns one leaked credential into a full account compromise. The new stack needed\nreal multi-factor authentication, and it needed to be something a user could\nactually turn on and use, not a checkbox.",[16,2639,2640],{},"There was no existing second-factor code to migrate. This was net-new security\nfunctionality on a fresh architecture, which is the good news and the\nresponsibility at once: nothing to copy, nothing to blame, and no room to get an\nauth flow subtly wrong.",[11,2642,1611],{"id":1610},[16,2644,2645],{},"I owned the feature as a vertical slice, top to bottom, so every layer was\nconsistent with the platform's conventions rather than special-cased.",[98,2647,2649],{"id":2648},"the-second-factors","The second factors",[16,2651,2652],{},"Two, so users are not forced onto one channel:",[47,2654,2655,2661],{},[50,2656,2657,2660],{},[42,2658,2659],{},"Email OTP"," - a one-time code delivered by email, with its own message\ntemplate, for users without an authenticator app.",[50,2662,2663,2666],{},[42,2664,2665],{},"Authenticator-app OTP"," - a time-based one-time password (TOTP) for users who\nwant a proper second factor that does not depend on email delivery.",[98,2668,2670],{"id":2669},"the-enrolment-flow","The enrolment flow",[16,2672,2673,2674,2677],{},"MFA is only useful if turning it on is a real flow, not a database flag someone\nsets by hand. I built activation via both email and the mobile app, tracked with\nan ",[125,2675,2676],{},"isMfaActivated"," field on the user, so the system knows a user's true MFA state\nand enforces it on subsequent logins.",[98,2679,2681],{"id":2680},"the-vertical-slice","The vertical slice",[16,2683,2684],{},"The one feature touched every layer, and I built each to the platform's standard:",[47,2686,2687,2693,2699,2705,2715],{},[50,2688,2689,2692],{},[42,2690,2691],{},"Entity"," - the MFA state on the user record.",[50,2694,2695,2698],{},[42,2696,2697],{},"Request \u002F response DTOs"," - typed contracts for the OTP exchange, carrying the\nOpenAPI metadata the rest of the platform relies on.",[50,2700,2701,2704],{},[42,2702,2703],{},"Repository and service"," - the OTP lifecycle, kept behind a service boundary\nrather than smeared through the handler.",[50,2706,2707,2710,2711,2714],{},[42,2708,2709],{},"Handler and route"," - the ",[125,2712,2713],{},"authenticate-mfa"," and send-code endpoints, named\nand shaped like every other endpoint on the platform.",[50,2716,2717,2720],{},[42,2718,2719],{},"Email template and config"," - so the OTP email is a first-class, configurable\npart of the system.",[98,2722,2724],{"id":2723},"environment-aware-recovery","Environment-aware recovery",[16,2726,2727],{},"Password recovery is the soft underbelly of any auth system. I made the\nforgot-password flow environment-specific, so a reset link generated in one\nenvironment cannot be used to walk into another - a small detail that is exactly\nthe kind of thing that goes wrong when password reset is treated as an\nafterthought.",[11,2729,1721],{"id":1720},[16,2731,2732,2735],{},[42,2733,2734],{},"It closed a real security gap on a payments system."," Going from a bare password\nto genuine multi-factor authentication is the difference between one leaked\ncredential being a catastrophe and being an inconvenience.",[16,2737,2738,2741],{},[42,2739,2740],{},"It was built to the platform's conventions, not around them."," Because the\nfeature went through the same DTO, service and OpenAPI patterns as everything\nelse, it did not become a special case that the next person has to learn\nseparately. It reads like the rest of the system.",[16,2743,2744,2747],{},[42,2745,2746],{},"It gave users a real choice."," Two second factors, an actual enrolment flow, and\nrecovery that does not undermine the thing it is recovering - the feature is\nusable, not just present.",[11,2749,904],{"id":903},[16,2751,2752,2755],{},[42,2753,2754],{},"Build auth as a vertical slice, not a bolt-on."," The failures in\nauthentication systems live in the seams - between the token and the session,\nbetween reset and login. Owning every layer of the one feature is how you keep\nthe seams honest.",[16,2757,2758,2761],{},[42,2759,2760],{},"Password recovery is part of the auth system, not a footnote."," The strongest\nsecond factor in the world does not help if the reset flow is a side door. Make it\nenvironment-aware and treat it with the same care as the login path.",{"title":123,"searchDepth":138,"depth":138,"links":2763},[2764,2765,2766,2772,2773],{"id":13,"depth":138,"text":14},{"id":638,"depth":138,"text":639},{"id":1610,"depth":138,"text":1611,"children":2767},[2768,2769,2770,2771],{"id":2648,"depth":144,"text":2649},{"id":2669,"depth":144,"text":2670},{"id":2680,"depth":144,"text":2681},{"id":2723,"depth":144,"text":2724},{"id":1720,"depth":138,"text":1721},{"id":903,"depth":138,"text":904},"The legacy platform had a single password gate and no second factor. I built multi-factor authentication end to end - email and authenticator-app OTP, the enrolment flow, and environment-aware password recovery - on a new stack, as a clean vertical slice from database to route.",{},[2777,2779,2782],{"value":1480,"label":2778},"second factors: email OTP and authenticator app",{"value":2780,"label":2781},"0 → full","MFA where the legacy system had a bare password gate",{"value":2783,"label":2784},"Vertical","slice owned end to end: entity, repository, service, handler, route","\u002Fcase-studies\u002Fmulti-factor-auth-from-scratch",{"title":2625,"description":2774},[606,2309,608,2788,615,2492],"TOTP","case-studies\u002Fmulti-factor-auth-from-scratch",[2791,2792,2793,2312],"Security","Authentication","Backend","2025","TkVoDNwzWp7dR5AWF7emPpmgXUQrDF_IBe1ZL5i5G84",1788019316917]