[{"data":1,"prerenderedAt":1578},["ShallowReactive",2],{"blog-\u002Fblog\u002Fshipping-battery-notifier-cross-platform":3,"blog-nav":529},{"id":4,"title":5,"author":6,"body":7,"coverText":513,"date":514,"description":515,"excerpt":516,"extension":517,"image":443,"meta":518,"navigation":294,"path":519,"readingTime":242,"seo":520,"stem":521,"tags":522,"__hash__":528},"blog\u002Fblog\u002Fshipping-battery-notifier-cross-platform.md","From a WinForms Hack to a Cross-Platform Desktop App","Sandip Choudhary",{"type":8,"value":9,"toc":502},"minimark",[10,15,19,22,38,41,52,56,59,62,65,68,72,75,87,90,93,97,100,109,112,121,125,128,131,140,143,366,369,373,376,429,438,447,451,454,457,461,469,472,476,479,482,485,498],[11,12,14],"h3",{"id":13},"the-origin-story","The Origin Story",[16,17,18],"p",{},"Back in 2022 I had a simple problem. My ageing laptop would die without warning — the battery had degraded so badly that the default Windows alert at 20% was useless. The machine would shut off at 50% or higher, sometimes right in the middle of work.",[16,20,21],{},"So I did what any developer does — I built something. A small WinForms utility that polls the battery status and fires a toast notification when it crosses a threshold I set. Nothing fancy. It ran from the system tray, did its job, and I used it every day.",[16,23,24,25,30,31,37],{},"I ",[26,27,29],"a",{"href":28},"\u002Fcase-studies\u002Fbattery-notifier","wrote about that original version"," a while back, and at the end I said I wanted to rebuild it with ",[26,32,36],{"href":33,"rel":34},"https:\u002F\u002Favaloniaui.net\u002F",[35],"nofollow","Avalonia UI"," so it could run on macOS and Linux too. That line sat there for a long time. This post is about what happened when I finally followed through.",[16,39,40],{},"The app went through three distinct phases — the bare-bones WinForms release in 2022, a major UI redesign in 2024, and the 2026 cross-platform rewrite in Avalonia.",[16,42,43,48],{},[44,45],"img",{"alt":46,"src":47},"Battery Notifier evolution: 2022 first release, 2024 redesign, 2026 Avalonia cross-platform","\u002Fimg\u002FBatteryNotifierEvolution.webp",[49,50,51],"em",{},"From left to right: the original WinForms release (2022), the redesigned UI (2024), and the current Avalonia cross-platform version (2026).",[11,53,55],{"id":54},"why-it-took-so-long","Why It Took So Long",[16,57,58],{},"It was not about waiting for a framework to mature. It was about me not being ready.",[16,60,61],{},"When I first built Battery Notifier I was a web developer. I knew C# and .NET from building backends, and WinForms from a POS app at work, but my understanding of what happens below the application layer was shallow. Cross-platform desktop development scared me — not because of the UI toolkit, but because I did not trust myself to handle the things that go wrong when your code runs close to the OS.",[16,63,64],{},"That changed when I joined IT University of Copenhagen for my master's in Computer Science. For the first time I studied concurrency properly — why race conditions appear, why deadlocks happen, how locks actually work under the hood. I got into memory management, hardware architecture, how the OS schedules processes. Things I had been hand-waving over for years.",[16,66,67],{},"That foundation gave me the confidence to actually attempt this. When you are building a system tray app that polls hardware, manages background threads, and talks to platform-specific APIs, you need to understand what is happening at a lower level than most web work requires. ITU gave me that.",[11,69,71],{"id":70},"the-platform-reality","The Platform Reality",[16,73,74],{},"Confidence is one thing. Each operating system fighting you is another.",[16,76,77,78,82,83,86],{},"Getting battery information sounds simple until you try it on three platforms. On Windows I call ",[79,80,81],"code",{},"GetSystemPowerStatus"," through P\u002FInvoke — I initially tried WMI but it was slow and its status values were unreliable. On macOS there is no managed API at all, so I spawn ",[79,84,85],{},"pmset -g batt"," and parse text output with regex. Same data, completely different paths to get there.",[16,88,89],{},"Detecting charger plug\u002Funplug events was trickier. Windows has WMI power management events. macOS required registering for Darwin kernel notifications and blocking on a file descriptor in a background thread — with a careful cleanup path to avoid hanging on shutdown.",[16,91,92],{},"Do Not Disturb detection was the worst. Windows uses an undocumented notification facility API. macOS changed their DND implementation across Monterey, Ventura, and Tahoe — I ended up with a three-tier fallback that, on the newest versions, literally uses AppleScript to click the Control Center and read the Focus state. Not my proudest code, but it works.",[11,94,96],{"id":95},"not-just-a-port-a-rethink","Not Just a Port — a Rethink",[16,98,99],{},"I did not want to just rewrite the same app with a different UI toolkit. I wanted to raise the standard. If I was going to ship this, it had to feel like a product someone would actually want to install and keep.",[16,101,102,103,108],{},"The biggest influence on my thinking was Apple's design philosophy. Say what you will about their ecosystem, but Apple raised the bar for what a utility app should look and feel like. I wanted ",[26,104,107],{"href":105,"rel":106},"https:\u002F\u002Fbatterynotifier.com",[35],"Battery Notifier"," to feel considered — not like a developer's side project, but like something designed with intention.",[16,110,111],{},"So I opened Figma and started from scratch. Multiple design iterations. Revised the layout, the colour palette, the iconography, the settings flow. I went through more versions than I want to admit before landing on something I was happy with.",[16,113,114,118],{},[44,115],{"alt":116,"src":117},"Battery Notifier Figma workspace — dashboard, settings, alerts, and sound picker screens","\u002Fimg\u002FBatteryNotifier-FigmaDesign.webp",[49,119,120],{},"The Figma file with all the screens — dashboard, settings, notification preferences, and the sound picker.",[11,122,124],{"id":123},"the-notification-problem","The Notification Problem",[16,126,127],{},"Here is the thing about battery notifications — if they are annoying, people turn them off. And once someone turns off notifications for your app, you have lost them permanently.",[16,129,130],{},"The original version had a naive approach: check the battery every N minutes, send a notification if it crosses the threshold. Simple, but it meant you could get the same alert every few minutes if you did not act on it. That gets old fast.",[16,132,133,134,139],{},"I had been reading about how ",[26,135,138],{"href":136,"rel":137},"https:\u002F\u002Fresearch.duolingo.com\u002Fpapers\u002Fyancey.kdd20.pdf",[35],"Duolingo approaches push notifications",". Their research paper lays out the core insight clearly — every notification you send has a cost, which is the risk of the user opting out entirely. So instead of sending more, they send smarter. If a user ignores a notification, they back off. They treat the notification channel as a scarce resource to be protected, not a firehose to be maximised.",[16,141,142],{},"I took that same principle and built an escalating backoff system. The intervals ramp up progressively, and after seven ignored notifications the app goes silent entirely — then auto-recovers after two hours, borrowing Duolingo's \"recovering arm\" concept:",[144,145,150],"pre",{"className":146,"code":147,"language":148,"meta":149,"style":149},"language-csharp shiki shiki-themes github-light github-dark","private static readonly TimeSpan[] BackoffIntervals =\n[\n    TimeSpan.Zero,\n    TimeSpan.FromMinutes(2),\n    TimeSpan.FromMinutes(5),\n    TimeSpan.FromMinutes(10),\n    TimeSpan.FromMinutes(15),\n    TimeSpan.FromMinutes(30),\n    TimeSpan.FromMinutes(45)\n];\n\nprivate const int MaxNotificationsBeforeSilence = 7;\n\n\u002F\u002F After two hours of silence the tracker resets,\n\u002F\u002F giving the user a fresh reminder cycle.\nprivate static readonly TimeSpan RecoveryInterval = TimeSpan.FromHours(2);\n","csharp","",[79,151,152,181,187,193,212,226,240,254,268,283,289,296,319,324,331,337],{"__ignoreMap":149},[153,154,157,161,164,167,171,175,178],"span",{"class":155,"line":156},"line",1,[153,158,160],{"class":159},"szBVR","private",[153,162,163],{"class":159}," static",[153,165,166],{"class":159}," readonly",[153,168,170],{"class":169},"sScJk"," TimeSpan",[153,172,174],{"class":173},"sVt8B","[] ",[153,176,177],{"class":169},"BackoffIntervals",[153,179,180],{"class":159}," =\n",[153,182,184],{"class":155,"line":183},2,[153,185,186],{"class":173},"[\n",[153,188,190],{"class":155,"line":189},3,[153,191,192],{"class":173},"    TimeSpan.Zero,\n",[153,194,196,199,202,205,209],{"class":155,"line":195},4,[153,197,198],{"class":173},"    TimeSpan.",[153,200,201],{"class":169},"FromMinutes",[153,203,204],{"class":173},"(",[153,206,208],{"class":207},"sj4cs","2",[153,210,211],{"class":173},"),\n",[153,213,215,217,219,221,224],{"class":155,"line":214},5,[153,216,198],{"class":173},[153,218,201],{"class":169},[153,220,204],{"class":173},[153,222,223],{"class":207},"5",[153,225,211],{"class":173},[153,227,229,231,233,235,238],{"class":155,"line":228},6,[153,230,198],{"class":173},[153,232,201],{"class":169},[153,234,204],{"class":173},[153,236,237],{"class":207},"10",[153,239,211],{"class":173},[153,241,243,245,247,249,252],{"class":155,"line":242},7,[153,244,198],{"class":173},[153,246,201],{"class":169},[153,248,204],{"class":173},[153,250,251],{"class":207},"15",[153,253,211],{"class":173},[153,255,257,259,261,263,266],{"class":155,"line":256},8,[153,258,198],{"class":173},[153,260,201],{"class":169},[153,262,204],{"class":173},[153,264,265],{"class":207},"30",[153,267,211],{"class":173},[153,269,271,273,275,277,280],{"class":155,"line":270},9,[153,272,198],{"class":173},[153,274,201],{"class":169},[153,276,204],{"class":173},[153,278,279],{"class":207},"45",[153,281,282],{"class":173},")\n",[153,284,286],{"class":155,"line":285},10,[153,287,288],{"class":173},"];\n",[153,290,292],{"class":155,"line":291},11,[153,293,295],{"emptyLinePlaceholder":294},true,"\n",[153,297,299,301,304,307,310,313,316],{"class":155,"line":298},12,[153,300,160],{"class":159},[153,302,303],{"class":159}," const",[153,305,306],{"class":159}," int",[153,308,309],{"class":169}," MaxNotificationsBeforeSilence",[153,311,312],{"class":159}," =",[153,314,315],{"class":207}," 7",[153,317,318],{"class":173},";\n",[153,320,322],{"class":155,"line":321},13,[153,323,295],{"emptyLinePlaceholder":294},[153,325,327],{"class":155,"line":326},14,[153,328,330],{"class":329},"sJ8bj","\u002F\u002F After two hours of silence the tracker resets,\n",[153,332,334],{"class":155,"line":333},15,[153,335,336],{"class":329},"\u002F\u002F giving the user a fresh reminder cycle.\n",[153,338,340,342,344,346,348,351,353,356,359,361,363],{"class":155,"line":339},16,[153,341,160],{"class":159},[153,343,163],{"class":159},[153,345,166],{"class":159},[153,347,170],{"class":169},[153,349,350],{"class":169}," RecoveryInterval",[153,352,312],{"class":159},[153,354,355],{"class":173}," TimeSpan.",[153,357,358],{"class":169},"FromHours",[153,360,204],{"class":173},[153,362,208],{"class":207},[153,364,365],{"class":173},");\n",[16,367,368],{},"A small detail, but the difference between an app people tolerate and one they actually keep running.",[11,370,372],{"id":371},"what-actually-shipped","What Actually Shipped",[16,374,375],{},"The scope grew beyond what I originally planned, but each feature earned its place:",[377,378,379,387,393,399,405,411,417,423],"ul",{},[380,381,382,386],"li",{},[383,384,385],"strong",{},"Customisable thresholds"," — set your own high and low battery triggers",[380,388,389,392],{},[383,390,391],{},"Battery health dashboard"," — capacity, cycle count, temperature, power draw, charge history sparkline, and wear trend",[380,394,395,398],{},[383,396,397],{},"Battery drainer detection"," — shows which apps are consuming the most power, with estimated time impact and tips",[380,400,401,404],{},[383,402,403],{},"Sound library"," — built-in synthesized tones, curated Editor's Choice sounds, or import your own audio files",[380,406,407,410],{},[383,408,409],{},"Encrypted settings"," — DPAPI on Windows, AES-256-GCM on macOS and Linux",[380,412,413,416],{},[383,414,415],{},"System tray"," — runs quietly in the background, click to show or hide",[380,418,419,422],{},[383,420,421],{},"Launch at startup"," — auto-start with your OS",[380,424,425,428],{},[383,426,427],{},"Themes"," — System, Light, and Dark mode",[16,430,431,432,437],{},"The app runs on Windows (x64 and ARM64) and macOS (Apple Silicon), with Linux support in progress. You can grab it from ",[26,433,436],{"href":434,"rel":435},"https:\u002F\u002Fgithub.com\u002FSandip124\u002FBatteryNotifier\u002Freleases",[35],"GitHub Releases"," or install via the command line.",[16,439,440,444],{},[44,441],{"alt":442,"src":443},"Battery Notifier running on Windows and macOS in light and dark themes","\u002Fimg\u002FBatteryNotifierBannerUpdated.webp",[49,445,446],{},"The current version running on Windows and macOS — light and dark themes.",[11,448,450],{"id":449},"ai-and-shipping","AI and Shipping",[16,452,453],{},"AI helped me move faster. Platform-specific battery APIs, encrypted settings, the notification backoff logic — I could iterate on these in hours instead of days. That part is real and I would be lying if I said otherwise.",[16,455,456],{},"But AI did not decide what this app should be. It did not open Figma. It did not throw away three design iterations because they felt cheap. It did not care whether the notification timing felt respectful or annoying. That stuff — the product instinct, the taste — is still yours to bring.",[11,458,460],{"id":459},"from-repo-to-product","From Repo to Product",[16,462,463,464,468],{},"I have plenty of side projects that live as repos and nothing else. This time I wanted to go further. I designed in Figma first. I set up ",[26,465,467],{"href":105,"rel":466},[35],"batterynotifier.com",". I built proper installers for each platform. I wrote release notes.",[16,470,471],{},"None of that is hard. But it is the gap between something you built and something someone else would actually use. I have always cared more about building products than building code — Battery Notifier was my chance to do that from start to finish.",[11,473,475],{"id":474},"what-stuck-with-me","What Stuck With Me",[16,477,478],{},"My first Figma mockup looked nothing like the final app. I went through more iterations than I expected, but each one got closer to something that felt right. The lesson is obvious in retrospect — the first version is never the good one.",[16,480,481],{},"Cross-platform is still hard. Avalonia handles the UI well, but each OS has its own system tray behaviour, notification quirks, and security model. You cannot abstract that away entirely.",[16,483,484],{},"And honestly — the biggest thing was just committing to shipping. This project sat as a \"someday\" for years. Once I decided it was happening, everything else followed.",[16,486,487,488,493,494,497],{},"The source is on ",[26,489,492],{"href":490,"rel":491},"https:\u002F\u002Fgithub.com\u002Fsandip124\u002Fbatterynotifier",[35],"GitHub"," and the app is at ",[26,495,467],{"href":105,"rel":496},[35],".",[499,500,501],"style",{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}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 .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":149,"searchDepth":183,"depth":183,"links":503},[504,505,506,507,508,509,510,511,512],{"id":13,"depth":189,"text":14},{"id":54,"depth":189,"text":55},{"id":70,"depth":189,"text":71},{"id":95,"depth":189,"text":96},{"id":123,"depth":189,"text":124},{"id":371,"depth":189,"text":372},{"id":449,"depth":189,"text":450},{"id":459,"depth":189,"text":460},{"id":474,"depth":189,"text":475},"Battery Notifier v2 — now on macOS, Windows, and Linux","2026-03-21","In 2022 I built a small battery notifier for myself. In 2026 I finally shipped it as a proper cross-platform product — with Avalonia UI, Duolingo-inspired notifications, and a real design system.",null,"md",{},"\u002Fblog\u002Fshipping-battery-notifier-cross-platform",{"title":5,"description":515},"blog\u002Fshipping-battery-notifier-cross-platform",[523,524,525,526,527],"dotnet","avalonia","desktop","open source","product design","k6lWqbzQDaZV7WWsB6sfhsK26u2n9qB83AtbPGfgReg",[530,866,1469],{"id":4,"title":5,"author":6,"body":531,"coverText":513,"date":514,"description":515,"excerpt":516,"extension":517,"image":443,"meta":863,"navigation":294,"path":519,"readingTime":242,"seo":864,"stem":521,"tags":865,"__hash__":528},{"type":8,"value":532,"toc":852},[533,535,537,539,546,548,554,556,558,560,562,564,566,568,574,576,578,580,582,587,589,595,597,599,601,606,608,768,770,772,774,808,813,819,821,823,825,827,832,834,836,838,840,842,850],[11,534,14],{"id":13},[16,536,18],{},[16,538,21],{},[16,540,24,541,30,543,37],{},[26,542,29],{"href":28},[26,544,36],{"href":33,"rel":545},[35],[16,547,40],{},[16,549,550,552],{},[44,551],{"alt":46,"src":47},[49,553,51],{},[11,555,55],{"id":54},[16,557,58],{},[16,559,61],{},[16,561,64],{},[16,563,67],{},[11,565,71],{"id":70},[16,567,74],{},[16,569,77,570,82,572,86],{},[79,571,81],{},[79,573,85],{},[16,575,89],{},[16,577,92],{},[11,579,96],{"id":95},[16,581,99],{},[16,583,102,584,108],{},[26,585,107],{"href":105,"rel":586},[35],[16,588,111],{},[16,590,591,593],{},[44,592],{"alt":116,"src":117},[49,594,120],{},[11,596,124],{"id":123},[16,598,127],{},[16,600,130],{},[16,602,133,603,139],{},[26,604,138],{"href":136,"rel":605},[35],[16,607,142],{},[144,609,610],{"className":146,"code":147,"language":148,"meta":149,"style":149},[79,611,612,628,632,636,648,660,672,684,696,708,712,716,732,736,740,744],{"__ignoreMap":149},[153,613,614,616,618,620,622,624,626],{"class":155,"line":156},[153,615,160],{"class":159},[153,617,163],{"class":159},[153,619,166],{"class":159},[153,621,170],{"class":169},[153,623,174],{"class":173},[153,625,177],{"class":169},[153,627,180],{"class":159},[153,629,630],{"class":155,"line":183},[153,631,186],{"class":173},[153,633,634],{"class":155,"line":189},[153,635,192],{"class":173},[153,637,638,640,642,644,646],{"class":155,"line":195},[153,639,198],{"class":173},[153,641,201],{"class":169},[153,643,204],{"class":173},[153,645,208],{"class":207},[153,647,211],{"class":173},[153,649,650,652,654,656,658],{"class":155,"line":214},[153,651,198],{"class":173},[153,653,201],{"class":169},[153,655,204],{"class":173},[153,657,223],{"class":207},[153,659,211],{"class":173},[153,661,662,664,666,668,670],{"class":155,"line":228},[153,663,198],{"class":173},[153,665,201],{"class":169},[153,667,204],{"class":173},[153,669,237],{"class":207},[153,671,211],{"class":173},[153,673,674,676,678,680,682],{"class":155,"line":242},[153,675,198],{"class":173},[153,677,201],{"class":169},[153,679,204],{"class":173},[153,681,251],{"class":207},[153,683,211],{"class":173},[153,685,686,688,690,692,694],{"class":155,"line":256},[153,687,198],{"class":173},[153,689,201],{"class":169},[153,691,204],{"class":173},[153,693,265],{"class":207},[153,695,211],{"class":173},[153,697,698,700,702,704,706],{"class":155,"line":270},[153,699,198],{"class":173},[153,701,201],{"class":169},[153,703,204],{"class":173},[153,705,279],{"class":207},[153,707,282],{"class":173},[153,709,710],{"class":155,"line":285},[153,711,288],{"class":173},[153,713,714],{"class":155,"line":291},[153,715,295],{"emptyLinePlaceholder":294},[153,717,718,720,722,724,726,728,730],{"class":155,"line":298},[153,719,160],{"class":159},[153,721,303],{"class":159},[153,723,306],{"class":159},[153,725,309],{"class":169},[153,727,312],{"class":159},[153,729,315],{"class":207},[153,731,318],{"class":173},[153,733,734],{"class":155,"line":321},[153,735,295],{"emptyLinePlaceholder":294},[153,737,738],{"class":155,"line":326},[153,739,330],{"class":329},[153,741,742],{"class":155,"line":333},[153,743,336],{"class":329},[153,745,746,748,750,752,754,756,758,760,762,764,766],{"class":155,"line":339},[153,747,160],{"class":159},[153,749,163],{"class":159},[153,751,166],{"class":159},[153,753,170],{"class":169},[153,755,350],{"class":169},[153,757,312],{"class":159},[153,759,355],{"class":173},[153,761,358],{"class":169},[153,763,204],{"class":173},[153,765,208],{"class":207},[153,767,365],{"class":173},[16,769,368],{},[11,771,372],{"id":371},[16,773,375],{},[377,775,776,780,784,788,792,796,800,804],{},[380,777,778,386],{},[383,779,385],{},[380,781,782,392],{},[383,783,391],{},[380,785,786,398],{},[383,787,397],{},[380,789,790,404],{},[383,791,403],{},[380,793,794,410],{},[383,795,409],{},[380,797,798,416],{},[383,799,415],{},[380,801,802,422],{},[383,803,421],{},[380,805,806,428],{},[383,807,427],{},[16,809,431,810,437],{},[26,811,436],{"href":434,"rel":812},[35],[16,814,815,817],{},[44,816],{"alt":442,"src":443},[49,818,446],{},[11,820,450],{"id":449},[16,822,453],{},[16,824,456],{},[11,826,460],{"id":459},[16,828,463,829,468],{},[26,830,467],{"href":105,"rel":831},[35],[16,833,471],{},[11,835,475],{"id":474},[16,837,478],{},[16,839,481],{},[16,841,484],{},[16,843,487,844,493,847,497],{},[26,845,492],{"href":490,"rel":846},[35],[26,848,467],{"href":105,"rel":849},[35],[499,851,501],{},{"title":149,"searchDepth":183,"depth":183,"links":853},[854,855,856,857,858,859,860,861,862],{"id":13,"depth":189,"text":14},{"id":54,"depth":189,"text":55},{"id":70,"depth":189,"text":71},{"id":95,"depth":189,"text":96},{"id":123,"depth":189,"text":124},{"id":371,"depth":189,"text":372},{"id":449,"depth":189,"text":450},{"id":459,"depth":189,"text":460},{"id":474,"depth":189,"text":475},{},{"title":5,"description":515},[523,524,525,526,527],{"id":867,"title":868,"author":6,"body":869,"coverText":1455,"date":1456,"description":1457,"excerpt":516,"extension":517,"image":1458,"meta":1459,"navigation":294,"path":1460,"readingTime":242,"seo":1461,"stem":1462,"tags":1463,"__hash__":1468},"blog\u002Fblog\u002Fbuilding-subscription-ecommerce.md","Lessons from Building a Subscription eCommerce from Scratch",{"type":8,"value":870,"toc":1445},[871,875,884,887,891,894,897,900,904,907,933,936,940,943,946,952,962,1245,1255,1259,1262,1265,1268,1271,1400,1404,1410,1416,1422,1426,1429,1432,1436,1439,1442],[11,872,874],{"id":873},"the-brief","The Brief",[16,876,877,878,883],{},"The client runs ",[26,879,882],{"href":880,"rel":881},"https:\u002F\u002Fboneappetitedk.com\u002F",[35],"Boneappetitedk",", a Copenhagen-based company that sells customised dog food. The core feature is a calorie calculator — owners enter their dog's breed, weight, and activity level, and the shop recommends a meal plan. Customers then subscribe to receive that plan on a recurring basis.",[16,885,886],{},"Simple concept. Surprisingly hard to execute with existing platforms.",[11,888,890],{"id":889},"why-platforms-failed-us","Why Platforms Failed Us",[16,892,893],{},"My first instinct was Shopify. I had used it before, it handles payments well, and the ecosystem is enormous. But the subscription model required plugins, and the plugins that came close to what we needed were either too expensive, too generic, or both. Customising the calorie calculation logic on top of a plugin layer would have meant fighting the platform constantly.",[16,895,896],{},"I looked at MedusaJS as a self-hosted alternative. At the time the available version was 1.x, and the documentation for subscriptions was sparse. The project timeline did not allow for that much exploration.",[16,898,899],{},"In the end, the cleanest path was a custom build.",[11,901,903],{"id":902},"the-stack-decision","The Stack Decision",[16,905,906],{},"I chose what I knew well:",[377,908,909,915,921,927],{},[380,910,911,914],{},[383,912,913],{},".NET Core"," for the API — I had years of production experience with it and trusted its performance",[380,916,917,920],{},[383,918,919],{},"Vue.js"," for the frontend — component-based, approachable, fast to iterate",[380,922,923,926],{},[383,924,925],{},"PostgreSQL"," for the database — open source, reliable, excellent JSON support for the dynamic meal plan data",[380,928,929,932],{},[383,930,931],{},"Stripe"," for payments — I had integrated it before and its subscription primitives are genuinely good",[16,934,935],{},"The decision to fully decouple frontend and backend was deliberate. The client's requirements would change — they always do — and keeping the UI layer independent meant we could redesign or replace it without touching the business logic.",[11,937,939],{"id":938},"stripe-subscriptions-are-surprisingly-deep","Stripe Subscriptions Are Surprisingly Deep",[16,941,942],{},"I expected Stripe to be the easy part. It mostly was, but subscriptions have more surface area than one-off payments.",[16,944,945],{},"A few things that took longer than expected:",[16,947,948,951],{},[383,949,950],{},"Proration",". When a customer changes their meal plan mid-cycle, Stripe can automatically calculate what they owe or are owed for the remainder of the billing period. Getting this to feel natural in the UI — showing the customer a clear preview of the charge before they confirm — required careful orchestration between the frontend, the API, and Stripe's preview invoice endpoint.",[16,953,954,957,958,961],{},[383,955,956],{},"Webhooks are the source of truth",". Your payment intent might succeed on the frontend, but the subscription is not active until Stripe fires ",[79,959,960],{},"customer.subscription.created"," and your webhook handler processes it. I learned early to never update subscription state based on the API response alone. The webhook is what drives database state — everything else is optimistic UI.",[144,963,965],{"className":146,"code":964,"language":148,"meta":149,"style":149},"[HttpPost(\"webhook\")]\npublic async Task\u003CIActionResult> HandleStripeWebhook()\n{\n    var json = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync();\n    var stripeEvent = EventUtility.ConstructEvent(\n        json,\n        Request.Headers[\"Stripe-Signature\"],\n        _webhookSecret\n    );\n\n    switch (stripeEvent.Type)\n    {\n        case Events.CustomerSubscriptionCreated:\n            var subscription = (Subscription)stripeEvent.Data.Object;\n            await _subscriptionService.ActivateAsync(subscription.Id);\n            break;\n\n        case Events.InvoicePaymentFailed:\n            var invoice = (Invoice)stripeEvent.Data.Object;\n            await _notificationService.SendPaymentFailedEmail(invoice.CustomerEmail);\n            break;\n    }\n\n    return Ok();\n}\n",[79,966,967,984,1010,1015,1043,1061,1066,1077,1082,1087,1091,1099,1104,1120,1139,1153,1160,1165,1179,1196,1210,1217,1223,1228,1239],{"__ignoreMap":149},[153,968,969,972,975,977,981],{"class":155,"line":156},[153,970,971],{"class":173},"[",[153,973,974],{"class":169},"HttpPost",[153,976,204],{"class":173},[153,978,980],{"class":979},"sZZnC","\"webhook\"",[153,982,983],{"class":173},")]\n",[153,985,986,989,992,995,998,1001,1004,1007],{"class":155,"line":183},[153,987,988],{"class":159},"public",[153,990,991],{"class":159}," async",[153,993,994],{"class":169}," Task",[153,996,997],{"class":173},"\u003C",[153,999,1000],{"class":169},"IActionResult",[153,1002,1003],{"class":173},"> ",[153,1005,1006],{"class":169},"HandleStripeWebhook",[153,1008,1009],{"class":173},"()\n",[153,1011,1012],{"class":155,"line":189},[153,1013,1014],{"class":173},"{\n",[153,1016,1017,1020,1023,1025,1028,1031,1034,1037,1040],{"class":155,"line":195},[153,1018,1019],{"class":159},"    var",[153,1021,1022],{"class":169}," json",[153,1024,312],{"class":159},[153,1026,1027],{"class":159}," await",[153,1029,1030],{"class":159}," new",[153,1032,1033],{"class":169}," StreamReader",[153,1035,1036],{"class":173},"(HttpContext.Request.Body).",[153,1038,1039],{"class":169},"ReadToEndAsync",[153,1041,1042],{"class":173},"();\n",[153,1044,1045,1047,1050,1052,1055,1058],{"class":155,"line":214},[153,1046,1019],{"class":159},[153,1048,1049],{"class":169}," stripeEvent",[153,1051,312],{"class":159},[153,1053,1054],{"class":173}," EventUtility.",[153,1056,1057],{"class":169},"ConstructEvent",[153,1059,1060],{"class":173},"(\n",[153,1062,1063],{"class":155,"line":228},[153,1064,1065],{"class":173},"        json,\n",[153,1067,1068,1071,1074],{"class":155,"line":242},[153,1069,1070],{"class":173},"        Request.Headers[",[153,1072,1073],{"class":979},"\"Stripe-Signature\"",[153,1075,1076],{"class":173},"],\n",[153,1078,1079],{"class":155,"line":256},[153,1080,1081],{"class":173},"        _webhookSecret\n",[153,1083,1084],{"class":155,"line":270},[153,1085,1086],{"class":173},"    );\n",[153,1088,1089],{"class":155,"line":285},[153,1090,295],{"emptyLinePlaceholder":294},[153,1092,1093,1096],{"class":155,"line":291},[153,1094,1095],{"class":159},"    switch",[153,1097,1098],{"class":173}," (stripeEvent.Type)\n",[153,1100,1101],{"class":155,"line":298},[153,1102,1103],{"class":173},"    {\n",[153,1105,1106,1109,1112,1114,1117],{"class":155,"line":321},[153,1107,1108],{"class":159},"        case",[153,1110,1111],{"class":169}," Events",[153,1113,497],{"class":173},[153,1115,1116],{"class":169},"CustomerSubscriptionCreated",[153,1118,1119],{"class":173},":\n",[153,1121,1122,1125,1128,1130,1133,1136],{"class":155,"line":326},[153,1123,1124],{"class":159},"            var",[153,1126,1127],{"class":169}," subscription",[153,1129,312],{"class":159},[153,1131,1132],{"class":173}," (",[153,1134,1135],{"class":169},"Subscription",[153,1137,1138],{"class":173},")stripeEvent.Data.Object;\n",[153,1140,1141,1144,1147,1150],{"class":155,"line":333},[153,1142,1143],{"class":159},"            await",[153,1145,1146],{"class":173}," _subscriptionService.",[153,1148,1149],{"class":169},"ActivateAsync",[153,1151,1152],{"class":173},"(subscription.Id);\n",[153,1154,1155,1158],{"class":155,"line":339},[153,1156,1157],{"class":159},"            break",[153,1159,318],{"class":173},[153,1161,1163],{"class":155,"line":1162},17,[153,1164,295],{"emptyLinePlaceholder":294},[153,1166,1168,1170,1172,1174,1177],{"class":155,"line":1167},18,[153,1169,1108],{"class":159},[153,1171,1111],{"class":169},[153,1173,497],{"class":173},[153,1175,1176],{"class":169},"InvoicePaymentFailed",[153,1178,1119],{"class":173},[153,1180,1182,1184,1187,1189,1191,1194],{"class":155,"line":1181},19,[153,1183,1124],{"class":159},[153,1185,1186],{"class":169}," invoice",[153,1188,312],{"class":159},[153,1190,1132],{"class":173},[153,1192,1193],{"class":169},"Invoice",[153,1195,1138],{"class":173},[153,1197,1199,1201,1204,1207],{"class":155,"line":1198},20,[153,1200,1143],{"class":159},[153,1202,1203],{"class":173}," _notificationService.",[153,1205,1206],{"class":169},"SendPaymentFailedEmail",[153,1208,1209],{"class":173},"(invoice.CustomerEmail);\n",[153,1211,1213,1215],{"class":155,"line":1212},21,[153,1214,1157],{"class":159},[153,1216,318],{"class":173},[153,1218,1220],{"class":155,"line":1219},22,[153,1221,1222],{"class":173},"    }\n",[153,1224,1226],{"class":155,"line":1225},23,[153,1227,295],{"emptyLinePlaceholder":294},[153,1229,1231,1234,1237],{"class":155,"line":1230},24,[153,1232,1233],{"class":159},"    return",[153,1235,1236],{"class":169}," Ok",[153,1238,1042],{"class":173},[153,1240,1242],{"class":155,"line":1241},25,[153,1243,1244],{"class":173},"}\n",[16,1246,1247,1250,1251,1254],{},[383,1248,1249],{},"Failed payments need a recovery flow",". Stripe retries failed payments automatically, but you need to handle the ",[79,1252,1253],{},"invoice.payment_failed"," event and communicate that to the customer gracefully. Building that email flow and the \"update your payment method\" screen was unglamorous work but important.",[11,1256,1258],{"id":1257},"the-colour-token-breakthrough","The Colour Token Breakthrough",[16,1260,1261],{},"Partway through the project the client started requesting frequent colour scheme changes. Not huge redesigns — just \"can we try the buttons in a darker orange?\" or \"can the hero section feel warmer?\". Each change meant hunting through components and updating values manually.",[16,1263,1264],{},"I had been using PrimeVue for the component layer. PrimeVue 4.0 was in beta at the time and introduced design tokens — a theming system where you define your palette once and every component that uses those tokens updates automatically.",[16,1266,1267],{},"Migrating to it took less than half a day. From that point on, responding to a colour change request meant updating a handful of token values in one file. It sounds like a small thing but it fundamentally changed how the client relationship felt. Instead of change requests being a source of dread, they became trivial.",[16,1269,1270],{},"If you are building a long-running client project with a design system, design tokens are not optional. They are the minimum viable foundation.",[144,1272,1276],{"className":1273,"code":1274,"language":1275,"meta":149,"style":149},"language-javascript shiki shiki-themes github-light github-dark","\u002F\u002F One file controls the entire colour scheme\nconst MyPreset = definePreset(Aura, {\n    semantic: {\n        primary: {\n            50:  '#fef6ee',\n            100: '#fdead7',\n            \u002F\u002F ...\n            500: '#e87b2f',  \u002F\u002F ← client says \"darker orange\" — change here, done\n            600: '#d4631a',\n            \u002F\u002F ...\n            950: '#431407',\n        },\n    },\n});\n","javascript",[79,1277,1278,1283,1299,1304,1309,1323,1336,1341,1357,1369,1373,1385,1390,1395],{"__ignoreMap":149},[153,1279,1280],{"class":155,"line":156},[153,1281,1282],{"class":329},"\u002F\u002F One file controls the entire colour scheme\n",[153,1284,1285,1288,1291,1293,1296],{"class":155,"line":183},[153,1286,1287],{"class":159},"const",[153,1289,1290],{"class":207}," MyPreset",[153,1292,312],{"class":159},[153,1294,1295],{"class":169}," definePreset",[153,1297,1298],{"class":173},"(Aura, {\n",[153,1300,1301],{"class":155,"line":189},[153,1302,1303],{"class":173},"    semantic: {\n",[153,1305,1306],{"class":155,"line":195},[153,1307,1308],{"class":173},"        primary: {\n",[153,1310,1311,1314,1317,1320],{"class":155,"line":214},[153,1312,1313],{"class":207},"            50",[153,1315,1316],{"class":173},":  ",[153,1318,1319],{"class":979},"'#fef6ee'",[153,1321,1322],{"class":173},",\n",[153,1324,1325,1328,1331,1334],{"class":155,"line":228},[153,1326,1327],{"class":207},"            100",[153,1329,1330],{"class":173},": ",[153,1332,1333],{"class":979},"'#fdead7'",[153,1335,1322],{"class":173},[153,1337,1338],{"class":155,"line":242},[153,1339,1340],{"class":329},"            \u002F\u002F ...\n",[153,1342,1343,1346,1348,1351,1354],{"class":155,"line":256},[153,1344,1345],{"class":207},"            500",[153,1347,1330],{"class":173},[153,1349,1350],{"class":979},"'#e87b2f'",[153,1352,1353],{"class":173},",  ",[153,1355,1356],{"class":329},"\u002F\u002F ← client says \"darker orange\" — change here, done\n",[153,1358,1359,1362,1364,1367],{"class":155,"line":270},[153,1360,1361],{"class":207},"            600",[153,1363,1330],{"class":173},[153,1365,1366],{"class":979},"'#d4631a'",[153,1368,1322],{"class":173},[153,1370,1371],{"class":155,"line":285},[153,1372,1340],{"class":329},[153,1374,1375,1378,1380,1383],{"class":155,"line":291},[153,1376,1377],{"class":207},"            950",[153,1379,1330],{"class":173},[153,1381,1382],{"class":979},"'#431407'",[153,1384,1322],{"class":173},[153,1386,1387],{"class":155,"line":298},[153,1388,1389],{"class":173},"        },\n",[153,1391,1392],{"class":155,"line":321},[153,1393,1394],{"class":173},"    },\n",[153,1396,1397],{"class":155,"line":326},[153,1398,1399],{"class":173},"});\n",[11,1401,1403],{"id":1402},"what-i-would-do-differently","What I Would Do Differently",[16,1405,1406,1409],{},[383,1407,1408],{},"Start with a proper domain model."," Early on I let the database schema drift close to the UI shape — tables that mapped to forms rather than to business concepts. Refactoring the subscription and meal plan models halfway through the project cost time that a clearer upfront design would have saved.",[16,1411,1412,1415],{},[383,1413,1414],{},"Write integration tests for the Stripe webhook handlers from day one."," I added them later, but the period before I had them was genuinely stressful. Stripe's test event tooling makes this easy. There is no excuse not to have them.",[16,1417,1418,1421],{},[383,1419,1420],{},"Be more explicit with the client about scope."," The calorie calculator logic evolved significantly during the project as the client better understood what their customers actually needed. That is normal and fine — but some of those changes touched the database schema and required migration work. Better upfront documentation of what was in and out of scope would have made those conversations easier.",[11,1423,1425],{"id":1424},"the-part-i-enjoyed-most","The Part I Enjoyed Most",[16,1427,1428],{},"Honestly? The calorie calculation logic itself. Working through the nutritional science, talking with the client about how different breeds and activity levels translate into daily calorie needs, and then encoding that into a clean service layer — that was the most intellectually satisfying part of the project.",[16,1430,1431],{},"Software is often just plumbing. Occasionally you get to build something where the domain itself is interesting, and the code becomes a direct expression of real knowledge. This was one of those times.",[11,1433,1435],{"id":1434},"wrapping-up","Wrapping Up",[16,1437,1438],{},"Building from scratch is not always the right answer. Platforms exist for good reasons and most eCommerce projects are better served by them. But when the core feature is genuinely bespoke and the platform fight would cost more than a custom build, rolling your own is a legitimate choice — provided you have the discipline to do it properly.",[16,1440,1441],{},"The Boneappetitedk project is still running and still evolving. That it has held up to changing requirements without major rewrites is the best validation I can ask for.",[499,1443,1444],{},"html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}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 .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}",{"title":149,"searchDepth":183,"depth":183,"links":1446},[1447,1448,1449,1450,1451,1452,1453,1454],{"id":873,"depth":189,"text":874},{"id":889,"depth":189,"text":890},{"id":902,"depth":189,"text":903},{"id":938,"depth":189,"text":939},{"id":1257,"depth":189,"text":1258},{"id":1402,"depth":189,"text":1403},{"id":1424,"depth":189,"text":1425},{"id":1434,"depth":189,"text":1435},"snapshot of Boneappetitedk","2026-03-10","No Shopify. No WooCommerce. Just .NET Core, Vue.js, PostgreSQL, and a lot of decisions I had to make myself. Here is what I learned building Boneappetitedk.","\u002Fimg\u002FBoneappetiteBannerWide.png",{},"\u002Fblog\u002Fbuilding-subscription-ecommerce",{"title":868,"description":1457},"blog\u002Fbuilding-subscription-ecommerce",[523,1464,1465,1466,1467],"vue","ecommerce","stripe","lessons learned","Dlfdq2GDMERCEbakYWgPxfmRE4wowTP2uRDwQhU6gjM",{"id":1470,"title":1471,"author":6,"body":1472,"coverText":1565,"date":1566,"description":1567,"excerpt":516,"extension":517,"image":1568,"meta":1569,"navigation":294,"path":1570,"readingTime":195,"seo":1571,"stem":1572,"tags":1573,"__hash__":1577},"blog\u002Fblog\u002Fwhy-i-write.md","Why I Finally Started Writing Online",{"type":8,"value":1473,"toc":1559},[1474,1478,1481,1484,1487,1490,1494,1501,1504,1510,1513,1517,1520,1523,1526,1529,1533,1536,1539,1542,1553,1556],[11,1475,1477],{"id":1476},"the-notebooks","The Notebooks",[16,1479,1480],{},"I have been keeping physical notebooks since 2018. Small pocket-sized ones that I carry everywhere. When I run into a tricky bug, I sketch it out on paper. When I learn something new — a pattern, a concept, a gotcha — I write it down by hand.",[16,1482,1483],{},"There is something about the act of writing by hand that forces you to slow down and actually understand what you are putting on the page. You cannot paste a Stack Overflow answer into a notebook. You have to distil it into your own words.",[16,1485,1486],{},"Over the years I have filled more notebooks than I can count. Ideas for projects, architecture diagrams, notes from conference talks, half-finished essays about things I was thinking about.",[16,1488,1489],{},"The problem? Once it was in a notebook it was essentially gone. I could find it again if I remembered roughly when I wrote it, but sharing it with anyone — or even searching it — was impossible.",[11,1491,1493],{"id":1492},"the-moment-i-changed-my-mind","The Moment I Changed My Mind",[16,1495,1496,1497,1500],{},"I was working on the ",[26,1498,882],{"href":880,"rel":1499},[35]," project, trying to figure out why PrimeVue's design tokens were not being applied in the right order during SSR hydration. I spent the better part of a day on it, eventually solved it, and wrote three pages of notes about what I found.",[16,1502,1503],{},"Six months later a friend asked me about a similar issue with a different UI library. I remembered solving something like it. I dug out the notebook, found the entry, and read it back to him over a voice call — squinting at my own handwriting.",[16,1505,1506,1507,497],{},"That was the moment I thought: ",[49,1508,1509],{},"this should be online",[16,1511,1512],{},"Not because the internet needed another developer blog. But because future-me and the people I work with would benefit from being able to search it, link to it, share it.",[11,1514,1516],{"id":1515},"what-stopped-me-honestly","What Stopped Me (Honestly)",[16,1518,1519],{},"Perfectionism, mostly. I kept thinking I needed to write long, well-researched, authoritative posts. The kind that get shared on Hacker News. The kind with benchmarks and citations.",[16,1521,1522],{},"That bar is impossibly high if you are also trying to hold down a job, work on side projects, and occasionally touch grass.",[16,1524,1525],{},"What changed my perspective was reading that the most useful content online is usually not the polished technical deep-dive — it is the \"I just figured this out and here is what I found\" note. The kind of thing you write ten minutes after solving a problem, while the details are still vivid.",[16,1527,1528],{},"That is exactly what my notebooks contain.",[11,1530,1532],{"id":1531},"how-i-plan-to-write-here","How I Plan to Write Here",[16,1534,1535],{},"Short. Honest. Close to the original notebook entry in spirit.",[16,1537,1538],{},"I am not trying to build an audience or a brand. I am trying to create a searchable, shareable version of the notebooks I have already been keeping.",[16,1540,1541],{},"Posts will mostly be:",[377,1543,1544,1547,1550],{},[380,1545,1546],{},"Things I learned while building something",[380,1548,1549],{},"Opinions I formed over time that I want to articulate properly",[380,1551,1552],{},"Notes on tools, patterns, or decisions I keep revisiting",[16,1554,1555],{},"I will not be posting on a schedule. I will post when something is worth writing down — which, based on the notebooks, is more often than I realise.",[16,1557,1558],{},"If something I write here saves you an afternoon of debugging, or makes you think about a problem differently, that is more than enough reason for it to exist.",{"title":149,"searchDepth":183,"depth":183,"links":1560},[1561,1562,1563,1564],{"id":1476,"depth":189,"text":1477},{"id":1492,"depth":189,"text":1493},{"id":1515,"depth":189,"text":1516},{"id":1531,"depth":189,"text":1532},"my pocket notebook where it all started 📓","2025-01-15","I have kept physical notebooks for years. Here is what pushed me to start publishing my thoughts online, and why I think every developer should too.","\u002Fimg\u002Fblog_cover.jpg",{},"\u002Fblog\u002Fwhy-i-write",{"title":1471,"description":1567},"blog\u002Fwhy-i-write",[1574,1575,1576],"writing","personal","developer life","ozW4Ct-R4NJDOL7hU-UBO1NBd-kGmVtdUMmCIOI0BXg",1775115797373]