← All articles

The Platform Nobody Asked For: How Five Products Ended Up Sharing One Identity System

I get asked a version of the same question whenever I talk through this monorepo with another engineer: "Why does your personal blog have an identity provider?" It's a fair question. The honest answer is that I didn't plan it that way. I planned to add an AI chat widget to davejackson.dev. What I ended up building, one phase at a time, was a real shared platform — identity, organization, commerce, notifications, and a dozen other bounded contexts — that every product in this repo now sits on top of. I want to walk through how that actually happened, because "we decided to build a platform" is not what occurred, and I think the real sequence is more useful than the tidy version.

It Started As a Feature, Not a Platform

Back when Platform IAM (libs/platform/iam, apps/platform/iam) first shipped, it wasn't framed as platform infrastructure — it was framed as the auth piece a multi-product SaaS setup was going to need eventually, sized as an XL effort that would cover OIDC provider strategies, zero-trust RBAC, service placement decisions, and the underlying data model. Eight phases, real OIDC flows (Auth Code, Client Credentials, Device Code, Refresh Token), a real policy store, real service accounts. It merged, all tests green, and then it sat there for a while as a well-built thing with no products actually wired up to call it.

That's the part I want to be honest about: I built a fairly serious identity system before I had a second product that needed one. It wasn't wasted work, but it also wasn't part of some five-year platform roadmap. It was scoped, built, and merged as its own feature because the domain modeling underneath it — Principal, Session, Policy, RoleAssignment, ServiceAccount — needed to exist somewhere, and I'd rather build it once, correctly, than bolt half of it onto whichever product asked for auth first.

The Turn: An AI Chat Widget That Grew Up

The actual pivot toward "platform" happened inside feat/hosted-app-ai-chat, a feature that started, per its own name, as adding an AI chat surface to my site. It kept growing. By the time it was 15 phases in, it had become a real DDD/CQRS platform: NestJS microservices behind apps/platform/api-gateway, an Angular MFE shell (apps/platform/shell) federating remotes for each product, shared OpenTelemetry/Grafana observability, and a full Ecommerce/Subscription/ Entitlements domain. This became explicit as the feature "grew into a much larger ask" than originally scoped.

What made that growth tractable instead of chaotic was that I already had a domain model to grow into. A separate, planning-only case study (the Freelance Marketplace spike) had already worked out 13 common, reusable sub-domains — People, Places, Things, Notes, Tasks, Attachments, Scheduling, Messaging, Identity, Organization, Commerce, Taxonomy, Notifications — as a methodology exercise, never intended to run in production. That model got promoted in place rather than re-deriving it, and it recognized that two of those domains already existed as real, merged code: Platform IAM is the real Identity, Platform Organization is the real Organization. That's libs/platform/iam and libs/platform/organization today, both tagged scope:platform, both consumed as libraries by every product that needs them rather than forked per product.

flowchart TD
    subgraph history["How this actually happened"]
        direction TD
        A["Platform IAM ships\n(7 phases, XL, no consumers yet)"] --> B["hosted-app-ai-chat:\nadd AI chat to davejackson.dev"]
        B --> C["Feature scope grows:\nDDD/CQRS platform, MFE shell, gateway"]
        C --> D["Promote the case study's\n13-domain model in place"]
        D --> E["libs/platform/* bounded contexts\n(iam, organization, commerce, notifications, ...)"]
    end

Once that promotion happened, a "compose, don't fork" discipline became the actual rule: every common domain gets the standard domain/application/infrastructure libs, tagged scope:platform, and stays colocated inside a shared NestJS microservice until it earns its own deployable. That's why libs/platform/ today holds a long list of bounded contexts — iam, organization, people, things, notes, taxonomy, attachments, places, tasks, scheduling, messaging, commerce, notifications, portfolios, communications, shared — instead of a dozen separate microservices each duplicating their own notion of "who is this user" and "what organization do they belong to."

Why scope:platform Gets No Boundary Restriction

The ESLint module-boundary rules in this repo (eslint.config.mjs, enforced via @nx/enforce-module-boundaries) tag every project with a scope. Product scopes — scope:agents, scope:job-search, scope:content, scope:freelance-portal — can only import from their own scope, scope:shared, and scope:platform. scope:platform itself carries no such restriction; it's the composition-root tag. That's a deliberate architectural statement: the platform tier is allowed to be depended on by anything, and its own libraries stay domain-primitive-friendly (organization IDs pass through as plain strings, not OrganizationEntity instances) specifically so that no product scope ever needs a forbidden import just to check who's logged in or what plan a customer is on.

I want to flag one lesson I learned catching this the hard way, because it's a good illustration of why that tag exists. content was originally generated at apps/platform/content and tagged scope:platform, before I corrected course mid-phase and moved it to apps/content under its own scope:content. freelance-portal learned from that mistake rather than repeating it — it was scoped to apps/freelance-portal/scope:freelance-portal proactively, before the remote was even generated, using the same rationale the content correction had just surfaced. It's tempting to read "lives under apps/platform/" as "is platform infrastructure," but the tag isn't about directory location — it's about whether a project is a composition root that everything else can depend on, versus a product that consumes the platform. Getting that distinction backwards for even one project quietly invites the exact coupling the boundary rules exist to prevent.

apps/platform/api-gateway is worth calling out specifically, because it's the one piece of this that could have gone a very different direction. The choice between three real architectural options was: a thin reverse proxy, a CQRS Backend-for-Frontend that dispatches commands on behalf of product UIs, or GraphQL federation. I went with the thin proxy — the gateway verifies JWTs, enforces global rate limiting, and routes to the right product service; it never touches a database, never runs CQRS, and never contains product domain logic. The rejected alternative is the more interesting part: a CQRS BFF would have coupled the gateway to every product's domain types, so a change to any one product's API contract would ripple into shared gateway code. That's the opposite of what a platform tier is supposed to buy you. Adding a new product to this gateway today means adding a routing config entry — not touching gateway code at all.

Real Consequences of Actually Sharing Identity

None of this stayed theoretical. Sharing one identity system across products surfaced real, non-obvious failure modes that a "each product gets its own auth" architecture would never have exposed, because there'd be nothing to bootstrap in the first place.

The one I'd point to first: on a fresh iam database, service-account (client_credentials) tokens deliberately carry no org_id claim — they aren't tied to any organization by design. But PolicyEnforcementGuard requires both a subject ID and an organization ID before it will evaluate any policy. Work through the implication and it's a genuine chicken-and-egg problem: no service account can ever bootstrap another service account, because a service-account token can never pass that guard. Only a real human login can. Which means the one-shot bootstrap slot has to go to a login-capable OIDC client — one with authorization_code and refresh_token grants and real redirect URIs — never a machine-only client like the gateway's own service account, or you permanently dead-end the bootstrap slot on a client that structurally can never complete a browser login. I got this wrong the first time I diagnosed it, wrote it down as a memory, and had to correct that memory a few days later once I'd actually traced the root cause. That's the kind of mistake you only make once you have a real multi-party identity system with real bootstrap ordering constraints — a single-product auth setup doesn't have this problem because there's only ever one party to bootstrap.

The second one is about failure classes repeating across products once they share infrastructure. The first-ever live nx serve of this feature's code against real TypeDB surfaced three defects, every one of them caused by the same root issue: tests had been mocking the exact thing that would have caught the problem. Two were TypeDB 3.x syntax errors in iam.schema.typeql (value long needing to be value integer, and a missing attribute declaration) that Phase 03/04's all-mocked tests never caught. The third was a webpack output.path off-by-one bug that had already been found and fixed once, in prompt-workbench-api, three weeks earlier — and then reappeared independently in api-gateway and agents-api because nobody had checked whether the fix generalized. That's the double-edged sword of a shared platform tier: a bug class you fix once in a common layer stays fixed everywhere that layer is used, but a bug you haven't found yet in the platform tier is a bug every product built on it inherits silently, until somebody actually runs the thing for real instead of trusting the test suite.

The Newest Consumer: Developer Portal

The most recent product built on top of all this is the Developer Portal — a full-stack app, protected by Platform IAM, that will eventually host this monorepo's documentation (migrated off git-tracked markdown onto a real docs store), an MCP server for agentic access with per-agent service accounts, and an Admin UI for managing user accounts, service accounts, and multi-provider AI configuration. What's notable about how it started is exactly what was predicted: Sprint 1 planning's real finding was that platform-iam already has a Principal entity, and that entity is Developer Portal's "UserAccount" domain concept under a different name. The first feature — manage user accounts — extended existing code (revoke(), a list query, HTTP controllers, the first-ever Admin UI screens) rather than building an identity model from scratch. That's the actual payoff of everything above: a fourth product's first feature was an extension, not a fresh identity implementation, because the platform tier had already done that work for the three products before it.

flowchart LR
    IAM["libs/platform/iam\nPrincipal, Session, Policy,\nRoleAssignment, ServiceAccount"]
    Org["libs/platform/organization"]
    Gateway["apps/platform/api-gateway\nthin proxy, JWT verification"]

    IAM --> Gateway
    Org --> Gateway
    Gateway --> FBT["Freelance Business Tools"]
    Gateway --> JSS["Job Search Studio"]
    Gateway --> PW["Prompt Workbench"]
    Gateway --> DP["Developer Portal\n(newest consumer — extends\nPrincipal, doesn't reinvent it)"]

The end goal, stated plainly, is seamless single sign-on across every product on the platform — Job Search Studio, Freelance Business Tools, Prompt Workbench, and Developer Portal, one identity across all of them via Platform IAM. Whether every one of those product names survives to launch is genuinely open — most are likely to get rebranded before this platform is public — but the identity layer underneath them doesn't need to know or care what a product is called this month. That's precisely the property a shared platform tier is supposed to give you, and it's the property that would be missing entirely if I'd let each product bring its own auth.

What I'd Actually Tell Someone Starting From Scratch

I wouldn't tell them to build a platform on day one — I didn't, and I don't think it would have gone well if I had, because I wouldn't have known yet which of those 13 common domains were real versus premature abstraction. What I'd tell them is to notice the moment a second product needs the same thing a first product already built, and to resist the urge to just copy the code. Copying gets you two auth systems that drift apart the first time you fix a bug in one and forget the other. Promoting the shared thing into its own bounded context — with an explicit boundary rule enforcing that everything can depend on it and it can't quietly depend back on any one product — is more work up front and considerably less work every time after that. This promotion didn't happen because I planned a platform. It happened because I looked at what I'd already built for one product, noticed it was the real, general-purpose version of what a second and third product also needed, and wrote down the decision to reuse it instead of forking it. That's the whole story.

If you've been through a similar moment — the point where "just copy the auth code" stopped being the easy answer and started being the trap — I'd like to hear how you caught it, and how long it took before the duplicated version started actively costing you more than it saved.

Want to know more?

Interested in "The Platform Nobody Asked For: How Five Products Ended Up Sharing One Identity System"? Leave your details and I'll follow up with more information.

← All articles