Architecture
A thin client and a backend that owns every decision — including which image processor runs, so the app never holds a third-party credential and a failed cut-out is handled in one place rather than once per client.
- Expo app: Uploads a photo and picks a processing mode. That is the whole extent of its involvement — no third-party credentials, no direct bucket access, nothing worth extracting if someone unpacks the APK.
- FastAPI: Layered routers → services → models, never skipping. Routers parse and return an envelope, services own the transactions, models are the ORM. Unglamorous, and the reason a second person could work in the app while I worked here.
- Cut-out: Background removal, with the model baked into the container image so a cold start doesn't have to download it first. When it fails the original is kept and the processed key stays null. The user carries on and can still confirm type and colour — a failed cut-out is not a failed upload.
- Ghost mannequin: The other mode: asks for the single most prominent garment rendered as an e-commerce flat lay. Which of the two runs is the user's choice, made before the upload.
- Object storage: Originals and processed images under per-user key prefixes. The bucket is never public — every read is a short-lived, user-scoped presigned URL, and deleting an account removes the entire prefix rather than just the rows.
- Cloud SQL: Every domain table carries soft-delete columns, and uniqueness is scoped to live rows — so a soft-deleted outfit can still occupy a date that an active one now holds. The tag join tables are the deliberate exception: composite key, hard delete on detach, because a detached tag is not history worth keeping.
- Google Identity: Sign-in verifies the Google ID token's audience against an explicit allowlist of client IDs, then issues our own JWT. No passwords are stored anywhere. It fails closed. With no client IDs configured, verification errors rather than skipping the check — a misconfigured deploy locks everyone out instead of letting everyone in.
One tag table, three entity types
The obvious model gives clothes a category — tops, formal, winter. I didn't build that: every fixed taxonomy is wrong for somebody, and the interesting queries cut across entity types anyway.
A tag is standalone, not a property of clothes. It attaches to a wardrobe, a garment *and* an outfit through three join tables, so picking one slices the whole app horizontally. No fixed vocabulary — people invent their own.
An outfit's tags are computed, never stored. They're the union of its own tags with those of both garments. Tag a blazer `party` and jeans `casual` and the outfit surfaces under both.
Because storing that union would drift. It would need recomputing on every tag edit to either garment — a denormalisation with a guaranteed bug in it. Wear counts and last-worn are derived the same way, straight from the calendar.
Privacy as a data-model decision
A wardrobe app accumulates photographs of the inside of someone's home. That work had to be structural rather than a policy page.
No geolocation, on any entity. A wardrobe carries a typed label — "Home", "Suitcase" — which covers every real *where are these clothes* need. It's a string the user types, never a coordinate. You cannot leak a location you never collected.
The bucket is never public. Keys are namespaced per user and every read is a short-lived, user-scoped presigned URL. Deleting an account removes the whole storage prefix, not just the rows.
Share links are hashed at rest. The database holds a SHA-256 of a 256-bit token; the raw value exists only in the URL the user copies. A dump hands nobody a working link, and the shared view is a minimal projection.
Sign-in fails closed. The Google ID token's audience is checked against an explicit allowlist. With none configured, verification *errors* rather than skipping the check — a misconfigured deploy locks everyone out, not everyone in.