The Week I Deleted 3,500 Lines of Code

Removing a feature is harder than adding one — the story of a refactor done with Claude Code, and what replaced the user account


A few months ago I shipped VoxMap: an interactive map where each of France’s ~35,000 municipalities is colored by how it voted, fed by a pipeline that aggregates a dozen public data sources. Built alone, evenings and weekends, written with Claude Code. I wrote a first article about what building a production app with an AI actually looks like.

Then I looked at the numbers.

People opened the app, hit a sign-up screen — email, password, verification code, home town — and left. The data was public, but access required something private. To see how a village had voted, you had to create an account. It was absurd, and I had built it myself.

So the following week, I added nothing. I deleted.


Deleting means dismantling a part that holds up ten others

Adding a feature is writing new code in an empty corner. Removing one is discovering everything that leaned on it without you knowing.

In VoxMap, the user account was load-bearing. It wasn’t just for “logging in”: it was the identity that the premium subscription, the push tokens, the preferences, the home town, the unlocking of enriched data, the device limit, the email verification and the biometric lock all hung from. Pull that thread and ten others come with it.

The real difficulty isn’t deleting thirty-one files. It’s answering, for each of those ten threads, the question: “so what does this attach to now?”

  • The premium subscription was tied to a user_id. What do you tie it to when there is no user anymore?
  • The server had to stop people from scraping its data in a loop. Without accounts, what distinguishes a genuine app from a script that mints 10,000 fake identities?
  • GDPR deletion went through an email to the DPO. Without an email, how does someone ask for their data to be erased?

Each of those questions is a small project. End to end, it adds up to 3,500 lines removed — and considerably more written elsewhere to keep the promises the account used to keep. That’s the paradox of deletion: the net balance is negative, but you worked more than you would have to add.

Where the AI changes the game isn’t typing speed. It’s that it holds the dependency map while you take things apart. “Remove the login screen” isn’t an instruction: it’s an investigation across forty files to find everything that assumed a user existed. An AI that reads the whole repository at once misses fewer threads than a human at 11 pm.


What “no account” actually changed

For those who like to see the part laid out on the bench, here are the ten threads, before and after.

  • Identity. Before: email, password, verification code, biometric unlock. After: an installation identifier — a UUID the app generates on first launch and keeps in the phone’s keychain. It names an installation, never a person.
  • Session. Before: a login that returned a token. After: POST /auth/anonymous, carrying an attestation proof (more on that below), which returns a one-hour token and a refresh token. Nothing to type, nothing to remember, nothing to steal.
  • Abuse. The login was also my wall against scraping. After: rate limiting per IP address on authentication, per token bearer on data — and above all attestation, which makes minting identities expensive.
  • Premium. Before: tied to the user_id. After: tied to the anonymous identity the payment provider already assigns to every installation, which the app reports to the server.
  • Notifications. One push token per installation, not per user.
  • Home town. Before: stored in the database, attached to the account. After: on the phone, and nowhere else. The server no longer knows it.
  • GDPR deletion. Before: an email to the DPO. After: a button, an endpoint, immediate effect.
  • Device limit, email verification, biometrics. Removed. They only made sense to protect an account.
  • Administration. The “Users” tab became a “Devices” tab: you see installations, versions, dates — not people.
  • The tally. Thirty-one files deleted, 3,500 lines gone; a few hundred added for attestation. The app does the same thing it did before. It just asks for a lot less.

What replaced the account: prove the app, not the user

Here is the heart of the problem. An account isn’t only friction — it’s also proof. The password proved the request really came from the person. By removing it, I was losing my only defense against abuse: what now stops anyone from hitting my API and draining its data, or generating identities without limit?

The answer isn’t to identify the person. It’s to prove that the request comes from a genuine instance of my app, installed from an official store, on a real device — without ever knowing who holds it.

It has a name: app attestation. App Attest at Apple, Play Integrity at Google. The operating system cryptographically signs a statement that “this app is yours, unmodified, on an authentic device”. The server verifies that statement before opening an anonymous session.

The protocol has three beats, identical on both platforms. The app asks the server for a challenge — thirty-two random bytes, valid for two minutes, single-use. The OS produces a proof bound to that challenge. The server verifies the proof, consumes the challenge, and opens the session only if everything checks out. The challenge is what makes the attestation non-replayable: an intercepted proof is worthless without it, and it only works once.

<code>public (Guid Id, byte[] Challenge) Issue()
{
    var id = Guid.NewGuid();
    var challenge = RandomNumberGenerator.GetBytes(32);
    cache.Set(Key(id), challenge, TimeSpan.FromMinutes(2));
    return (id, challenge);
}

<em>// Consumed on first verification: a replayed token</em>
<em>// no longer finds its challenge.</em>
public byte[]? TryConsume(Guid id)
{
    if (!cache.TryGetValue(Key(id), out byte[]? challenge)) return null;
    cache.Remove(Key(id));
    return challenge;
}</code>
Code language: PHP (php)

On paper, elegant. In practice, this is where the evening got long.

iOS: App Attest, by hand

On the app side, Apple provides DCAppAttestService. On first contact, the app creates a key inside the Secure Enclave and asks Apple to attest it; on subsequent launches, it only produces an assertion signed by that key. In both cases, what it signs is the hash of our challenge.

var challenge = await http.GetFromJsonAsync<ChallengeResponse>("/auth/attestation-challenge");
var clientDataHash = NSData.FromArray(
    SHA256.HashData(Convert.FromBase64String(challenge.Challenge)));

var keyId = Preferences.Get("AppAttestKeyId", string.Empty);
if (!string.IsNullOrEmpty(keyId))
{
    <em>// Subsequent launches: an assertion signed by the Secure Enclave key.</em>
    var assertion = await DCAppAttestService.SharedService
        .GenerateAssertionAsync(keyId, clientDataHash);
    return Envelope("assert", challenge.ChallengeId, keyId, assertion);
}

<em>// First contact: a fresh key, attested by Apple.</em>
var newKeyId = await DCAppAttestService.SharedService.GenerateKeyAsync();
var attestation = await DCAppAttestService.SharedService
    .AttestKeyAsync(newKeyId, clientDataHash);
Preferences.Set("AppAttestKeyId", newKeyId);
return Envelope("attest", challenge.ChallengeId, newKeyId, attestation);
Code language: JavaScript (javascript)

On the server side, App Attest can’t be verified with a simple API call. You have to decode a CBOR object, validate an X.509 certificate chain up to Apple’s root, recompute a nonce by hashing, check that it matches a specific extension of the certificate, then keep an anti-replay counter per device. And surprise: no maintained .NET library does this. I looked — nothing. Kotlin, Go, Rust have their implementations. .NET doesn’t.

So we wrote it by hand. A complete verifier, from Apple’s documentation, with the platform’s raw cryptographic primitives. Condensed, the initial attestation looks like this:

<em>// 1. CBOR: { fmt: "apple-appattest", attStmt: { x5c }, authData }</em>
var (fmt, x5c, authData) = ReadAttestationCbor(cbor);
if (fmt != "apple-appattest" || x5c.Count < 2) return false;

<em>// 2. Certificate chain up to the "Apple App Attestation Root CA",</em>
<em>//    embedded in the binary: validation never depends on a download.</em>
using var leaf = X509CertificateLoader.LoadCertificate(x5c[0]);
using var chain = new X509Chain();
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
chain.ChainPolicy.CustomTrustStore.Add(appleRoot);
foreach (var extra in x5c.Skip(1))
    chain.ChainPolicy.ExtraStore.Add(X509CertificateLoader.LoadCertificate(extra));
if (!chain.Build(leaf)) return false;

<em>// 3. nonce = SHA256(authData ‖ clientDataHash), expected in an extension</em>
<em>//    of the leaf certificate (OID 1.2.840.113635.100.8.2).</em>
var nonce = SHA256.HashData([.. authData, .. clientDataHash]);
var ext = leaf.Extensions["1.2.840.113635.100.8.2"];
if (ext is null || !ExtensionContainsNonce(ext.RawData, nonce)) return false;

<em>// 4. authData: rpIdHash = SHA256("TEAMID.bundleId"), initial counter 0,</em>
<em>//    credentialId = keyId.</em>
var rpIdHash = SHA256.HashData(Encoding.UTF8.GetBytes(appId));
if (!authData.AsSpan(0, 32).SequenceEqual(rpIdHash)) return false;

<em>// 5. The leaf certificate's public key will sign every future assertion:</em>
<em>//    store it for this installation.</em>
device.AppAttestPublicKey = leaf.GetECDsaPublicKey()!.ExportSubjectPublicKeyInfo();
device.AppAttestCounter = 0;
Code language: JavaScript (javascript)

Then, on every launch, the assertion. It’s shorter to verify, but it carries the most important piece of the whole scheme: the counter. Apple increments it with every assertion; if it doesn’t strictly increase, someone is replaying a token.

<em>// CBOR: { signature, authenticatorData }</em>
var nonce = SHA256.HashData([.. authData, .. clientDataHash]);
using var ecdsa = ECDsa.Create();
ecdsa.ImportSubjectPublicKeyInfo(device.AppAttestPublicKey, out _);
if (!ecdsa.VerifyData(nonce, signature, HashAlgorithmName.SHA256,
        DSASignatureFormat.Rfc3279DerSequence))
    return false;

<em>// A counter that doesn't increase = a replay.</em>
long counter = BinaryPrimitives.ReadUInt32BigEndian(authData.AsSpan(33, 4));
if (counter <= device.AppAttestCounter) return false;
device.AppAttestCounter = counter;
Code language: PHP (php)

This is exactly the kind of task where an AI is at its best: a closed, specified, verifiable problem. Apple’s spec says what must happen, step by step; each step is a refusal if it fails. No product ambiguity, no “it depends on taste”. Claude wrote the CBOR validation, the certificate chain construction, the nonce check; I read every line against the docs and supplied the context it couldn’t guess — my Team ID, the attestation environment, the shape of my tokens.

Android: Play Integrity, with Google in the middle

On Android the work is split differently: the token is opaque, and it’s Google’s API that decodes it for you. What remains on your side is binding the token to your challenge — the famous nonce — and then reading the verdicts.

var challenge = await http.GetFromJsonAsync<ChallengeResponse>("/auth/attestation-challenge");
<em>// The nonce binds the token to OUR challenge: base64url, no padding.</em>
var nonce = Convert.ToBase64String(Convert.FromBase64String(challenge.Challenge))
    .TrimEnd('=').Replace('+', '-').Replace('/', '_');

var manager = IntegrityManagerFactory.Create(Application.Context);
var request = IntegrityTokenRequest.InvokeBuilder().SetNonce(nonce).Build();
var response = await manager.RequestIntegrityToken(request)
    .AsAsync<IntegrityTokenResponse>();
return Envelope(challenge.ChallengeId, response.Token());
Code language: JavaScript (javascript)

On the server, we have Google decode the token with the project’s service account, then check — in this order — that it answers our challenge, targets our package, is fresh, that the app is the one from the Play Store, and that the device is sound.

var payload = await DecodeIntegrityTokenAsync(envelope.Token);   <em>// POST …/{package}:decodeIntegrityToken</em>

var details = payload.GetProperty("requestDetails");
if (details.GetProperty("nonce").GetString()?.TrimEnd('=') != expectedNonce) return false;
if (details.GetProperty("requestPackageName").GetString() != packageName) return false;
if (Math.Abs((DateTimeOffset.UtcNow - issuedAt).TotalMinutes) > 10) return false;

<em>// The app really is the Play Store build, unmodified…</em>
var app = payload.GetProperty("appIntegrity").GetProperty("appRecognitionVerdict").GetString();
if (app != "PLAY_RECOGNIZED") return false;

<em>// … on a device that passes integrity.</em>
var verdicts = payload.GetProperty("deviceIntegrity").GetProperty("deviceRecognitionVerdict");
if (!verdicts.EnumerateArray().Any(v => v.GetString() == "MEETS_DEVICE_INTEGRITY")) return false;
Code language: JavaScript (javascript)

One detail that matters: a development build installed by hand comes back as UNRECOGNIZED_VERSION, and the server refuses it. That’s intentional. Test devices go through an explicit list, maintained by an administrator — never through an exception in the code.

Shadow mode

The detail I’m proudest of isn’t the crypto. It’s shadow mode — and it deserves more than a paragraph, because it’s the part that made everything above deployable.

The problem with attestation is that it sits at the front door and it’s all-or-nothing. If my verifier has a bug — one byte off in the authData layout, a certificate chain I build wrong, a nonce encoding that differs between iOS versions — then on launch day every real user is locked out. Worse: from the server’s logs, a bug in my code and an attack look exactly the same. Both are refusals. You can’t tell them apart until you’ve seen the verifier run against real devices, and you can’t see that without deploying it, and deploying it means enforcing it. That’s the trap.

The way out is to separate verifying from enforcing. One server flag, set only on the test environment. When it’s on, the server still runs the full verification whenever a token is present — CBOR, chain, nonce, counter on iOS; Google’s decode and verdicts on Android — and writes the verdict to the logs, with the platform and the installation id. Then it lets the request through regardless of the result. Production never sets the flag. Production stays fail-closed.

if (allowAll)   <em>// test environment only — production never sets this flag</em>
{
    if (token is not null && verifiers.TryGetValue(platform, out var shadow))
    {
        var verdict = await shadow.VerifyAsync(token, installationId, ct);
        log.LogInformation("Attestation SHADOW {Platform}: {Verdict}",
            platform, verdict ? "OK" : "REFUSED");
    }
    return true;   <em>// observe, never refuse</em>
}

if (bypass) return true;                              <em>// test device registered by an administrator</em>
if (string.IsNullOrWhiteSpace(token)) return false;   <em>// fail-closed</em>
return await verifiers[platform].VerifyAsync(token, installationId, ct);
Code language: JavaScript (javascript)

What this bought me is a rehearsal on real hardware with zero risk. Every real device that talked to the test environment — a TestFlight iPhone, a Pixel from Play’s internal-testing track, development builds from the IDE — exercised the whole chain and left a verdict in the logs, and nobody was ever blocked while I read them. And the logs taught me things the spec hadn’t.

They showed me the assertion path working end to end: on the second launch of a real iPhone, the counter went from 0 to 1, and the server accepted it — the anti-replay mechanism, live, not on paper. They showed me a real Pixel coming back from Google with PLAY_RECOGNIZED and MEETS_DEVICE_INTEGRITY, the two verdicts I had decided to require.

They also showed me two classes of refusal, and both turned out to be correct. The iOS simulator can never attest: DCAppAttestService simply reports itself as unsupported there — not a bug, a fact of the platform. And an Android build sideloaded from the IDE, outside the Play Store, comes back as UNRECOGNIZED_VERSION — which is precisely what attestation is for. Had I enforced from day one, I’d have discovered both by locking myself out of my own app. Instead I could decide, calmly, how to handle them: an explicit bypass list of test devices, edited by an administrator, so that the code never contains an exception. That’s the bypass line above, and it’s the only permissive path in the whole router.

There was an operational lesson too. On the test environment, every deploy from the IDE regenerates the installation id — the development keychain doesn’t survive it. Requiring a registration per build would have made the environment unusable within a day. That’s why the flag lives there permanently, and why it lives only there.

The switching rule is simple to state: when the logs show nothing but “OK” for every class of real device you intend to support, you’re done rehearsing. Production, which never had the flag, enforces from its first deployment — and it did. And if something ever went wrong in production, relaxing it is one configuration change away, not a rebuild and a store review.

The cost of all this? About fifteen lines in a router. The decision to build it, when to trust the logs, what risk to accept on launch day — that’s an engineer’s judgment, not a line of code, and that kind of judgment stays, entirely, mine.

When the first “attestation OK” appeared in the production logs for a real iPhone, then “verdicts OK” for a real Pixel, with no bypass of any kind — the security I had lost by deleting the account was back. Differently. Better.


Premium without an account, and deletion turned into a feature

Two corollaries, shorter.

The subscription. Without a user_id, what do you attach a premium to? To the anonymous identity the payment provider (RevenueCat) already assigns to every installation. The app reports that identifier to the server, which checks the entitlement with the provider’s API. Unexpected benefit: when an Android user reinstalls the app, their subscription is transferred automatically to the new identity — without doing anything. One less friction, again.

GDPR deletion. Before, you had to write to the DPO. Now it’s a button in the settings: “Delete my data”. One tap, immediate effect, everything the server knows about the device is erased and the app starts over. The endpoint fits in four lines, and that is precisely what makes it credible: there isn’t much to erase.

<em>// DELETE /api/devices/me — everything the server knows about this installation, and nothing else.</em>
await db.DeviceTokens.Where(t => t.InstallationId == installationId).ExecuteDeleteAsync(ct);
await db.NotificationPreferences.Where(p => p.InstallationId == installationId).ExecuteDeleteAsync(ct);
await db.RefreshTokens.Where(r => r.InstallationId == installationId).ExecuteDeleteAsync(ct);
await db.Devices.Where(d => d.InstallationId == installationId).ExecuteDeleteAsync(ct);
return Results.NoContent();
Code language: JavaScript (javascript)

The regulatory constraint became a feature — and, incidentally, an argument: your lookups are tied to no identity, and you can erase everything with one tap. On politically sensitive data, that counts.


What deleting taught me about AI

Three things, honestly.

An AI is excellent at closed problems and mediocre at open ones. Writing an App Attest verifier from a spec: perfect. Deciding whether a shadow mode is needed, when to switch, what risk to accept: mine. The boundary isn’t “hard code / easy code”. It’s “specified / to be arbitrated”. It holds the map; I choose the route.

Deletion is the best test of how well a repository is understood. Adding, you can do blind. Removing cleanly requires knowing what leans on what. An AI that reads all the code at once excels where a tired human forgets a thread — and a forgotten thread, in a deletion, is a crash at startup.

It is confidently wrong, and you have to look. One evening, after a test purchase, the premium data didn’t show up right away. The obvious suspect was the purchase flow — and the AI followed me eagerly in that direction, because I was leading it there, proposing fix after fix that all looked right. The real culprit was elsewhere: a server cache patiently serving a stale “not subscribed”. The lesson isn’t new: an AI amplifies your hypothesis, the right one and the wrong one alike. Look at the whole system, not only at the place you already suspect.


VoxMap now opens with no account, no sign-up, no email. Maps and results available on first launch. The data is public; it no longer demands anything private. And the app is, I believe, safer than before — because it now proves its own authenticity instead of delegating it to a password.

A week of deleting. Three and a half thousand lines fewer. It’s the best work I’ve shipped on this project.

Built by one person. Written with Claude. Reviewed, line by line, by me.


VoxMap is free on iOS and Android — now without an account. To follow what comes next, there’s a newsletter.

42,000 Lines of Code, One Developer, Ten Weeks

What building a production app with Claude Code actually looks like — the good, the tedious, and the things it still gets wrong


This spring, I wanted an answer to a stupidly simple question: how did my town vote?

The data exists. It’s public. The French Ministry of the Interior publishes every municipal election result down to the last village. But try actually looking at it. You get administrative spreadsheets, scattered CSVs, and websites that were clearly never opened on a phone.

So I built the thing I wanted. Alone, evenings and weekends.

Two and a half months later, VoxMap is live on the App Store and Google Play: an interactive map of France where every one of the ~35,000 communes is colored by how it voted, across four election cycles (2008, 2014, 2020, 2026), backed by a data pipeline pulling from a dozen public sources.

Roughly 42,000 lines of code. A .NET MAUI mobile app, an ASP.NET Core backend, PostgreSQL/PostGIS, Redis, Docker, Nginx, a sovereign French host. No team. No agency. No inherited codebase.

I wrote all of it with Claude Code.

This article is not “AI wrote my app and it was magic.” It’s what the process actually felt like, where it genuinely changed what one person can ship, and — more usefully — where it fell flat on its face.


What I actually built

A quick sense of scope, because “I built an app” hides a lot:

Mobile (.NET MAUI, iOS + Android). Interactive map, commune search with autocomplete, detail sheets, year comparison, push notifications, in-app purchases, an ad layer, device management, account flows.

Backend (ASP.NET Core, C#). JWT auth with refresh-token rotation, a tile server, REST endpoints, an admin panel, RGPD deletion workflows, a RevenueCat webhook, cron jobs.

Data. PostgreSQL 16 with PostGIS holding commune geometries and about 1.3 million election result rows, plus civic datasets (income, healthcare, schools, transport, mobile coverage, air quality) normalized from INSEE, ANFR, ARCEP, and others.

Infrastructure. Docker Compose on a French VPS, Nginx with Let’s Encrypt, Redis caching, self-hosted analytics, automated database migrations on deploy.

The piece I’m most proud of is invisible: the map doesn’t ship 35,000 polygons to the phone. The backend renders raster tiles server-side — 256×256 PNGs over the standard XYZ scheme — with geometry simplification that adapts to zoom level, cached in Redis. The phone just displays images. That’s what makes it fast on a mid-range Android in a train tunnel.


How the work actually went

The mental model that clicked for me: I stopped being a typist and became a reviewer.

Not a manager — a reviewer. That distinction matters. Managers delegate and check the outcome. Reviewers read every line.

A typical session looked like this:

“The map needs to color each commune by the winning party’s nuance code. I want the colors to come from the Ministry’s official codes, not my own choices. Here’s the schema.”

Claude would come back with a full implementation: the SQL with a LATERAL JOIN, the color mapping, the tile rendering. Usually 80% right. Then the real work started — the part where I earn my title:

  • Why are you querying the geometry table twice?
  • This breaks for Paris/Lyon/Marseille, they’re districts, not communes.
  • That index won’t be used with this WHERE clause.

The 80% it gets right is the boring 80%. Boilerplate, wiring, the fifth CRUD endpoint, the migration file, the Razor page that looks like the other Razor pages. That work is not where craft lives, and handing it off is pure gain.

The remaining 20% — schema design, the caching strategy, deciding that PLM districts need their own data model — is where I spent my thinking. Claude was genuinely useful there too, but as a sparring partner, not an oracle. I’d propose an approach, it would poke holes, I’d revise.


Where it changed what’s possible

Breadth, not depth. I’m a backend engineer by trade. Left alone, I’d have written a competent API and a mediocre app. With Claude, I shipped native iOS and Android, wrote CSS I’m not embarrassed by, and configured Nginx without three days of Stack Overflow. It didn’t make me a better backend engineer. It made me dangerous outside my lane — which, for a solo project, is the whole ballgame.

Momentum survives context switching. The killer of side projects isn’t difficulty, it’s the twenty minutes of re-orientation every time you sit down. “Where was I? What does this class do?” Being able to ask “remind me how the tile cache invalidation works” and get a straight answer collapses that to zero. I’d guess this alone doubled my effective hours.

The tedium tax drops to near zero. French and English localization files, six subscription tiers, twelve civic-data importers with slightly different CSV formats. Multiply that by ten weeks of evenings and it’s the difference between shipping and quietly abandoning it in November.


Where it fell flat

This is the part most “I built X with AI” posts skip, so let’s be specific. Every example below is real.

It cannot tell you what’s actually broken at runtime.

My newsletter signup form looked perfect. The code was clean, it compiled, it deployed. It also returned HTTP 400 on every single submission. The form used a hardcoded action="/newsletter" attribute, which meant Razor’s tag helper never activated, which meant no antiforgery token was generated, which meant the server rejected every POST.

Nothing in the code looks wrong. You find this by hitting your own endpoint with curl and seeing a 400 come back. I caught it because I test in production before I trust anything. If I’d shipped on “it compiles and looks right,” I’d have silently collected zero emails while congratulating myself.

It defaults to plausible, not correct.

I asked for a promotional PDF. It came back with background-clip: text for a gradient headline — beautiful on screen, and completely unsupported by Chrome’s print engine. The exported PDF had blue rectangles where the numbers should be. Reasonable code, wrong context.

Same category: styling a section on a light background when the entire site uses a dark theme. The title inherited white-on-white and vanished. The CSS was valid. It just hadn’t looked at the room it was walking into.

It has no eyes.

Related to the above, and worth stating plainly: it can’t see rendered output. Every visual bug in this project — the invisible heading, the print artifacts, the store badges that failed to load because of a relative path — was found by me looking at a screenshot. If your workflow doesn’t include actually looking at the thing, the model has no way to catch these.

It doesn’t know your environment drifted.

My CI runner died with JAVA_HOME is set to an invalid directory. Cause: Homebrew had bumped openjdk@21 from 21.0.11 to 21.0.12, and the runner had the versioned path hardcoded. No amount of reading application code finds that. You find it by running ls on the path that’s supposedly broken.

And it won’t check your business state.

The most instructive one. I recorded a demo video using our App Store review account, assuming it was premium. The video came out with test ads plastered across the bottom and none of the premium data visible. The code was fine. The account had expired three weeks earlier. One SQL query would have told me. I only found it because a human — the app’s owner — said “you should have used the other account.”


What I’d tell you if you’re about to try this

Test in production, early and suspiciously. Compiling is not working. Deployed is not working. Observed is working. Every serious bug in this project survived the compiler and died the moment I actually exercised the feature.

Stay the architect. Every structural decision that turned out well — server-side tiles, separating the maire from the head-of-list in the schema, one email interface with swappable providers — came from me. Every one of those, Claude then implemented faster and more completely than I would have. That division of labor is the whole trick. Invert it and you get an app that works until it doesn’t and nobody knows why.

Write comments that explain why. My codebase is full of them, and they’re the single highest-leverage thing I did. When you come back in six weeks — or when the model reads the file fresh — the comment explaining why the maire field is separate from the list head is worth more than any amount of clean naming.

Push back. When something felt off, I said so, and about a third of the time I was the one who was wrong and learned something. Treating output as a proposal rather than an answer is the difference between a collaborator and a very fast intern you’re afraid to correct.


Was it worth it?

I’m a Tech Lead by day. I’ve shipped software for twenty years. I know roughly what ten weeks of evenings buys you, and it is not a cross-platform app with a national dataset, a tile server, an admin panel, and RGPD compliance.

What actually changed isn’t that the code got written faster — though it did. It’s that the project never hit the wall where a solo developer quits: the wall where you’re two months in, the interesting part is done, and what’s left is twelve CSV importers and a localization file. I got to keep doing the interesting part while the tedious part kept pace.

The failure modes are real and they’re specific: it doesn’t see, it doesn’t run, it doesn’t know your data, and it’s confidently wrong in ways that look right. Every one of those is survivable if you stay in the loop and actually look at your app.

VoxMap is live, free, and neutral by construction — the colors on the map are the Ministry’s official party codes, not mine. Next up: a secure citizen consultation where each vote is verified by ID, one person one voice, with no identity data retained.

Built by one person. Written with Claude. Reviewed, line by line, by me.


VoxMap is available free on iOS and Android. If you want to follow what comes next, there’s a newsletter.

MobileConcept.Maui.Core

A Clean Foundation for Scalable .NET MAUI Apps

Building a .NET MAUI application that stays clean, maintainable, and scalable over time is not trivial.
As soon as your app grows beyond a few pages, you start facing recurring problems:

  • Where should lifecycle logic live?
  • How do you reliably react to app foreground/background events?
  • How do you pass parameters between pages without fragile string-based navigation?
  • How do you keep dependency injection structured as the app grows?

That’s exactly why MobileConcept.Maui.Core exists.

👉 GitHub: https://github.com/maui-plaroche/MobileConcept.Maui.Core

Why MobileConcept.Maui.Core?

MobileConcept.Maui.Core is an essential core library for .NET MAUI applications targeting Android and iOS.
It provides a strong MVVM foundation, lifecycle handling, and a structured architecture that removes boilerplate and guesswork.

Instead of reinventing patterns for every project, this library gives you:

  • A consistent ViewModel lifecycle
  • Application lifecycle events at the ViewModel level
  • Type-safe navigation with parameter passing
  • A clean Bootstrapper pattern for dependency injection
  • Practical helpers and extensions for common mobile scenarios

All without fighting against MAUI or Shell.

Installation

Add the package via the CLI:

dotnet add package MobileConcept.Maui.CoreCode language: CSS (css)

Or using NuGet:

Install-Package MobileConcept.Maui.CoreCode language: CSS (css)

Requirements

  • .NET 10.0 or later
  • .NET MAUI 10.0 or later

One-Line Setup That Changes Everything

The heart of the library is activated with a single line in MauiProgram.cs:

builder
    .UseMauiApp<App>()
    .UseMobileConcept(); // 👈 Required
Code language: HTML, XML (xml)

This does several important things automatically:

  • Registers core services (like INavigationService)
  • Hooks into Android and iOS app lifecycle events
  • Forwards lifecycle events directly to your ViewModels

No platform-specific code. No handlers to wire manually.

A Clean Bootstrapper Pattern for DI

As your app grows, dumping everything into MauiProgram.cs quickly becomes unmanageable.

MobileConcept.Maui.Core introduces a simple but powerful BootstrapperBase:

public class SampleDemoBootstrapper : BootstrapperBase
{
    public SampleDemoBootstrapper(IServiceCollection services) : base(services) {}

    protected override void RegisterServices()
    {
        Services.AddSingleton<IMyService, MyService>();
    }

    protected override void RegisterViewModels()
    {
        Services.AddTransient<MainPageViewModel>();
        Services.AddTransient<SecondPageViewModel>();
    }

    protected override void RegisterViews()
    {
        Services.AddTransient<MainPage>();
        Services.AddTransient<SecondPage>();
    }
}
Code language: HTML, XML (xml)

Your DI configuration becomes:

  • Readable
  • Predictable
  • Easy to scale

And initialization stays explicit and controlled.

ViewModel Lifecycle — Done Right

Every ViewModel inherits from ViewModelBase, giving you lifecycle hooks that actually make sense in MVVM.

public class MainPageViewModel : ViewModelBase
{
    public override Task OnAppearingAsync() { }
    public override Task OnDisappearingAsync() { }

    public override Task OnAppStartAsync() { }
    public override Task OnAppEnterForegroundAsync() { }
    public override Task OnAppEnterBackgroundAsync() { }
}

Why this matters

  • No logic in code-behind
  • No guessing when lifecycle events fire
  • The same behavior on Android and iOS

You can load data, cancel subscriptions, or persist state exactly where it belongs: the ViewModel.

Type-Safe Navigation (No More String Routes)

Navigation is handled through INavigationService:

await navigationService.NavigateToAsync<SecondPage>(
    "test",
    1,
    new List<object> { "test", 1 }
);
Code language: JavaScript (javascript)

On the receiving side:

public override Task InitializeAsync(params object[] args)
{
    var message = args[0] as string;
    var number = (int)args[1];
    return Task.CompletedTask;
}
Code language: PHP (php)

Benefits

  • No fragile route strings
  • Compile-time safety
  • Multiple parameters supported
  • Clear intent in code

Pages That Respect MVVM

Pages inherit from BaseContentPage<TViewModel>:

<views:BaseContentPage
    x:TypeArguments="viewModels:MainPageViewModel"
    x:Class="MobileConceptSample.MainPage">
</views:BaseContentPage>
Code language: HTML, XML (xml)

And in code-behind:

public MainPage(MainPageViewModel viewModel) : base(viewModel)
{
    InitializeComponent();
    EnableLifecycleEvents = true;
}
Code language: PHP (php)

That’s it.
Lifecycle events are now forwarded automatically to your ViewModel.

Handling App Foreground & Background Transitions

Mobile apps live and die by lifecycle correctness.

With MobileConcept.Maui.Core, your ViewModels receive:

PlatformForegroundBackground
AndroidOnResumeOnPause
iOSOnActivatedOnResignActivation

No platform checks. No conditional compilation.

Image Helpers That Actually Solve Real Problems

Anyone who has dealt with camera images knows about EXIF rotation issues.

The ImageRotationHelper solves this:

var stream = await result.OpenReadAsync();
var processed = ImageRotationHelper.ResizeProportionalWithExifRotation(
    stream,
    maxWidth: 400,
    maxHeight: 400
);
Code language: JavaScript (javascript)

What you get

  • Correct orientation
  • Proportional resizing
  • Lower memory usage
  • Identical behavior on Android & iOS

Recommended Project Structure

MyMauiApp/
├── Bootstrap/
├── ViewModels/
├── Views/
├── Services/
├── AppShell.xaml
├── App.xaml
└── MauiProgram.cs

This structure works naturally with the library and keeps responsibilities clear.

Best Practices

  • ✅ Always call .UseMobileConcept()
  • ✅ Enable lifecycle events on pages
  • ✅ Load data in OnAppearingAsync
  • ✅ Save state in OnAppEnterBackgroundAsync
  • ✅ Keep navigation parameters simple
  • ✅ Let ViewModels own behavior

Final Thoughts

MobileConcept.Maui.Core doesn’t try to replace MAUI.
It completes it.

It gives you the missing architectural pieces that real-world mobile apps need:

  • Predictable lifecycle handling
  • Clean navigation
  • Scalable dependency injection
  • Less boilerplate, more intent

If you’re serious about building maintainable .NET MAUI applications, this library will feel like home.

👉 GitHub: https://github.com/maui-plaroche/MobileConcept.Maui.Core
📦 NuGet: https://www.nuget.org/packages/MobileConcept.Core
📖 More content: https://www.mobile-concept.com

Hello Medium! I’m Paulin Laroche, Your .NET MAUI Expert👋

Welcome to what I hope will become your go-to resource for everything .NET MAUI! I’m thrilled to join the Medium community and share my passion for cross-platform development with Microsoft’s revolutionary Multi-platform App UI framework.

Who Am I?

I’m Paulin Laroche, a seasoned software developer with deep expertise in the Microsoft ecosystem. My journey has taken me through the entire evolution of Microsoft’s cross-platform story — from the early days of Xamarin to the modern era of .NET MAUI. You can connect with me on LinkedIn where I regularly share insights about the ever-changing landscape of mobile development.

Continue reading “Hello Medium! I’m Paulin Laroche, Your .NET MAUI Expert👋”