What I built first
EgyGomla helps Egyptian traders find manufacturers and suppliers, browse wholesale catalogues and request quotations. I built it end to end: web apps, dashboards, backend and Flutter apps. The public website is live; the mobile apps are not on the stores yet.
The production edge is one Caddy config, a hostname per surface:
egygomla.com: the public marketing siteapp.egygomla.com: the buyer applicationfactory.egygomla.com: the supplier portaladmin.egygomla.com: the platform consoleapi.egygomla.com: the HTTP APIid.egygomla.com: Keycloak, the OIDC issuer
On top of that split I decided every role gets its own email. It felt tidy: each role in its own place, the data separate.
Where it breaks: the wholesaler
The product's own audience line names factories, importers, and wholesale and retail traders. A wholesaler, by definition of the trade, buys from the factory and sells to the retailer. That person is both sides of the market.
Under my first design, that trader needs a second email, a second account, a second password, and keeps switching between them. I was penalising my best users for being my best users.
The cost is not only friction. Two accounts share no catalogue, conversations, reviews or history, and the platform cannot know they are one business, which matters once you think about abuse.
What the big marketplaces do
On Amazon you buy on the main site, and Amazon's own seller registration guide says you can sell with the same email and password as your customer account. noon follows the same shape: you shop on its main site, and the seller portal is a separate surface on its own domain (login.noon.partners).
The pattern in both: one login, the buyer experience as the front door, selling as a second surface you opt into.
Identity is not a role
An email represents a person. "Buyer" and "supplier" are things that person does. Tie identity to role and anyone with more than one role pays for it, while everyone else never notices.
Where the constraint actually lived
I assumed the data model was the problem. OrganizationType is BUYER | SUPPLIER | PLATFORM, and a schema comment says one organization row cannot be both at once. But the enum was not what blocked wholesalers. Most of the multi-role machinery already existed:
- Membership is many-to-many in practice, with a unique key on
(userId, organizationId), so one person can belong to a buyer organization and a supplier organization at once. - An endpoint returns a list of the person's organizations, not one.
- The guard resolves the acting organization on every request from a header, then checks the membership, its status, and that the organization's type matches the route.
What blocked the wholesaler was one rule in onboarding:
// onboarding service (paraphrased from the ADR)
if (onboarding.kind === "PROVISIONED") {
throw new DomainValidationError(
"This account already exists. Use an invitation to join another organization.",
);
}That rule was deliberate. An earlier decision used (issuer, subject) from the identity provider as the natural idempotency key for founding an organization, so "one founding per identity" was what that key bought, and the price was written down: no second organization through self-service.
So I did not have a type problem. I had a founding-rule problem, and knowing that spared me a much bigger change.
The design: one identity, two linked organizations
The decision keeps the enum as three mutually exclusive values and narrows the founding rule instead of deleting it: one self-service founding per identity per side. Illustrative SQL, simplified:
-- Illustrative. Simplified from the design, not the production schema.
ALTER TABLE organizations
ADD CONSTRAINT organizations_one_founding_per_side
UNIQUE (founder_user_id, type);A person can found one buyer organization and one supplier organization; a second buyer organization is refused. The idempotency key stays natural and stays in the database, not in a header the client sends.
Link, not merge
I considered the obvious alternative: replace the type with capability flags like can_buy and can_sell. The ADR rejects it for two reasons.
First, thirteen composite foreign keys pin each party column to an organization of the right type, so a supplier can never sit in a buyer's slot. Flags mean rebuilding that network in one change. Second, a shortcut like a fourth BOTH type is worse: every CHECK turns into IN ('SUPPLIER','BOTH'), and the guarantee that one row is not both sides disappears without a constraint failing or a test going red.
So the two organizations stay separate rows joined by a declared link. Verification does not cross it: a verified badge belongs to the supplier organization, and reviews are testimony about the supplier, not something the buying side inherits.
Block self-dealing
Today an organization cannot invite itself as a supplier, because it is never both types. After that, a quote needs an invited recipient row, an award needs a quote, and a review is anchored to an award. The whole chain holds because the two types are disjoint on a single row.
Give one person a row on each side and the chain protects nothing: the buyer row invites the supplier row, awards it, and rates it five stars. The design has to re-derive that protection explicitly:
-- Illustrative. The shape of the constraint, not the exact production DDL.
-- Each row carries a denormalised copy of each party's link group,
-- held honest by a composite foreign key to organizations.
ALTER TABLE rfq_recipients
ADD CONSTRAINT rfq_recipients_not_self_dealing
CHECK (buyer_link_group_id <> supplier_link_group_id);One detail changed between the sketch and the implementation. The sketch had a nullable link group with IS NULL branches. But a PostgreSQL foreign key is MATCH SIMPLE by default: if any referencing column is NULL, the constraint is satisfied without being checked. A writer that forgot the column would switch the rule off by omission, and nothing would report it. So an organization with no declared link is a group of one, its own id, and the check becomes a plain <> over two NOT NULL columns.
The design writes its limits down too:
- Only a declared link is caught. Two organizations created with two emails and no declaration pass, as they do today.
- Harvesting competitors' prices through your own legitimate request for quotation is market behaviour, not a schema problem. No key closes it, though the platform can at least see it once the link exists.
The link must never widen a read
Reads are scoped by the organization id the guard resolved from the membership. A supplier acting as a buyer is just a different buyer and gets the same not-found as anyone else. The link is presentational only: no query may use it to widen a tenant filter, and an architecture test fails the build if the column reaches a query condition, an ordering, an authorization path or a presenter.
Auth and sessions across subdomains
One identity across several hostnames sounds like it needs shared cookies. It does not.
- Each hostname is its own origin.
__Host-cookies carry noDomainattribute, so a session on one surface is never shared with another, even by accident. - Identity is shared through the issuer. Keycloak at
id.is the OIDC issuer, and external identities are keyed by(issuer, subject), so the issuer's hostname is a data-model boundary, not branding. - Each application is an OIDC client with literal redirect and post-logout URLs. Wildcards are forbidden, so renaming a host is a two-place change, and the second place is the identity provider.
- Application session material lives in Redis behind an opaque cookie.
- The acting organization is chosen per request and verified against a membership. A header names it; the server decides whether the person may act as it. A client-sent "act as" role would be a worse design.
The cross-portal hop follows the usual OIDC pattern: the second application redirects to the issuer, the issuer already knows the person, and the person returns. I am describing that from the config and the ADRs, not from a recorded test. The design also calls for an explicit link between the buyer app and the supplier portal, with no silent switching, and every screen that changes something names the acting organization and its side.
If you already have duplicate accounts
I have not run a merge of duplicate accounts in EgyGomla, and nothing here claims that. This is general guidance.
- Count before you change anything. The migrations in this repo count rows that violate a new rule and refuse to continue, naming them, instead of coercing or deleting them. Do the same for duplicates, using signals you trust: normalised email, phone, tax id, company name.
- Never auto-merge on a match. Similar details do not prove the same person, and a merge is hard to undo.
- Let the person confirm both. Ask them to sign in to each account, then attach both organizations to one identity and link them. Do not collapse them into one row.
- Keep catalogues and history where they are. The link is for understanding, not for combining data.
- Expand, backfill, contract. Add nullable columns, backfill, then add constraints, and build unique indexes concurrently on a live database.
- Ship writers first. If a new column is
NOT NULLwith no default, every writer, including seeds and scripts, must supply it before the migration runs. - Expect a tail. Some people will never link. They behave as they do today, which is acceptable if the limit is written down.
What I'd do differently
- Start from the heaviest user's day. Ask who the person is before deciding what each page needs, and walk through a day for the user who touches both sides.
- Find where the constraint lives first. I blamed the enum; the blocker was an onboarding rule I had chosen on purpose.
- Write the abuse chain before the happy path. The review system leaned on a property no constraint stated. Making it explicit was the most important part of the design.
- Do not ship the second-organization path alone. The ADR says it must not go out without the anti-self-dealing constraints, or self-dealing drops from "needs a second email" to "one click".
- Keep marketing behind the feature. The ADR forbids copy like "one account buys and sells" until the feature exists, and a copy test guards the site's other promises.
- Say what is decided and what is shipped. This article describes a decision, not something in front of traders.
Mohamed Elgohary, Mobile & Product Engineer, Cairo. mohamedelgohary.dev

