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.