<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Mohamed Elgohary</title>
    <description>The latest articles on DEV Community by Mohamed Elgohary (@mohamedelgohary).</description>
    <link>https://dev.to/mohamedelgohary</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1385700%2F8c43df02-7dfc-4be9-8ac2-414a5098653f.jpg</url>
      <title>DEV Community: Mohamed Elgohary</title>
      <link>https://dev.to/mohamedelgohary</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mohamedelgohary"/>
    <language>en</language>
    <item>
      <title>Valid product, wrong supplier: moving an ownership rule into the database</title>
      <dc:creator>Mohamed Elgohary</dc:creator>
      <pubDate>Tue, 10 Nov 2026 21:54:05 +0000</pubDate>
      <link>https://dev.to/mohamedelgohary/valid-product-wrong-supplier-moving-an-ownership-rule-into-the-database-3o92</link>
      <guid>https://dev.to/mohamedelgohary/valid-product-wrong-supplier-moving-an-ownership-rule-into-the-database-3o92</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;In EgyGomla, a request for a sample could name supplier A and a product that belonged to supplier B. Every constraint in the database accepted it.&lt;/li&gt;
&lt;li&gt;The only thing refusing that pair was one &lt;code&gt;if&lt;/code&gt; in one repository method. It protected that code path and no other.&lt;/li&gt;
&lt;li&gt;A composite foreign key on &lt;code&gt;(product_id, supplier_organization_id)&lt;/code&gt; moved the rule into the database. I kept the application check so callers still get a clear error.&lt;/li&gt;
&lt;li&gt;No production incident is behind this. I found it while reviewing the schema. The fix enforces one stored relationship, not authorization in general.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The shape of the problem
&lt;/h2&gt;

&lt;p&gt;EgyGomla is a B2B sourcing platform I built for Egyptian traders. Two of its tables hold sample requests and private-label requests. Each row carries a supplier and, optionally, a catalogue product.&lt;/p&gt;

&lt;p&gt;The foreign key from the request to the product named only &lt;code&gt;catalog_products(id)&lt;/code&gt;. So the database asked one question: does this product exist? It never asked whether the product belongs to the supplier written on the same row.&lt;/p&gt;

&lt;p&gt;That means a row pairing supplier A with supplier B's product satisfied every constraint. The product is real and the supplier is real. The relationship is wrong.&lt;/p&gt;

&lt;p&gt;There was a check. The service verified that the product belonged to the supplier, so requests arriving through the API were covered. My own migration comment describes the weakness better than I would paraphrase it: that check protects the path that runs it and no other, "not a repair script, not a future module, not a bulk import".&lt;/p&gt;

&lt;p&gt;I want to be exact about the scope here. I did not find corrupted data, and no customer was affected. This was a gap between what the application enforced and what the database enforced, and the cheapest time to close it is before something writes a bad row.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;The rule moves into the key itself. First, the parent table gets a unique constraint over the pair, so the pair can be referenced:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="s2"&gt;"catalog_products"&lt;/span&gt;
  &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="s2"&gt;"catalog_products_id_supplier_organization_id_key"&lt;/span&gt;
  &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"supplier_organization_id"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This adds no real restriction. &lt;code&gt;id&lt;/code&gt; is already the primary key, so any &lt;code&gt;(id, supplier)&lt;/code&gt; pair is already unique. The constraint exists only so that something can point at the pair.&lt;/p&gt;

&lt;p&gt;Then the child tables swap their single-column key for a composite one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="s2"&gt;"sample_requests"&lt;/span&gt;
  &lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="s2"&gt;"sample_requests_product_id_fkey"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="s2"&gt;"sample_requests"&lt;/span&gt;
  &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="s2"&gt;"sample_requests_product_id_supplier_organization_id_fkey"&lt;/span&gt;
  &lt;span class="k"&gt;FOREIGN&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"product_id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"supplier_organization_id"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="s2"&gt;"catalog_products"&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"supplier_organization_id"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;RESTRICT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;CASCADE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same change is applied to &lt;code&gt;private_label_requests&lt;/code&gt;. Now the database checks the whole pair against the catalogue. In plain terms: the pair &lt;code&gt;(P42, A)&lt;/code&gt; must exist as a row in the products table, and the row says P42 belongs to B, so the insert is rejected.&lt;/p&gt;

&lt;p&gt;The pairing becomes a fact the database keeps rather than an invariant the application remembers.&lt;/p&gt;

&lt;p&gt;In a LinkedIn post I called the column &lt;code&gt;supplier_id&lt;/code&gt; to keep the example simple. The real column is &lt;code&gt;supplier_organization_id&lt;/code&gt;, because suppliers are organizations in this schema.&lt;/p&gt;

&lt;h2&gt;
  
  
  The detail that is easy to get wrong: NULL
&lt;/h2&gt;

&lt;p&gt;A request can name no product at all. In that case &lt;code&gt;product_id&lt;/code&gt; is NULL, and the request should be valid.&lt;/p&gt;

&lt;p&gt;PostgreSQL foreign keys default to &lt;code&gt;MATCH SIMPLE&lt;/code&gt;: if any referencing column is NULL, the constraint is not checked. That is exactly what "this request names no product" should mean, so the default is what I want here. With &lt;code&gt;MATCH FULL&lt;/code&gt;, a request with no product would also have to null out its supplier, which is wrong.&lt;/p&gt;

&lt;p&gt;I mention it because the same rule cuts the other way. When a column must always be checked, &lt;code&gt;MATCH SIMPLE&lt;/code&gt; plus a nullable column means a writer that forgets to fill it in switches the check off without any error. The same trap came up again on a different constraint, and the answer there was to make the column &lt;code&gt;NOT NULL&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the service check
&lt;/h2&gt;

&lt;p&gt;I did not delete the application validation. The database is the backstop and the service is the user interface of the rule.&lt;/p&gt;

&lt;p&gt;If the composite key rejects an insert, the driver surfaces a foreign-key error. The repository translates it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Simplified from the repository.&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;errorCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s2"&gt;"P2003"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DomainValidationError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;"The linked product is not available for this supplier."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The message is deliberately vague. It does not confirm whether the product exists or who owns it. A buyer probing product ids should not be able to learn another supplier's catalogue from the difference between two error messages.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not do
&lt;/h2&gt;

&lt;p&gt;It would be easy to oversell this, so here are the limits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It enforces one stored relationship: this product belongs to this supplier. It is not an authorization system and it is not tenant isolation. Something still has to decide who may create a request in the first place.&lt;/li&gt;
&lt;li&gt;It does not fix a wrong row by changing the supplier. It rejects the pair.&lt;/li&gt;
&lt;li&gt;It cannot see anything outside the columns it covers. A different table with a similar mismatch needs its own key.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How I would test it
&lt;/h2&gt;

&lt;p&gt;A constraint that is never exercised is a comment. The test I want is at the database level, with direct inserts that skip the application entirely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Illustrative test cases, not from the repository.&lt;/span&gt;
&lt;span class="c1"&gt;-- 1. Valid pair: product P42 owned by B, request names (P42, B)  -&amp;gt; accepted&lt;/span&gt;
&lt;span class="c1"&gt;-- 2. Wrong pair: product P42 owned by B, request names (P42, A)  -&amp;gt; rejected&lt;/span&gt;
&lt;span class="c1"&gt;-- 3. No product: request names (NULL, A)                         -&amp;gt; accepted&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The three cases cover the intended path, the bug, and the NULL semantics. If all three behave, the rule lives in the schema and a future import script cannot bypass it by forgetting to call the service.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same pattern, applied wider
&lt;/h2&gt;

&lt;p&gt;The same mechanism shows up again in a later migration that makes a wrong-type tenant unrepresentable. Without it, the database would store a buyer organization as the owner of a catalogue product, or a supplier in the buyer slot of a request. Only whichever query remembered to filter on type would refuse it. The fix has three parts: a denormalised type column on the child row, a composite foreign key that forces it to agree with the organization row, and a &lt;code&gt;CHECK&lt;/code&gt; that pins it to the one legal value for that slot.&lt;/p&gt;

&lt;p&gt;The schema now has thirteen composite keys on &lt;code&gt;organizations(id, type)&lt;/code&gt;. A later design decision about marketplace accounts, which I describe in another article, refuses to disturb that network.&lt;/p&gt;

&lt;p&gt;Two practical habits came out of that migration:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Count the violators first.&lt;/strong&gt; Before adding the constraints, the migration counts existing rows that would break them and refuses to continue, naming the table and the number, rather than failing on a bare constraint name or silently changing data. A violation there is a real finding and gets reconciled by hand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mind the deploy order.&lt;/strong&gt; The new columns are &lt;code&gt;NOT NULL&lt;/code&gt; with no default, so every writer has to supply them before the migration runs. An older release cannot insert into those tables once the migration is applied.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The costs
&lt;/h2&gt;

&lt;p&gt;Composite keys are not free:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The parent needs a unique constraint over the extra pair, and every referencing table carries a copy of the owning column.&lt;/li&gt;
&lt;li&gt;Every writer must supply the extra columns, including seeds, scripts and old app versions.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ON UPDATE CASCADE&lt;/code&gt; means a change to the referenced column ripples down. That is intended, and it also means ownership changes need thought.&lt;/li&gt;
&lt;li&gt;Error handling needs work, because a raw foreign-key violation is a poor message.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I accept those costs for rules where a silent wrong row is expensive and the data is shared across tenants.&lt;/p&gt;

&lt;h2&gt;
  
  
  A review you can run on your own schema
&lt;/h2&gt;

&lt;p&gt;Look for tables that store a tenant or owner id next to a resource id. For each one, ask whether the database proves that the resource belongs to that tenant, or only proves that both exist. If it is the second, write down which code paths currently enforce the rule, and which could write around them: imports, repair scripts, admin tools, new services.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Add the composite key when the table is created.&lt;/strong&gt; Retrofitting it took a separate migration that dropped and recreated the key. A new table can have it from the first commit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test at the database level from the start.&lt;/strong&gt; I would write the three insert cases as part of the migration review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Look for the pattern earlier.&lt;/strong&gt; The same shape existed in other tables, and a wider migration followed two days later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Say what the fix covers.&lt;/strong&gt; It closes one gap. Calling it tenant isolation would be wrong.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Mohamed Elgohary, Mobile &amp;amp; Product Engineer, Cairo. mohamedelgohary.dev&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on my site: &lt;a href="https://mohamedelgohary.dev/writing/valid-product-wrong-supplier-composite-foreign-key/" rel="noopener noreferrer"&gt;mohamedelgohary.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>prisma</category>
      <category>backend</category>
    </item>
    <item>
      <title>Tested like a QA engineer: how I release mobile apps</title>
      <dc:creator>Mohamed Elgohary</dc:creator>
      <pubDate>Tue, 10 Nov 2026 21:53:36 +0000</pubDate>
      <link>https://dev.to/mohamedelgohary/tested-like-a-qa-engineer-how-i-release-mobile-apps-57fa</link>
      <guid>https://dev.to/mohamedelgohary/tested-like-a-qa-engineer-how-i-release-mobile-apps-57fa</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;I have a QA/SDET background alongside my mobile engineering work, and I release apps the way a tester would: assume the happy path works and spend the effort on everything around it.&lt;/li&gt;
&lt;li&gt;My public release checklist has 34 items in six sections: build, testing, backend, App Store, Google Play, and launch day. Store rules on it were checked against the official pages on 28 September 2026.&lt;/li&gt;
&lt;li&gt;The habits that matter most: test the exact artifact you ship, refuse every permission, break the network on purpose, upgrade from the version people already have, and keep the API compatible with old installs.&lt;/li&gt;
&lt;li&gt;A checklist is not a test suite. It tells you what to test and what evidence to keep.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where this comes from
&lt;/h2&gt;

&lt;p&gt;From August 2024 to August 2025 I worked as a Software Development Engineer in Test for TAKADOM AL-IRAQ (TTech), on a freelance basis. I was responsible for mobile, web and API testing on public-service products, and I built the Kotlin and Selenium automation for the Naftcom project, with API and regression coverage around its citizen and station workflows. That code is in a private repository, so I will not describe it in detail here.&lt;/p&gt;

&lt;p&gt;I also build mobile apps. At Apps Square I am responsible for the mobile apps of several products, from setup and architecture through testing and delivery. At Inside Tech I built Bismar's customer and seller apps on Kotlin Multiplatform as the only mobile engineer on them, and both have public App Store and Google Play listings.&lt;/p&gt;

&lt;p&gt;The testing habit comes with me. When I read a feature now, my first question is still what happens when this fails.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the thing you ship
&lt;/h2&gt;

&lt;p&gt;A debug build proves very little about a release. The checklist is explicit that I test the signed release build with production settings, because code shrinking and release configuration can break things a debug build never shows.&lt;/p&gt;

&lt;p&gt;A few items sit next to that one because they fail in the same quiet way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Version and build number.&lt;/strong&gt; Every upload needs a higher build number than the last (&lt;code&gt;versionCode&lt;/code&gt; on Android, the build number on iOS), and the visible version must match the release notes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Signing keys.&lt;/strong&gt; Use Play App Signing so a lost upload key can be reset. Back up keys and passwords outside the repository.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Crash symbols.&lt;/strong&gt; Upload the R8/ProGuard mapping file and the iOS dSYMs to the crash reporter. Without them, production stack traces are unreadable and the first real crash is a guessing game.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No debug leftovers.&lt;/strong&gt; Check base URLs, feature flags, verbose logging of personal data and hidden test menus in the release configuration.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Devices and the upgrade path
&lt;/h2&gt;

&lt;p&gt;The checklist asks for real devices at both ends: at least the oldest OS version you support and the newest, a small screen and a large one.&lt;/p&gt;

&lt;p&gt;The upgrade test is the one I would least like to skip. Install the version on the store today, create some data, then update. Sessions, saved settings and local data must survive. A fresh install can be perfect while the person who has used the app for a year loses their session.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure paths first
&lt;/h2&gt;

&lt;p&gt;This is where the QA background shows most. The checklist's network item reads: test slow, offline and flaky connections, confirm loading, retry and error states work, and make sure a repeated request never creates a duplicate order or payment.&lt;/p&gt;

&lt;p&gt;That last clause is the important one. A retry that works is only half of the feature. The other half is that the retry is safe.&lt;/p&gt;

&lt;p&gt;Here is a small example from a Flutter app I worked on, Fueler. The home screen loaded account, wallet and product data in parallel, and also resolved the user's location to find nearby stations. The original code waited for all of it before showing the page, so a slow location fix hid the wallet balance behind a loading state, even though the wallet never needed coordinates.&lt;/p&gt;

&lt;p&gt;The test that guards the fix does what a tester would do: it delays one dependency on purpose.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Simplified from the project's test file.&lt;/span&gt;
&lt;span class="n"&gt;locationResolver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// a genuinely cold fix&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;loading&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cubit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nc"&gt;Future&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;delayed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;milliseconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// The screen is already usable before the location fix lands:&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cubit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;HomeStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;loaded&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cubit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_profile&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cubit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;products&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_products&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// ...and the sections that need coordinates say so honestly:&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cubit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;locationAccess&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;LocationAccess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cubit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stations&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;isEmpty&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A second test in the same group asserts that a warm fix emits exactly one state per refresh, so the stations section does not flash back to "locating". I did not measure device timing or any conversion effect, and I am not claiming one. The point is the shape of the test: slow down one thing, and check what the rest of the screen is allowed to do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Permissions and the language you read in
&lt;/h2&gt;

&lt;p&gt;Camera, location and notifications each need a test for the case people forget: the person taps Don't Allow. The app should keep working and explain why it needs the permission.&lt;/p&gt;

&lt;p&gt;The checklist also treats Arabic as a test configuration, not a translation task: mirrored layouts, mixed Arabic and English text, and the system font size at its largest. Right-to-left bugs are easy to miss when you read the interface in only one language.&lt;/p&gt;

&lt;h2&gt;
  
  
  The API is part of the release
&lt;/h2&gt;

&lt;p&gt;A mobile release is half a backend release, and the half people forget is the old app. People update slowly, so the checklist asks that the API keep serving the installed version: old endpoints and response shapes keep working, or a forced-update path ships first.&lt;/p&gt;

&lt;p&gt;A minimum-supported-version check lets you retire a broken build without waiting for everyone to update. And on the data side: run migrations on a copy of production data first, and take a backup before the release.&lt;/p&gt;

&lt;p&gt;I carry the same habit into my own tooling. PostSync Pro, an open-source Compose Desktop toolkit I built, sends REST requests, runs bounded assertions and validates responses against an OpenAPI specification. It is published as a beta, and the screens that still use simulated data are labelled as previews.&lt;/p&gt;

&lt;h2&gt;
  
  
  A regression pass on what hurts most
&lt;/h2&gt;

&lt;p&gt;I do not try to retest everything. The checklist says to run a regression pass on the flows that matter most: sign-up, sign-in, checkout or booking, and anything a support call would be about. Notifications and deep links get their own check: they should open the right screen from a closed app, from the background and when signed out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Store rules are test cases too
&lt;/h2&gt;

&lt;p&gt;Both stores publish rules that read like requirements, so they become checks. A few from the checklist:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;App Store:&lt;/strong&gt; build with the required SDK (iOS 26 SDK or later, since 28 April 2026); keep App Privacy details in line with what the app and its SDKs collect; give App Review a working demo account and keep the backend on during review; offer account deletion inside the app; if you offer a third-party sign-in, offer an equivalent option such as Sign in with Apple; answer the encryption question in &lt;code&gt;Info.plist&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google Play:&lt;/strong&gt; keep the Data safety form in line with the app; meet the target API requirement; provide sign-in details for review that keep working (reusable, no one-time code, not expiring); and for a new personal developer account, run the closed test before applying for production.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Dates and thresholds change, so the checklist links each rule to the official page and records when it was last checked. Re-check before you rely on any of it.&lt;/p&gt;

&lt;p&gt;A store review is a separate gate with its own rules. It does not tell me whether checkout survives a flaky connection, so it never replaces my own testing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Evidence, not memory
&lt;/h2&gt;

&lt;p&gt;After a release I want to answer what was tested, on what, and what happened. A short record is enough. This is an illustrative template, not a file from a specific project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;release&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2.4.0 (build 118)&lt;/span&gt;
&lt;span class="na"&gt;artifact&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;signed release build, production config&lt;/span&gt;
&lt;span class="na"&gt;devices&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;oldest&lt;/span&gt; &lt;span class="s"&gt;supported&lt;/span&gt; &lt;span class="s"&gt;OS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;newest&lt;/span&gt; &lt;span class="s"&gt;OS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;small&lt;/span&gt; &lt;span class="s"&gt;screen&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;large&lt;/span&gt; &lt;span class="s"&gt;screen&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="na"&gt;upgrade_from_live&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;passed, session and local data kept&lt;/span&gt;
&lt;span class="na"&gt;network&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;slow / offline / flaky checked, repeated request created no duplicate&lt;/span&gt;
&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;camera, location, notifications denied once each&lt;/span&gt;
&lt;span class="na"&gt;arabic_rtl_large_text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;checked&lt;/span&gt;
&lt;span class="na"&gt;regression&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sign-in, checkout, support-call flows&lt;/span&gt;
&lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;old endpoints still served, min-version check in place&lt;/span&gt;
&lt;span class="na"&gt;symbols&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mapping file and dSYMs uploaded&lt;/span&gt;
&lt;span class="na"&gt;rollout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;staged, halt criteria agreed&lt;/span&gt;
&lt;span class="na"&gt;owner_first_72h&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;named&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Writing it down changes behaviour: a skipped line is visible, and "I thought we checked that" stops being an answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Launch day and after
&lt;/h2&gt;

&lt;p&gt;The release is not over when the build is live. The last section of the checklist asks for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;release notes written for people, in every store language, saying what changed and whether they need to do anything;&lt;/li&gt;
&lt;li&gt;someone who watches the first 72 hours, with an owner for crash reports, store reviews and support messages;&lt;/li&gt;
&lt;li&gt;a plan for a bad release, since an installed update stays until you ship a fix: keep a hotfix path and server-side switches ready;&lt;/li&gt;
&lt;li&gt;release analytics verified in production, firing once with the right values and respecting consent.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On Google Play the checklist also asks for a staged rollout: release to a small percentage first and halt if the crash or ANR rate rises. That applies to updates, not to a first release.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Turn more of the list into automated checks.&lt;/strong&gt; Some items, like the upgrade path and the repeated-request test, are repeatable enough that a person should not have to remember them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Attach an owner and evidence to each item.&lt;/strong&gt; A checkbox says someone looked. It does not say what they saw.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat the list as a starting point.&lt;/strong&gt; It is written for mobile apps with a backend. Some projects will skip items, and a project with payments will need more.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the store rules fresh.&lt;/strong&gt; They change, and the "reviewed on" date is only honest if someone updates it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Be precise about what the list is.&lt;/strong&gt; It is my public checklist, not a record that every item was done on every release.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Mohamed Elgohary, Mobile &amp;amp; Product Engineer, Cairo. mohamedelgohary.dev&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on my site: &lt;a href="https://mohamedelgohary.dev/writing/tested-like-a-qa-engineer-how-i-release-mobile-apps/" rel="noopener noreferrer"&gt;mohamedelgohary.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mobile</category>
      <category>testing</category>
      <category>flutter</category>
      <category>qa</category>
    </item>
    <item>
      <title>One identity, many roles: what I got wrong about marketplace accounts</title>
      <dc:creator>Mohamed Elgohary</dc:creator>
      <pubDate>Tue, 10 Nov 2026 21:52:52 +0000</pubDate>
      <link>https://dev.to/mohamedelgohary/one-identity-many-roles-what-i-got-wrong-about-marketplace-accounts-2fdd</link>
      <guid>https://dev.to/mohamedelgohary/one-identity-many-roles-what-i-got-wrong-about-marketplace-accounts-2fdd</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;I built EgyGomla, a B2B sourcing platform for Egyptian traders, with a separate email, account and password for each side of the market: buyers on one subdomain, suppliers on another.&lt;/li&gt;
&lt;li&gt;A wholesaler buys from factories and sells to retailers. My "clean" design made the people the product is for pay the most.&lt;/li&gt;
&lt;li&gt;The better model: the email is the person, and "buyer" and "supplier" are roles. In the data model that is one user with at most one buyer organization and one supplier organization, linked but never merged, with self-dealing blocked in the database.&lt;/li&gt;
&lt;li&gt;This is a decided design (ADR-0013, status Accepted, 14 August 2026), and the migrations for it are written in the repo. It is not a claim that traders can do this in production today.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I built first
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The production edge is one Caddy config, a hostname per surface:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;egygomla.com&lt;/code&gt;: the public marketing site&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;app.egygomla.com&lt;/code&gt;: the buyer application&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;factory.egygomla.com&lt;/code&gt;: the supplier portal&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;admin.egygomla.com&lt;/code&gt;: the platform console&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;api.egygomla.com&lt;/code&gt;: the HTTP API&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;id.egygomla.com&lt;/code&gt;: Keycloak, the OIDC issuer&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it breaks: the wholesaler
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the big marketplaces do
&lt;/h2&gt;

&lt;p&gt;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 (&lt;code&gt;login.noon.partners&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;The pattern in both: one login, the buyer experience as the front door, selling as a second surface you opt into.&lt;/p&gt;

&lt;h2&gt;
  
  
  Identity is not a role
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the constraint actually lived
&lt;/h2&gt;

&lt;p&gt;I assumed the data model was the problem. &lt;code&gt;OrganizationType&lt;/code&gt; is &lt;code&gt;BUYER | SUPPLIER | PLATFORM&lt;/code&gt;, 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:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Membership is many-to-many in practice, with a unique key on &lt;code&gt;(userId, organizationId)&lt;/code&gt;, so one person can belong to a buyer organization and a supplier organization at once.&lt;/li&gt;
&lt;li&gt;An endpoint returns a list of the person's organizations, not one.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What blocked the wholesaler was one rule in onboarding:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// onboarding service (paraphrased from the ADR)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;onboarding&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s2"&gt;"PROVISIONED"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DomainValidationError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;"This account already exists. Use an invitation to join another organization."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That rule was deliberate. An earlier decision used &lt;code&gt;(issuer, subject)&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;So I did not have a type problem. I had a founding-rule problem, and knowing that spared me a much bigger change.&lt;/p&gt;

&lt;h2&gt;
  
  
  The design: one identity, two linked organizations
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Illustrative. Simplified from the design, not the production schema.&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;organizations&lt;/span&gt;
  &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;organizations_one_founding_per_side&lt;/span&gt;
  &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;founder_user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Link, not merge
&lt;/h3&gt;

&lt;p&gt;I considered the obvious alternative: replace the type with capability flags like &lt;code&gt;can_buy&lt;/code&gt; and &lt;code&gt;can_sell&lt;/code&gt;. The ADR rejects it for two reasons.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;BOTH&lt;/code&gt; type is worse: every &lt;code&gt;CHECK&lt;/code&gt; turns into &lt;code&gt;IN ('SUPPLIER','BOTH')&lt;/code&gt;, and the guarantee that one row is not both sides disappears without a constraint failing or a test going red.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Block self-dealing
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Illustrative. The shape of the constraint, not the exact production DDL.&lt;/span&gt;
&lt;span class="c1"&gt;-- Each row carries a denormalised copy of each party's link group,&lt;/span&gt;
&lt;span class="c1"&gt;-- held honest by a composite foreign key to organizations.&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;rfq_recipients&lt;/span&gt;
  &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;rfq_recipients_not_self_dealing&lt;/span&gt;
  &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buyer_link_group_id&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;supplier_link_group_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One detail changed between the sketch and the implementation. The sketch had a nullable link group with &lt;code&gt;IS NULL&lt;/code&gt; branches. But a PostgreSQL foreign key is &lt;code&gt;MATCH SIMPLE&lt;/code&gt; 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 &lt;code&gt;&amp;lt;&amp;gt;&lt;/code&gt; over two &lt;code&gt;NOT NULL&lt;/code&gt; columns.&lt;/p&gt;

&lt;p&gt;The design writes its limits down too:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Only a declared link is caught. Two organizations created with two emails and no declaration pass, as they do today.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The link must never widen a read
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Auth and sessions across subdomains
&lt;/h2&gt;

&lt;p&gt;One identity across several hostnames sounds like it needs shared cookies. It does not.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Each hostname is its own origin. &lt;code&gt;__Host-&lt;/code&gt; cookies carry no &lt;code&gt;Domain&lt;/code&gt; attribute, so a session on one surface is never shared with another, even by accident.&lt;/li&gt;
&lt;li&gt;Identity is shared through the issuer. Keycloak at &lt;code&gt;id.&lt;/code&gt; is the OIDC issuer, and external identities are keyed by &lt;code&gt;(issuer, subject)&lt;/code&gt;, so the issuer's hostname is a data-model boundary, not branding.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;li&gt;Application session material lives in Redis behind an opaque cookie.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you already have duplicate accounts
&lt;/h2&gt;

&lt;p&gt;I have not run a merge of duplicate accounts in EgyGomla, and nothing here claims that. This is general guidance.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Count before you change anything.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never auto-merge on a match.&lt;/strong&gt; Similar details do not prove the same person, and a merge is hard to undo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Let the person confirm both.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep catalogues and history where they are.&lt;/strong&gt; The link is for understanding, not for combining data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expand, backfill, contract.&lt;/strong&gt; Add nullable columns, backfill, then add constraints, and build unique indexes concurrently on a live database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ship writers first.&lt;/strong&gt; If a new column is &lt;code&gt;NOT NULL&lt;/code&gt; with no default, every writer, including seeds and scripts, must supply it before the migration runs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expect a tail.&lt;/strong&gt; Some people will never link. They behave as they do today, which is acceptable if the limit is written down.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Start from the heaviest user's day.&lt;/strong&gt; Ask who the person is before deciding what each page needs, and walk through a day for the user who touches both sides.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Find where the constraint lives first.&lt;/strong&gt; I blamed the enum; the blocker was an onboarding rule I had chosen on purpose.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write the abuse chain before the happy path.&lt;/strong&gt; The review system leaned on a property no constraint stated. Making it explicit was the most important part of the design.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not ship the second-organization path alone.&lt;/strong&gt; 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".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep marketing behind the feature.&lt;/strong&gt; The ADR forbids copy like "one account buys and sells" until the feature exists, and a copy test guards the site's other promises.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Say what is decided and what is shipped.&lt;/strong&gt; This article describes a decision, not something in front of traders.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Mohamed Elgohary, Mobile &amp;amp; Product Engineer, Cairo. mohamedelgohary.dev&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on my site: &lt;a href="https://mohamedelgohary.dev/writing/one-identity-many-roles-marketplace-accounts/" rel="noopener noreferrer"&gt;mohamedelgohary.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>systemdesign</category>
      <category>architecture</category>
      <category>authentication</category>
      <category>postgres</category>
    </item>
  </channel>
</rss>
