<?xml version="1.0" encoding="utf-8"?><?xml-stylesheet type="text/xsl" href="rss.xsl"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
    <channel>
        <title>DartWay Blog</title>
        <link>https://dartway.dev/blog</link>
        <description>Full-stack Dart in production: engineering writing, case breakdowns, framework releases.</description>
        <lastBuildDate>Sat, 05 Sep 2026 00:00:00 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <language>en</language>
        <copyright>Copyright © 2026 DartWay.</copyright>
        <item>
            <title><![CDATA[One account, two doors: what phone-or-email login really costs]]></title>
            <link>https://dartway.dev/blog/one-account-two-doors</link>
            <guid>https://dartway.dev/blog/one-account-two-doors</guid>
            <pubDate>Sat, 05 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Letting users sign in with either a phone number or an email looks like a one-line change. It is a data-model change, and the bill arrives as duplicate accounts you cannot merge.]]></description>
            <content:encoded><![CDATA[<p>Letting people sign in with <strong>either</strong> a phone number or an email looks like a
one-line change: widen the lookup, accept both. We shipped it last week in a live
project, and the one-line change turned out to be a data-model change with a bill
attached.</p>
<p>Here is what it actually costs, in the order the costs show up.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-lookup-was-never-the-problem">The lookup was never the problem<a href="https://dartway.dev/blog/one-account-two-doors#the-lookup-was-never-the-problem" class="hash-link" aria-label="Direct link to The lookup was never the problem" title="Direct link to The lookup was never the problem" translate="no">​</a></h2>
<p>The framework stored a single <code>userIdentifier</code> column and matched it by strict
equality. Ask it to find a profile by phone <em>or</em> email and there is no seam to
extend — not because the query is hard, but because authentication itself requires
that column to exist. Remove it and the core fails at startup.</p>
<p>So the first fix is not "search two fields". It is <strong>giving the application a say
in how a user is found</strong>:</p>
<div class="language-dart codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-dart codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token class-name">DwAuthConfig</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  findUserProfileByIdentifier</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token plain">session</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> identifier</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">async</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token comment" style="color:rgb(98, 114, 164)">// the app decides what "the same person" means</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><br></div></code></pre></div></div>
<p>The column stops being mandatory, the identifier arrives already normalised, and
the framework stops pretending it knows what identity means in your domain. Three
obligations move to the application, and the docs say so out loud — a resolver that
silently does the wrong thing is worse than no resolver.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="searching-two-fields-fixes-nothing-on-its-own">Searching two fields fixes nothing on its own<a href="https://dartway.dev/blog/one-account-two-doors#searching-two-fields-fixes-nothing-on-its-own" class="hash-link" aria-label="Direct link to Searching two fields fixes nothing on its own" title="Direct link to Searching two fields fixes nothing on its own" translate="no">​</a></h2>
<p>This is the part that surprised us, and it is the reason this post exists.</p>
<p>A user who signed up by phone has an <strong>empty email column</strong>. Searching both fields
finds nothing new, because there is nothing in the second field. The lookup is
correct and useless at the same time.</p>
<p>To fill that column you need a way to attach a second channel to an existing
account — which means <code>addAuthProvider</code> and <code>changeIdentifier</code> have to actually
work. In our case both threw <code>UnimplementedError</code>, and there was no screen for it
in any mockup. Until that exists, <strong>signing in from the other channel keeps quietly
creating a second account</strong>, exactly as before.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-duplicates-are-already-there">The duplicates are already there<a href="https://dartway.dev/blog/one-account-two-doors#the-duplicates-are-already-there" class="hash-link" aria-label="Direct link to The duplicates are already there" title="Direct link to The duplicates are already there" translate="no">​</a></h2>
<p>Then comes the part you cannot postpone. We went to add unique indexes on <code>phone</code>
and <code>email</code> and found there were <strong>no unique indexes at all</strong> — not on the new
columns, not on the old <code>userIdentifier</code>. Two rows with the same value were already
legal, and the lookup resolved to whichever one the database felt like returning.</p>
<p>You cannot add the index until the duplicates are merged. And the duplicates exist
precisely <em>because</em> login from a second channel used to create a new account. The
bug and its cleanup are the same object.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="merging-is-an-event-not-an-action">Merging is an event, not an action<a href="https://dartway.dev/blog/one-account-two-doors#merging-is-an-event-not-an-action" class="hash-link" aria-label="Direct link to Merging is an event, not an action" title="Direct link to Merging is an event, not an action" translate="no">​</a></h2>
<p>Account merge is irreversible: one profile absorbs another, and afterwards nobody
can reconstruct what came from where. So we modelled it as a <code>ProfileMerge</code> <strong>event</strong>
rather than a method call — who merged, whom, and what moved. An action leaves the
database consistent and the story gone; an event leaves both.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-we-would-tell-ourselves-a-week-ago">What we would tell ourselves a week ago<a href="https://dartway.dev/blog/one-account-two-doors#what-we-would-tell-ourselves-a-week-ago" class="hash-link" aria-label="Direct link to What we would tell ourselves a week ago" title="Direct link to What we would tell ourselves a week ago" translate="no">​</a></h2>
<ul>
<li class="">"Login by phone or email" is not an auth-screen task. It is a <strong>profile identity</strong>
task, and the screen is the last five percent.</li>
<li class="">Ship the <strong>attach-a-second-channel</strong> flow in the same release. Without it, the
feature is a lookup that finds nothing.</li>
<li class="">Check for unique indexes <strong>before</strong> you promise anything. Ours were missing for the
entire life of the project, and nobody noticed because nothing enforced them.</li>
<li class="">Verify on a live database with a real round trip — phone signup, email attach,
email login into the same account, one row in the table. We had 23 server tests
including that pass; the reading-the-code version of this article would have been
wrong in two places.</li>
</ul>
<p>The framework side landed in <code>dartway</code> core <code>0.12.1</code> — the auth resolver and a
working <code>changeIdentifier</code>. If you are running the earlier version, the migration is
the interesting part, not the API.</p>]]></content:encoded>
            <category>Engineering</category>
            <category>Case breakdown</category>
        </item>
    </channel>
</rss>