Engineering
Adding a Mobile Client to a Product That Already Has a Web App
By Clasify Team · · 5 min read
Adding a mobile client to a product that already has a web app looks like a porting exercise. It is not. The web app has years of accumulated behaviour, and the interesting work is deciding which of it belongs on a phone, then keeping the two from drifting apart once both are alive.
We built a Flutter client for Clasify, our academy platform, sharing one codebase across iOS and Android. These are the three problems that actually cost us time — none of them were layout.
Parity is a moving target, so test it as behaviour
The naive plan is a checklist: here are the web screens, build them again. That plan starts rotting the day someone changes a web screen, because nothing tells the mobile app it fell behind.
Worse, the usual mobile tests do not catch it. A widget test proves a component renders in isolation. A screenshot test proves pixels match a baseline, and mostly proves that the baseline is out of date. Neither notices that a screen now throws when it loads real data for a real role.
What we run instead is a parity smoke test that drives the actual app: it logs in as a teacher, walks every screen touched by the parity work — schedule, attendance and analytics, students, summaries, assignments, home — then logs in as a student and does it again. The test fails if any screen throws while building.
That is a low bar, deliberately. It does not check that the screen is correct. It checks that the screen *exists and survives contact with real data for that role*, which is the failure that actually ships. Most mobile regressions we would have shipped were not wrong pixels; they were a screen that assumed a field the API stopped sending, for one role only.
Running it per role matters more than running it per screen. A teacher and a student see different shapes of the same data, and the bug is almost always in the role you tested less.
The scope decision is a feature, and it needs a test too
Not everything on the web belongs on the phone. Some things are actively worse there, and some are risky enough that the smaller surface is the point.
Changing a password is one we deliberately left off the mobile profile screen. That is a real decision with a real reason — but decisions like it are exactly the kind that get quietly undone six months later by someone adding "the missing button" in good faith.
So the absence has a test. There is an integration test whose entire job is to assert that the voluntary change-password button is *not* on the profile screen, for a teacher and for the student who shares that screen.
Writing tests for things that should not exist feels strange the first time. It is the cheapest way to make a scope decision durable — the same reasoning as a comment explaining why a line is missing, except a test cannot be skimmed past.
A device is not a user, and on multi-tenant it is not a tenant either
Push notifications are where mobile stops being a client and starts being its own distributed-systems problem.
The model looks simple. A device registers, you store its push token, you send to it. In our schema a registered device carries the platform, the token, the user it belongs to, and the tenant. The obvious next step is to treat that token like every other tenant-owned row and scope lookups by tenant.
That is wrong, and it took a real bug to see why.
The registration key is the push token, and the token belongs to the *phone*, not to a person or an academy. The same physical device can be a teacher's at one academy, then get logged out and logged back in as somebody at a different academy on the same app. If registration looks the device up scoped to the caller's tenant, it will not find the existing row — and then it tries to insert a token that already exists, and collides with the uniqueness constraint that was supposed to be keeping things tidy.
So token lookup during registration is deliberately global, and every other operation on devices — listing a user's devices, removing one — is scoped explicitly instead. The exception is written down with its reason next to it, because an unexplained global lookup in a multi-tenant system looks exactly like a bug and will eventually be "fixed".
The general lesson: a phone outlives any one session and any one tenant. Model it as its own long-lived thing that a user is currently attached to, not as property of the user.
What we would tell another team
- Decide what the mobile app is *not* before you decide what it is, and put the omissions under test.
- Test parity as runtime behaviour per role, not as screenshots. "Does this screen survive real data for this user?" catches what actually breaks.
- Treat device identity as separate from user identity and tenant identity. It has a different lifetime than both.
- Write the reason next to every deliberate exception. In a multi-tenant codebase, an unexplained unscoped query reads as a defect.
One codebase for both platforms was the right call for us, and the framework was never the hard part. The hard part was the boundary: what belongs here, and how do we know when it drifts?
We build mobile clients for other teams as mobile app development; how we built Clasify covers the backend they talk to.
All articles