[{"data":1,"prerenderedAt":624},["ShallowReactive",2],{"case-study:replatforming-a-legacy-payments-system":3},{"id":4,"title":5,"body":6,"description":586,"draft":208,"extension":587,"industry":588,"meta":589,"metrics":590,"navigation":208,"order":130,"path":600,"role":601,"seo":602,"stack":603,"stem":616,"tags":617,"year":622,"__hash__":623},"caseStudies\u002Fcase-studies\u002Freplatforming-a-legacy-payments-system.md","Re-platforming a payments system: four legacy codebases into one",{"type":7,"value":8,"toc":572},"minimark",[9,14,18,22,30,33,37,45,82,85,89,96,101,116,369,380,384,391,395,410,474,478,487,491,523,527,538,550,554,565,568],[10,11,13],"h2",{"id":12},"in-one-line","In one line",[15,16,17],"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\n75% of it (1,815 commits across five repositories), turning four tangled legacy\nsystems into one modular, type-safe platform that a team can actually extend.",[10,19,21],{"id":20},"the-context","The context",[15,23,24,25,29],{},"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 ",[26,27,28],"em",{},"how"," it's built.",[15,31,32],{},"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.",[10,34,36],{"id":35},"the-mess-i-inherited","The mess I inherited",[15,38,39,40,44],{},"The platform wasn't one legacy system. It was ",[41,42,43],"strong",{},"four",", layered up over a decade:",[46,47,48,59,65,75],"ul",{},[49,50,51,52,55,56],"li",{},"A ",[41,53,54],{},"Zend Framework 1"," backend exposing ",[41,57,58],{},"SOAP and JSON-RPC",[49,60,51,61,64],{},[41,62,63],{},"Zend Framework 2"," backend exposing REST on Doctrine 2.5",[49,66,51,67,70,71,74],{},[41,68,69],{},"ZF2 + AngularJS 1.2"," hybrid - server-rendered pages ",[26,72,73],{},"and"," a SPA",[49,76,77,78,81],{},"A separate ",[41,79,80],{},"Vue 3 SPA monorepo"," of ~11 apps stitched into that frontend",[15,83,84],{},"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.",[10,86,88],{"id":87},"the-architecture-i-designed","The architecture I designed",[15,90,91,92,95],{},"I designed a clean ",[41,93,94],{},"three-tier system"," to replace all four, along with the\npatterns the rebuild is built on.",[97,98,100],"h3",{"id":99},"backend-a-modular-monolith-mezzio-php-84-doctrine-orm","Backend - a modular monolith (Mezzio \u002F PHP 8.4, Doctrine ORM)",[15,102,103,104,107,108,111,112,115],{},"One deployable, but internally split into domain modules - auth, customers,\nmerchants, terminals, payment, reporting, PIN management, content. Every request\nflows through a strict ",[41,105,106],{},"Handler → Service → Repository"," layering, and that\nboundary is ",[26,109,110],{},"enforced in CI"," (not just documented), so the modular structure\ncan't quietly erode into a ball of mud. Services return ",[41,113,114],{},"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.",[117,118,123],"pre",{"className":119,"code":120,"language":121,"meta":122,"style":122},"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","",[124,125,126,135,141,147,167,174,203,210,235,241,277,305,310,316,357,363],"code",{"__ignoreMap":122},[127,128,131],"span",{"class":129,"line":130},"line",1,[127,132,134],{"class":133},"sV9Aq","\u002F\u002F A handler stays thin: parse input, call a service, return a DTO.\n",[127,136,138],{"class":129,"line":137},2,[127,139,140],{"class":133},"\u002F\u002F Business logic never leaks up into HTTP, and persistence never leaks up\n",[127,142,144],{"class":129,"line":143},3,[127,145,146],{"class":133},"\u002F\u002F into business logic.\n",[127,148,150,154,157,161,164],{"class":129,"line":149},4,[127,151,153],{"class":152},"seHd6","final",[127,155,156],{"class":152}," class",[127,158,160],{"class":159},"sU0A5"," FetchCustomerStoresHandler",[127,162,163],{"class":152}," implements",[127,165,166],{"class":159}," RequestHandlerInterface\n",[127,168,170],{"class":129,"line":169},5,[127,171,173],{"class":172},"sn6KH","{\n",[127,175,177,180,183,187,190,193,196,200],{"class":129,"line":176},6,[127,178,179],{"class":152},"    public",[127,181,182],{"class":152}," function",[127,184,186],{"class":185},"sjrmR"," __construct",[127,188,189],{"class":172},"(",[127,191,192],{"class":152},"private",[127,194,195],{"class":159}," StoreService",[127,197,199],{"class":198},"sVyAn"," $stores",[127,201,202],{"class":172},") {}\n",[127,204,206],{"class":129,"line":205},7,[127,207,209],{"emptyLinePlaceholder":208},true,"\n",[127,211,213,215,217,221,223,226,229,232],{"class":129,"line":212},8,[127,214,179],{"class":152},[127,216,182],{"class":152},[127,218,220],{"class":219},"sVbv2"," handle",[127,222,189],{"class":172},[127,224,225],{"class":159},"ServerRequestInterface",[127,227,228],{"class":198}," $request",[127,230,231],{"class":172},"): ",[127,233,234],{"class":159},"ResponseInterface\n",[127,236,238],{"class":129,"line":237},9,[127,239,240],{"class":172},"    {\n",[127,242,244,247,250,253,256,259,262,265,268,270,274],{"class":129,"line":243},10,[127,245,246],{"class":198},"        $customerId",[127,248,249],{"class":185}," =",[127,251,252],{"class":172}," (",[127,254,255],{"class":159},"int",[127,257,258],{"class":172},") ",[127,260,261],{"class":198},"$request",[127,263,264],{"class":172},"->",[127,266,267],{"class":219},"getAttribute",[127,269,189],{"class":172},[127,271,273],{"class":272},"subq3","'id'",[127,275,276],{"class":172},");\n",[127,278,280,283,286,289,292,295,297,299,302],{"class":129,"line":279},11,[127,281,282],{"class":198},"        $criteria",[127,284,285],{"class":185},"   =",[127,287,288],{"class":159}," SearchCriteria",[127,290,291],{"class":172},"::",[127,293,294],{"class":219},"fromRequest",[127,296,189],{"class":172},[127,298,261],{"class":198},[127,300,301],{"class":172},"); ",[127,303,304],{"class":133},"\u002F\u002F page, size, search, sort\n",[127,306,308],{"class":129,"line":307},12,[127,309,209],{"emptyLinePlaceholder":208},[127,311,313],{"class":129,"line":312},13,[127,314,315],{"class":133},"        \u002F\u002F Service returns DTOs; the handler never sees a Doctrine entity.\n",[127,317,319,322,325,328,330,333,335,338,340,343,345,348,351,354],{"class":129,"line":318},14,[127,320,321],{"class":152},"        return",[127,323,324],{"class":152}," new",[127,326,327],{"class":159}," JsonResponse",[127,329,189],{"class":172},[127,331,332],{"class":159},"$this",[127,334,264],{"class":172},[127,336,337],{"class":198},"stores",[127,339,264],{"class":172},[127,341,342],{"class":219},"listForCustomer",[127,344,189],{"class":172},[127,346,347],{"class":198},"$customerId",[127,349,350],{"class":172},",",[127,352,353],{"class":198}," $criteria",[127,355,356],{"class":172},"));\n",[127,358,360],{"class":129,"line":359},15,[127,361,362],{"class":172},"    }\n",[127,364,366],{"class":129,"line":365},16,[127,367,368],{"class":172},"}\n",[15,370,371,372,375,376,379],{},"The request pipeline itself is an explicit, ordered middleware stack - error\nhandling, env validation, body parsing, routing, banned-request filtering,\n",[41,373,374],{},"authentication",", route verification, forced-action checks (e.g. mandatory\npassword change), ",[41,377,378],{},"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.",[97,381,383],{"id":382},"middleware-a-thin-proxy-tier-slim-4","Middleware - a thin proxy tier (Slim 4)",[15,385,386,387,390],{},"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 ",[41,388,389],{},"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.",[97,392,394],{"id":393},"frontend-a-layered-nuxt-4-vue-3-spa-vuetify-3","Frontend - a layered Nuxt 4 \u002F Vue 3 SPA (Vuetify 3)",[15,396,397,398,401,402,405,406,409],{},"Organised into layers (base, auth, administration, customers, …) with\nserver-side pagination, full i18n, and a ",[41,399,400],{},"database-driven ACL"," surfaced through\na single ",[124,403,404],{},"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 ",[124,407,408],{},"v-if","s:",[117,411,415],{"className":412,"code":413,"language":414,"meta":122,"style":122},"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",[124,416,417,440,445],{"__ignoreMap":122},[127,418,419,422,425,428,431,434,437],{"class":129,"line":130},[127,420,421],{"class":152},"const",[127,423,424],{"class":172}," { ",[127,426,427],{"class":159},"isAllowed",[127,429,430],{"class":172}," } ",[127,432,433],{"class":185},"=",[127,435,436],{"class":219}," useAcl",[127,438,439],{"class":172},"()\n",[127,441,442],{"class":129,"line":137},[127,443,444],{"class":133},"\u002F\u002F One source of truth for \"can this user see\u002Fdo this\", driven by backend ACL.\n",[127,446,447,449,452,454,457,460,463,466,468,471],{"class":129,"line":143},[127,448,421],{"class":152},[127,450,451],{"class":159}," canEditCustomer",[127,453,249],{"class":185},[127,455,456],{"class":219}," computed",[127,458,459],{"class":172},"(() ",[127,461,462],{"class":152},"=>",[127,464,465],{"class":219}," isAllowed",[127,467,189],{"class":172},[127,469,470],{"class":272},"'ui\u002FeditCustomer'",[127,472,473],{"class":172},"))\n",[97,475,477],{"id":476},"contract-a-generated-type-safe-sdk","Contract - a generated, type-safe SDK",[15,479,480,481,486],{},"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",[482,483,485],"a",{"href":484},"\u002Fcase-studies\u002Feliminating-api-schema-drift","Killing API schema drift",".",[10,488,490],{"id":489},"decisions-that-mattered-and-the-tradeoffs","Decisions that mattered (and the tradeoffs)",[46,492,493,499,505],{},[49,494,495,498],{},[41,496,497],{},"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.",[49,500,501,504],{},[41,502,503],{},"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.",[49,506,507,510,511,514,515,518,519,522],{},[41,508,509],{},"Patterns over heroics for the legacy data."," The old database is full of\ntraps: polymorphic tables keyed by a type-discriminator column, ",[124,512,513],{},"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",[124,516,517],{},"SoftDeleteable"," interface, a centralized HTML purifier injected wherever\nuser text is stored, and a ",[124,520,521],{},"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.",[10,524,526],{"id":525},"migrating-without-a-big-bang","Migrating without a big bang",[15,528,529,530,533,534,537],{},"There is ",[41,531,532],{},"no flip-the-switch cutover",", by design. The work is organised into\nphases, and ",[41,535,536],{},"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.",[15,539,540,541,544,545,549],{},"It also means migration is a chance to ",[26,542,543],{},"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 ",[482,546,548],{"href":547},"\u002Fcase-studies\u002Foptimizing-a-32-join-query","660× optimization",".)",[10,551,553],{"id":552},"where-it-stands","Where it stands",[15,555,556,557,560,561,564],{},"I designed this platform from an empty repository and, as its primary developer,\nauthored roughly ",[41,558,559],{},"75% of the codebase (1,815 commits across five\nrepositories)",", working within a five-developer team. The new stack runs and is\nvalidated in a ",[41,562,563],{},"test environment",", with features rebuilt and signed off module\nby module against the legacy system; production rollout is phased.",[15,566,567],{},"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.",[569,570,571],"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":122,"searchDepth":137,"depth":137,"links":573},[574,575,576,577,583,584,585],{"id":12,"depth":137,"text":13},{"id":20,"depth":137,"text":21},{"id":35,"depth":137,"text":36},{"id":87,"depth":137,"text":88,"children":578},[579,580,581,582],{"id":99,"depth":143,"text":100},{"id":382,"depth":143,"text":383},{"id":393,"depth":143,"text":394},{"id":476,"depth":143,"text":477},{"id":489,"depth":137,"text":490},{"id":525,"depth":137,"text":526},{"id":552,"depth":137,"text":553},"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.","md","Payments \u002F Fintech",{},[591,594,597],{"value":592,"label":593},"~75%","of the new platform authored personally (1,815 commits)",{"value":595,"label":596},"4 → 1","legacy codebases consolidated into one modular platform",{"value":598,"label":599},"5","repositories designed and built end to end","\u002Fcase-studies\u002Freplatforming-a-legacy-payments-system","Senior Full-Stack Developer",{"title":5,"description":586},[604,605,606,607,608,609,610,611,612,613,614,615],"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",[618,619,620,621],"Architecture","Legacy Modernization","Fintech","Modular Monolith","2024–present","2H5lmPrD40eCujB-RCiL9uXCV_reJhIgxsqPSAUjZiI",1781646389033]