← Sijie Wang

what you're reading

YouTeacher

Where China's schools meet the world's teachers.

YouTeacher is an ESL/EFL recruitment platform for the China market — a three-sided market of teachers, schools, and agencies, where a school's access to a teacher's contact details is gated behind verification and a consumable unlock quota. Under the product sits a constellation of ~30 independently-deployed repositories: each service owns its own database, they talk through a dumb gateway, and no service trusts another without proof.

— the bet

Represent the market by proof, not by claims.

A recruitment site lives or dies on one asymmetry: the platform holds the contact details, and everyone wants them for free. YouTeacher makes that asymmetry the product. A teacher’s phone and email are never in a search result; a school reaches them only after two gates — verification (a human admin approves the employer) and a consumable unlock that spends from the employer’s quota and writes a talent_unlocks ledger row.

That ledger is idempotent on [talent_id, viewer_id, viewer_type]: re-opening a teacher you already unlocked never double-charges. One uniqueness constraint is the entire paywall — no billing state machine, just a unique index and a quota counter.

Three roles meet here: TalentProfile (teachers), EmployerProfile (schools), RecruiterProfile (agencies). A school can even be scraped-then-claimed — an employer_profiles row can exist unclaimed, with scraped_from set, waiting for the real school to claim it.

— the shape

A constellation, not a monolith — and the gateway is dumb on purpose.

There is no shared database. Each core service — youteacher_auth, youteacher_job, youteacher_profile, youteacher_talent, youteacher_content, youteacher_admin, youteacher_discord — owns its own Postgres; IDs cross service boundaries as plain references, never as foreign keys. The web app reaches them all through nginx/Traefik under one path convention, /api/{service}/v1/{resource}.

The gateway does no business logic — it only proxies. Identity is resolved at each service by calling youteacher_auth GET /me, which makes auth the hub of the whole diagram. The admin console is the sharpest expression of the rule: youteacher_admin has no database of its own at all. It is a pure aggregator that fans out to the other services, signing a fresh 60-second {service:’admin’} JWT per call and forwarding the operator’s session cookie.

The constellation — services
/api/{svc}/v1upsertaggregatedjob.published60s JWTyouteacher_webNext.js 15 · React 19nginx / Traefikthe dumb gatewayyouteacher_auththe hub · GET /meyouteacher_jobpostings + searchyouteacher_profilesource of truthyouteacher_talentdiscovery + invitesyouteacher_contentposts · pagesyouteacher_adminDB-less aggregatoryouteacher_discordbot + SSE hubjob_scrapersPlaywright + DeepSeek

No shared database: each service owns its own Postgres, and the gateway only proxies. Not drawn: every service resolves the user by calling youteacher_auth GET /me — which is why auth is the hub.

— trust

Two questions on every hop: which service, and which user.

Cross-service calls carry two independent proofs. A service JWT — { service: ’youteacher-web’, iat, exp } in the Authorization header — proves the caller is a trusted service. The user’s session cookie, forwarded with credentials:’include’, proves the user. A downstream service validates the cookie by calling youteacher_auth GET /me; neither proof alone is enough.

Sessions can also be minted from a key. Sign an Ed25519 challenge against POST /api/auth/v1/sessions/by-key and you get a session whose derived_from_key_id points back at the api_keys row that vouched for it — with onDelete: Cascade. Revoke the key and every session it ever minted vanishes in the same transaction, so a compromised laptop or MCP client loses access immediately, not at natural expiry.

Trust is deliberately compartmentalised. The desktop-games backend signs with its own GAMES_BFF_JWT_SECRET, explicitly not the shared SERVICE_JWT_SECRET, so it is a distinct, captcha-exempt client that can be rotated without weakening the main gate.

Dual-auth — which service, and which user
Browseryouteacher_webgatewayyouteacher_profileyouteacher_authlogin → session cookieJWT {service:'web'} + cookieproxy /api/profile/v1GET /me (validate cookie)user identitydataadmin signs a fresh 60s JWT per fan-out

The service JWT proves the caller is a trusted service; the forwarded session cookie proves the user. Neither alone is enough — both are checked on every hop.

— the loop

The loop: verify, search, unlock, invite, accept.

Search runs on Meilisearch, and youteacher_profile keeps it warm: whenever a profile changes, profile pushes the teacher into the talent search index over a TalentSyncPort (POST /upsert, service-JWT only). Only visible, verified teachers land in the index, so an employer’s search never returns someone they couldn’t contact.

Once a school spends an unlock and sends an invite, a row lands in invitations with a 32-hex token and a 7-day expires_at. The teacher opens /invitation/:token, and accepting flips it to the terminal accepted state — or returns 410 if the TTL has passed. The invitation is a one-way, expiring capability, not a mutable relationship.

The market loop — verify, unlock, invite
Employeryouteacher_profileAdminyouteacher_talentTeacherPOST /verify → pendingapprove → verifiedGET /search (Meili, visible only)POST /unlock {talentId}consume quota → talent_unlocks (idempotent)POST /invite {jobId}GET /invitation/:tokenPOST /accept (410 if expired)

Contact info is gated behind verification AND a consumable unlock quota. The unlock is idempotent on [talent_id, viewer_id, viewer_type], so re-opening a teacher never double-charges.

— supply

Jobs arrive two ways: scraped by an AI, or posted direct.

A fleet of platform-specific scrapers (job_scrapers, one Playwright strategy per site — Teachaway, Seek Teachers, eChinacities, and more) fetch raw HTML and hand it to a DeepSeek parser that extracts structured fields. The result is written as an aggregated JobPosting, deduplicated on [source_platform, external_id], with a 60-day TTL and a periodic reachability recheck — a source URL that goes non-2xx is deleted and answers 404 JOB_UNAVAILABLE.

Employer-created jobs are direct instead: a draft that publishes to active with a deterministic id — {platformSlug(2 words)}-{titleSlug(4 words)}-{base36(timestamp)} — so the same posting never collides with itself. Publishing emits a job.published event over Redis, which youteacher_discord turns into a channel post via webhook impersonation, pulling the poster’s name and avatar from auth+profile (no Discord OAuth involved).

Jobs — scraped by an AI, then fanned out
job_scrapersDeepSeekyouteacher_jobMeilisearchyouteacher_discordPlaywright fetch platform HTMLparse → structured fieldsJobPosting fieldswrite 'aggregated' (dedup on [platform, external_id])index activejob.published (Redis event)webhook post to channels

Scraped jobs are 'aggregated' with a 60-day TTL and a reachability recheck (a dead source URL → 404 JOB_UNAVAILABLE). Employer 'direct' jobs instead get a deterministic id: {platform}-{title}-{base36(timestamp)}.

— the data

One database per service; every id crosses a border alone.

Because no two services share a database, there are almost no cross-service foreign keys — a job_postings.user_id or a talent_unlocks.viewer_id is just a string that happens to name a row in another service’s Postgres. Referential integrity that would normally be the database’s job becomes the application’s job, on purpose: the price of hard isolation is that cascades are hand-written.

Inside a single database, real constraints still do the work. sessions.derived_from_key_id → api_keys ON DELETE CASCADE is the cascade-revoke above. talent_unlocks is unique on [talent_id, viewer_id, viewer_type] — the paywall. employer_reviews is unique on [talent_id, employer_id] with a 1..5 rating and a moderation status. Content is its own shape entirely: posts with a content_format (blocknote), and pages whose layout is a Puck JSON blob in puck_data.

Data model — one DB per service
1..NCASCADEno FKno FKno FKusersid, email uniquser_typestrong_factor_*api_keyspublic_key_pemalgorithm ed25519revoked_atsessionstoken uniqderived_from_key_id→ api_keys CASCADEtalent_profilesuser_id uniqpublic_to_employerssalary_expectation_*employer_profilescompany_nameverification_statusquota_total / quota_usedtalent_unlocks[talent_id, viewer_id, viewer_type] uniqthe paywall ledgeremployer_reviews[talent_id, employer_id]rating 1..5status, is_verifiedjob_postingsid deterministicorigin_type direct|agg[platform, external_id] uniqpostsslug uniqcontent_format blocknotestatuspagespuck_data JSON

Because no two services share a database, cross-service links (dashed) are plain string references with NO foreign key — cascades are hand-written. Inside one DB real constraints stay: api_keys → sessions cascade-revoke; talent_unlocks is the paywall.

— the other line

A second revenue line that never phones home to play.

Beside the recruitment platform sits an entirely separate business: paid offline classroom games, sold through a WooCommerce store (youteacher-store) and shipped as Tauri desktop apps. A game is licensed to a physical USB drive: game-shell (Rust) computes SHA256(driveVolumeUUID + LICENSE_SALT) and compares it to a .lic file on the drive. No network call is needed to start playing.

The salt fails closed. LICENSE_SALT is baked per release channel at build time, and a build missing it panic!s at compile — a misconfigured build can never ship with the wrong salt. The shell itself is unversioned and floats per channel: a game’s dev-v* tag wraps the shell’s dev branch, v* wraps main, so production content always gets the production shell.

Games — a license bound to a USB drive
Customeryouteacher-storeUSB drivegame-shellgames-bffyouteacher_authbuy (WooCommerce)write .licread .licSHA256(driveUUID + LICENSE_SALT) == .lic ?sign-in (X-Games-Client-Key)login (dedicated JWT, captcha-exempt)session token

Playing needs no network — the shell checks the .lic against the drive UUID offline. LICENSE_SALT is baked per channel at build time or the build fails to compile, so a misconfigured build can never ship the wrong salt (fail-closed).

— honest seams

What's load-bearing, and what's only scaffolded.

Honesty about the seams: multi-tenancy is scaffolded but not used — an x-tenant-id header is forwarded everywhere, yet there is no tenant table and the platform is single-tenant today. The job-status enum reserves filled and closed states the documented lifecycle doesn’t drive yet. declineReason is accepted by the invitation API but has no column to persist into. And the admin dashboard defines a cache it never reads — every request refetches.

The test story is the reassuring counterweight: youteacher_integral runs Playwright end-to-end against the real stack, and mock-services stands in for external dependencies during isolated web development. The layering is unusually strict for a frontend — youteacher_web is full DDD/hexagonal, its domain depending on nothing, with 27 auth use-cases wired to 18 ports.