Full-stack Engineer
Chosel
Chosel is a funnel and CRM platform built inside a Turborepo monorepo: a Next.js frontend, a Fastify API, background workers, and a dedicated workflow runner, all sharing one design system, database layer, and set of types with every piece of data is scoped to a workspace, enforced at the database layer.
- Next.js
- Fastify
- PostgreSQL
- Turborepo
- BullMQ
The problem
The product needed real multi-tenancy from day one: every user belongs to one or more workspaces, and no query anywhere in the system could be allowed to leak data across that boundary, whether the request came from the web app, the API directly, or a background job.
Constraints
- Workspace access control had to hold even if I missed a check somewhere in application code, since a single mistake in a route handler could otherwise leak one workspace's data into another.
- The invite flow needed to work for people who don't have an account yet, which meant an invite couldn't simply be a foreign key to a user row.
- Every app in the monorepo needed to share the same design system and component primitives, so a button or form field looks and behaves identically no matter which app renders it.
Architecture decisions
- 01I enforced workspace scoping with Postgres row-level security as the source of truth, backed by a SECURITY DEFINER function that checks workspace membership without recursing into that table's own RLS policy, a subtle Postgres gotcha that's easy to get wrong.
- 02Modeled invites as email-addressed rather than account-addressed, with an explicit status lifecycle (pending, accepted, expired, revoked), so an invited person who hasn't joined yet has no membership row at all.
- 03Structured the codebase as a Turborepo and pnpm workspace: separate apps for the frontend, API, background worker, and workflow runner, all pulling from shared internal packages instead of duplicating logic between them.
- 04Built a shared UI package as the single source of truth for the design system, plus a small script to scaffold new components consistently instead of hand-rolling each one differently.
What I'd do differently
Knowing what I know now, I'd design the workspace-membership security definer function and the invite status lifecycle before writing any application-level authorization checks, not alongside them. The database-level guarantees are what let the rest of the code stay simple.