<?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: Son Tran</title>
    <description>The latest articles on DEV Community by Son Tran (@tbson87).</description>
    <link>https://dev.to/tbson87</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%2F79995%2F6de9d37a-5521-4b7a-a835-9787e74caf0c.jpg</url>
      <title>DEV Community: Son Tran</title>
      <link>https://dev.to/tbson87</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tbson87"/>
    <language>en</language>
    <item>
      <title>Design First, Ship by Context: One ERD From Sketch to Production</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Wed, 15 Jul 2026 10:28:00 +0000</pubDate>
      <link>https://dev.to/tbson87/design-first-ship-by-context-one-erd-from-sketch-to-production-4jgg</link>
      <guid>https://dev.to/tbson87/design-first-ship-by-context-one-erd-from-sketch-to-production-4jgg</guid>
      <description>&lt;p&gt;There is a moment in every project where the schema design dies. Not when it's drawn - drawing it is the fun part - but at the handoff, when implementation starts. The diagram says &lt;code&gt;subscriptions&lt;/code&gt; has nine fields; someone now has to retype those nine fields as an ORM model, then do it again for fifty-nine more entities. From that day forward the code is the model, the database evolves through migrations, and the diagram becomes a souvenir.&lt;/p&gt;

&lt;p&gt;The tooling world's answer is to regenerate the diagram from the code, and the duct tape shows. Prisma users reach for a diagram generator and find &lt;a href="https://github.com/keonik/prisma-erd-generator/issues/65" rel="noopener noreferrer"&gt;schema generation fails under WSL because of a puppeteer dependency&lt;/a&gt; three layers down. Rails users run &lt;code&gt;rake erd&lt;/code&gt; into a &lt;a href="https://github.com/voormedia/rails-erd/issues/278" rel="noopener noreferrer"&gt;Graphviz-is-not-in-your-path error&lt;/a&gt; that persists after Graphviz is installed. Even when these pipelines work, they emit an auto-laid-out artifact - a rendering, not a design.&lt;/p&gt;

&lt;p&gt;It doesn't have to work this way. Here is the full loop from a real project - about sixty entities, designed once, shipped context by context - where the diagram stays the design the whole way through. It is an &lt;strong&gt;ERD single source of truth&lt;/strong&gt; workflow in four phases, and at no point does the ERD write a single statement to the database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 1: Design the whole model up front
&lt;/h2&gt;

&lt;p&gt;Before any code, the entire schema goes onto &lt;a href="https://schemity.com/doc/erd-canvas" rel="noopener noreferrer"&gt;the canvas&lt;/a&gt;: entities aligned, relationship lines routed so they read cleanly, and - this is the load-bearing part - &lt;a href="https://schemity.com/doc/legends-and-annotations" rel="noopener noreferrer"&gt;legends&lt;/a&gt; drawn around each group of entities to mark the bounded contexts. Identity, catalog, billing, fulfillment: each becomes a named, colored region, and coloring the entities makes every context traceable by hue. This is &lt;strong&gt;domain-driven design database schema&lt;/strong&gt; work in its natural habitat - you are deciding where the seams are while moving boxes is still free.&lt;/p&gt;

&lt;p&gt;Design up front does not mean design perfectly. It means capturing everything you currently know about the domain in one picture, so that implementation starts from a map instead of a blank file. Sixty entities sounds heavy; on an infinite canvas with locked legends - drag the region and everything inside moves as one block - it is an afternoon of thinking made visible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 2: Implement one context at a time, with AI writing the code
&lt;/h2&gt;

&lt;p&gt;Now the trick that makes the design &lt;em&gt;executable&lt;/em&gt;. Right-click a legend and Schemity exports the SQL for every entity inside it - the whole bounded context as &lt;code&gt;CREATE TABLE&lt;/code&gt; statements in one action. That SQL, plus one line of instruction ("this is the billing module, generate the models"), goes to Claude Code, which writes the ORM models - and sometimes the CRUD layer - for that context.&lt;/p&gt;

&lt;p&gt;Then the direction of authority flips, deliberately: migration files are generated &lt;em&gt;from those models&lt;/em&gt; and applied by the project's own source code, the way the ORM intends. The ERD provided the spec; the code owns the database from here. Notice what the diagram tool did not do - it never connected as a writer, never applied DDL. The &lt;strong&gt;bounded context ERD&lt;/strong&gt; was the prompt, and a context at a time turns out to be the right unit for AI codegen too: small enough to fit in a model's head, large enough to be a coherent module.&lt;/p&gt;

&lt;p&gt;This is a different intersection of AI and ERDs than &lt;a href="https://schemity.com/blog/ai-database-design-without-the-cloud" rel="noopener noreferrer"&gt;chatting with a model to design your schema&lt;/a&gt; - here the human designed, and AI does the typing. What they share is the privacy shape: &lt;strong&gt;private AI database design&lt;/strong&gt; means your schema goes only where you point it, from a local file on your machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 3: Connect to the live database and arrange it once
&lt;/h2&gt;

&lt;p&gt;With the first contexts migrated into the local database, create a live connection and &lt;a href="https://schemity.com/doc/reverse-engineer-database" rel="noopener noreferrer"&gt;reverse-engineer it&lt;/a&gt;. This produces the second diagram of the project - the connected one - and yes, it arrives unarranged. But arranging it is not a redraw; it is a paste. The original design is open in the next tab, so select a context's entities there, copy, and paste into the connected diagram. Schemity's paste is merge-aware: for an entity that already exists in the target - matched by name - it transfers only the visual attributes - position, size, and color - with the color carrying to the entity's relationships. Your designed layout moves between diagrams as data, context by context. (Relationship routing stays per-diagram, since the other end of a line may sit somewhere different here.) Recreate the legends around the groups, and from this point the connected diagram is the living mirror of the real schema.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 4: Refresh, drag, repeat forever
&lt;/h2&gt;

&lt;p&gt;Eventually the code outgrows the original design - it always does. A new table &lt;code&gt;A&lt;/code&gt; belongs in context &lt;code&gt;C1&lt;/code&gt;, so it goes into the ORM model first, the generated migration runs, and then the update to the diagram is the whole point of this post: open the ERD (or hit &lt;strong&gt;Reset ERD&lt;/strong&gt;), and &lt;a href="https://schemity.com/doc/resync-database" rel="noopener noreferrer"&gt;re-sync pulls the fresh schema&lt;/a&gt; with the live database as the source of truth. Table &lt;code&gt;A&lt;/code&gt; is simply there, waiting for its place in &lt;code&gt;C1&lt;/code&gt;. Drag it in - or, if &lt;code&gt;A&lt;/code&gt; was designed in the original tab first, copy that context and paste: the merge-aware paste snaps &lt;code&gt;A&lt;/code&gt; to its designed position, size, and color while touching nothing else. Everything already arranged keeps its layout; anything dropped from the database is gone from the picture. &lt;a href="https://schemity.com/blog/keeping-your-erd-updated-shouldnt-be-a-second-job" rel="noopener noreferrer"&gt;Updating the diagram stops being a job&lt;/a&gt; and becomes a gesture - a drag, or a paste.&lt;/p&gt;

&lt;p&gt;From here the loop is closed: design informs code, code migrates the database, the database refreshes the diagram, and the diagram - grouped back into contexts - informs the next round of design.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trick: let the diff write the prompt
&lt;/h2&gt;

&lt;p&gt;Adding a table is the easy change. The subtler case is modifying what exists - splitting a field, tightening a type, hanging a new constraint on an old column. The default move is to describe the change to Claude Code in prose and let it edit the model files, and then you inherit a review job: did it interpret "split the address out of customers" the way you meant?&lt;/p&gt;

&lt;p&gt;There is a better move, and it uses the diagram as the editor. Make the change on the connected ERD - the same gesture as designing - and press Save. Schemity compares the edited diagram against the live database and shows the &lt;a href="https://schemity.com/doc/migration-sql-diff" rel="noopener noreferrer"&gt;migration SQL diff&lt;/a&gt;: the exact &lt;code&gt;ALTER&lt;/code&gt; statements, in your engine's dialect, that your edit implies. Now &lt;em&gt;don't apply it&lt;/em&gt;. Copy that SQL, hand it to Claude Code along with the reason for the change, and it updates the ORM model precisely - because a SQL diff is an unambiguous spec in a way prose never is. The review burden collapses: you already saw the intended change, statement by statement, before the AI touched a file.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw5jmu3e8ukgvwv172gc5.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw5jmu3e8ukgvwv172gc5.webp" alt="Schemity's Migration confirm dialog after adding an address field to the users entity on the canvas - the generated ALTER TABLE statement with a copy button, and a Cancel button next to Apply SQL statement; in this workflow you copy the SQL for Claude Code and cancel" width="799" height="572"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;From there the loop runs as always: the ORM generates its migration files from the changed model, your source code applies them, and the next refresh brings your own change back to the canvas through re-sync - confirmed by the database itself. One design gesture became a precise diff, the diff became the prompt, the prompt became code, and the code migrated the database. You never left the canvas to specify the change.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ERD never writes to the database
&lt;/h2&gt;

&lt;p&gt;Run the whole loop again in your head and count the write paths to the database. There is exactly one: the ORM's migrations, executed by your source code. The ERD reads - at reverse-engineering, at every re-sync - and never writes, not because the tool couldn't, but because the workflow makes it unnecessary. Even the migration diff, the one feature built to write, spends its life here as a messenger: a generated spec you hand to the AI, never a statement you run. The canvas keeps the boundary honest, too: anything the database has not confirmed yet - a sketched table, an over-eager paste - wears a dashed border until a successful migration makes it real, and saving warns you about entities that exist nowhere but the picture.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzlm6z8uhueustqt0p8xh.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzlm6z8uhueustqt0p8xh.webp" alt="A live-connected Schemity diagram where a newly added comments entity wears a dashed border while every table the database has confirmed stays solid - the draft is visible at a glance before any migration runs" width="799" height="584"&gt;&lt;/a&gt; That asymmetry is what lets you point a diagram tool at a database &lt;a href="https://schemity.com/blog/documenting-production-shouldnt-feel-dangerous" rel="noopener noreferrer"&gt;without the low-grade fear that a canvas edit becomes a DDL statement&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;It also explains why this works as an &lt;strong&gt;offline ERD tool&lt;/strong&gt; workflow with nothing to configure: both diagrams are &lt;a href="https://schemity.com/doc/json-storage-format" rel="noopener noreferrer"&gt;plain JSON files&lt;/a&gt; in the repo, committed alongside the models they specified, so the design history and the code history &lt;a href="https://schemity.com/blog/erd-lives-in-your-git-repo" rel="noopener noreferrer"&gt;live in the same Git log&lt;/a&gt;. No sync service, no diagram server, no write credentials - a &lt;strong&gt;desktop ERD tool&lt;/strong&gt;, a local database, and an AI assistant that receives exactly the SQL you right-clicked.&lt;/p&gt;

&lt;p&gt;Design first, so the thinking happens while change is free. Ship by context, so every unit of work has a boundary. Let the database drive the diagram after that, so the truth maintains itself. Sixty entities in, the diagram is still the design - which is all anyone ever wanted from an ERD.&lt;/p&gt;

&lt;p&gt;Originally published at &lt;a href="https://schemity.com/blog/design-first-ship-by-context/" rel="noopener noreferrer"&gt;https://schemity.com&lt;/a&gt; on July 14, 2026.&lt;/p&gt;

</description>
      <category>erd</category>
      <category>databasedesign</category>
      <category>datamodeling</category>
    </item>
    <item>
      <title>You Have Been Reading ERD Notation Wrong</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Mon, 13 Apr 2026 01:58:10 +0000</pubDate>
      <link>https://dev.to/tbson87/you-have-been-reading-erd-notation-wrong-b1o</link>
      <guid>https://dev.to/tbson87/you-have-been-reading-erd-notation-wrong-b1o</guid>
      <description>&lt;p&gt;If you have ever designed a database schema, you have probably drawn an ERD with crow's foot notation. You learned that a circle means optional, a line means mandatory, and a crow's foot means many. You felt confident.&lt;/p&gt;

&lt;p&gt;Then you opened your SQL editor and had to guess whether the FK should be &lt;code&gt;NULL&lt;/code&gt; or &lt;code&gt;NOT NULL&lt;/code&gt;. You were not sure if you needed &lt;code&gt;UNIQUE&lt;/code&gt;. And nobody told you that one of the notation symbols cannot be enforced in SQL at all.&lt;/p&gt;

&lt;p&gt;This article fixes that. We will cover what each symbol actually means, how to translate notation directly into SQL constraints, and where the notation is richer than what your database can enforce.&lt;/p&gt;

&lt;p&gt;Throughout this article, we use one consistent convention: &lt;strong&gt;parent entity is always on the left, child entity is always on the right.&lt;/strong&gt; This makes every notation example unambiguous.&lt;/p&gt;




&lt;h2&gt;
  
  
  A Brief History
&lt;/h2&gt;

&lt;p&gt;The crow's foot symbol was introduced by Gordon Everest in his 1976 paper "Basic Data Structure Models Explained with a Common Example." Everest used an "inverted arrow" to represent the one-to-many relationship in hierarchical and network data structures, noting that it "inherently and visually depicts the one-to-many relationship" without implying a direction of access.&lt;/p&gt;

&lt;p&gt;At that stage, the notation only expressed &lt;strong&gt;maximum degree&lt;/strong&gt;: whether a link could have one or many instances. The minimum degree symbols (the circle and the additional line that express zero vs. one) were added later by other authors and tools as the notation evolved into what we now call crow's foot or Information Engineering (IE) notation.&lt;/p&gt;

&lt;p&gt;This origin matters for one reason: the symbol that most engineers obsess over - the minimum degree symbol - was not even part of the original notation. As we will see, there is a strong argument that it still should not be.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Rule Most Tutorials Miss
&lt;/h2&gt;

&lt;p&gt;Before explaining any symbol, there is one reading rule that almost every tutorial skips:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Symbols are read from the entity outward, in pairs. The symbols closest to an entity describe the constraints on that entity.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is the part that trips most engineers up. The symbols on the left side of the line describe the left entity. The symbols on the right side describe the right entity. Not the other way around.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[users] || ----- o&amp;lt; [blogs]
         ↑       ↑
   read from   read from
   users out   blogs out
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;||&lt;/code&gt; near users = a blog must have exactly one user (FK NOT NULL)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;o&amp;lt;&lt;/code&gt; near blogs = a user can have zero or many blogs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With that rule clear, everything else follows.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Symbols
&lt;/h2&gt;

&lt;p&gt;Crow's foot notation uses three base symbols:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symbol&lt;/th&gt;
&lt;th&gt;Name&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;o&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Circle&lt;/td&gt;
&lt;td&gt;Zero (optional)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;⎮&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Line&lt;/td&gt;
&lt;td&gt;One (mandatory)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Crow's foot&lt;/td&gt;
&lt;td&gt;Many&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each end of a relationship line uses a &lt;strong&gt;pair&lt;/strong&gt; of symbols, read left to right as they visually appear. The symbol closest to the entity is the &lt;strong&gt;maximum&lt;/strong&gt;. The symbol closest to the line is the &lt;strong&gt;minimum&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  All Valid Combinations
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Parent side (left)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symbol&lt;/th&gt;
&lt;th&gt;Min&lt;/th&gt;
&lt;th&gt;Max&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;⎮⎮&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Child must reference this parent. FK is NOT NULL.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;⎮o&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Child optionally references this parent. FK is nullable.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Child side (right)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symbol&lt;/th&gt;
&lt;th&gt;Min&lt;/th&gt;
&lt;th&gt;Max&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;⎮⎮&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Exactly one child. Used in mandatory 1:1 relationships.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;o⎮&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Zero or one child. Used in optional 1:1 relationships.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;o&amp;lt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;many&lt;/td&gt;
&lt;td&gt;Zero or more children. The most common case.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;⎮&amp;lt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;many&lt;/td&gt;
&lt;td&gt;One or more children. The parent must have at least one child.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That is the complete set. Four combinations on the child side, two on the parent side. No others exist.&lt;/p&gt;




&lt;h2&gt;
  
  
  Notation to SQL: The Complete Mapping
&lt;/h2&gt;

&lt;p&gt;Let us use a concrete domain: &lt;code&gt;users&lt;/code&gt;, &lt;code&gt;profiles&lt;/code&gt;, &lt;code&gt;avatars&lt;/code&gt;, &lt;code&gt;blogs&lt;/code&gt;, &lt;code&gt;addresses&lt;/code&gt;, and &lt;code&gt;attachments&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flwawvz9ycubupx0ogylk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flwawvz9ycubupx0ogylk.png" alt="concrete domain fig"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  1. users and profiles - &lt;code&gt;||——||&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;A user must always have exactly one profile. A profile must always belong to exactly one user.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[users] ||——|| [profiles]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="nv"&gt;"profiles"&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nv"&gt;"id"&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="nv"&gt;"user_id"&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"display_name"&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"bio"&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"created_at"&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;CURRENT_TIMESTAMP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"updated_at"&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;CURRENT_TIMESTAMP&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="nv"&gt;"profiles"&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="nv"&gt;"profiles_pkey"&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"id"&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="nv"&gt;"profiles"&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="nv"&gt;"profiles_user_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="nv"&gt;"user_id"&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="nv"&gt;"profiles"&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="nv"&gt;"profiles_user_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="nv"&gt;"user_id"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="nv"&gt;"users"&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;NOT NULL&lt;/code&gt; enforces that a profile must belong to a user&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;UNIQUE&lt;/code&gt; enforces that a user can have at most one profile&lt;/li&gt;
&lt;li&gt;"A user must have a profile" is &lt;strong&gt;not enforced by SQL&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2. users and avatars - &lt;code&gt;||——o|&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;A user can have zero or one avatar. If an avatar exists, it must belong to exactly one user.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[users] ||——o| [avatars]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="nv"&gt;"avatars"&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nv"&gt;"id"&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="nv"&gt;"user_id"&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"url"&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"created_at"&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;CURRENT_TIMESTAMP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"updated_at"&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;CURRENT_TIMESTAMP&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="nv"&gt;"avatars"&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="nv"&gt;"avatars_pkey"&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"id"&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="nv"&gt;"avatars"&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="nv"&gt;"avatars_user_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="nv"&gt;"user_id"&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="nv"&gt;"avatars"&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="nv"&gt;"avatars_user_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="nv"&gt;"user_id"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="nv"&gt;"users"&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;NOT NULL&lt;/code&gt; enforces the avatar must belong to a user&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;UNIQUE&lt;/code&gt; still enforces at most one avatar per user&lt;/li&gt;
&lt;li&gt;Note: in SQL, &lt;code&gt;NULL&lt;/code&gt; values are not considered equal, so multiple &lt;code&gt;NULL&lt;/code&gt; values are allowed even with a &lt;code&gt;UNIQUE&lt;/code&gt; constraint&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. users and blogs - &lt;code&gt;||——o&amp;lt;&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;A user can have zero or many blogs. A blog must belong to exactly one user.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[users] ||——o&amp;lt; [blogs]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="nv"&gt;"blogs"&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nv"&gt;"id"&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="nv"&gt;"user_id"&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"slug"&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"title"&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"description"&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"content"&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"created_at"&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;CURRENT_TIMESTAMP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"updated_at"&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;CURRENT_TIMESTAMP&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="nv"&gt;"blogs"&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="nv"&gt;"blogs_pkey"&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"id"&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="nv"&gt;"blogs"&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="nv"&gt;"blogs_slug_key"&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"slug"&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="nv"&gt;"blogs"&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="nv"&gt;"blogs_title_key"&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"title"&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="nv"&gt;"blogs"&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="nv"&gt;"blogs_user_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="nv"&gt;"user_id"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="nv"&gt;"users"&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;NOT NULL&lt;/code&gt; enforces that every blog must have an owner&lt;/li&gt;
&lt;li&gt;No &lt;code&gt;UNIQUE&lt;/code&gt; because one user can have many blogs&lt;/li&gt;
&lt;li&gt;This is the most common pattern in relational schemas&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4. users and addresses - &lt;code&gt;||——|&amp;lt;&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;A user must have at least one address. An address must belong to exactly one user.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[users] ||——|&amp;lt; [addresses]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="nv"&gt;"addresses"&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nv"&gt;"id"&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="nv"&gt;"user_id"&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"line_1"&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"line_2"&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"postcode"&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"created_at"&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;CURRENT_TIMESTAMP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"updated_at"&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;CURRENT_TIMESTAMP&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="nv"&gt;"addresses"&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="nv"&gt;"addresses_pkey"&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"id"&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="nv"&gt;"addresses"&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="nv"&gt;"addresses_user_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="nv"&gt;"user_id"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="nv"&gt;"users"&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;NOT NULL&lt;/code&gt; enforces that every address must belong to a user&lt;/li&gt;
&lt;li&gt;"A user must have at least one address" is &lt;strong&gt;not enforced by SQL&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  5. blogs and attachments - &lt;code&gt;|o——o&amp;lt;&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;An attachment can optionally belong to a blog. A blog can have zero or many attachments.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[blogs] |o——o&amp;lt; [attachments]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="nv"&gt;"attachments"&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nv"&gt;"id"&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="nv"&gt;"blog_id"&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"url"&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"created_at"&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;CURRENT_TIMESTAMP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;"updated_at"&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;CURRENT_TIMESTAMP&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="nv"&gt;"attachments"&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="nv"&gt;"attachments_pkey"&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"id"&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="nv"&gt;"attachments"&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="nv"&gt;"attachments_blog_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="nv"&gt;"blog_id"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="nv"&gt;"blogs"&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;|o&lt;/code&gt; near blogs = FK is nullable, an attachment can exist without a blog&lt;/li&gt;
&lt;li&gt;This is the nullable FK case that most tutorials never explain&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The Full Mapping Table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Notation&lt;/th&gt;
&lt;th&gt;Side&lt;/th&gt;
&lt;th&gt;FK nullable?&lt;/th&gt;
&lt;th&gt;UNIQUE?&lt;/th&gt;
&lt;th&gt;Reference status&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;⎮⎮&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Parent (left)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;--&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NOT NULL REFERENCES&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;⎮o&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Parent (left)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;--&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NULL REFERENCES&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;⎮⎮&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Child (right, 1:1)&lt;/td&gt;
&lt;td&gt;--&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;--&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;o⎮&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Child (right, 1:1)&lt;/td&gt;
&lt;td&gt;--&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;--&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;o&amp;lt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Child (right)&lt;/td&gt;
&lt;td&gt;--&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;--&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;⎮&amp;lt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Child (right)&lt;/td&gt;
&lt;td&gt;--&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;--&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  The Gap: What SQL Cannot Enforce
&lt;/h2&gt;

&lt;p&gt;Here is the part that most tutorials never tell you.&lt;/p&gt;

&lt;p&gt;Two of the six notation cases produce &lt;strong&gt;identical SQL&lt;/strong&gt; but mean different things:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Notation&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;th&gt;Reference status&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;o&amp;lt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Zero or more children&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NOT NULL REFERENCES&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;⎮&amp;lt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;One or more children&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NOT NULL REFERENCES&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The SQL is the same. The &lt;code&gt;|&amp;lt;&lt;/code&gt; minimum of one is purely a business rule. SQL has no constraint that says "this parent must have at least one child row."&lt;/p&gt;

&lt;p&gt;Similarly, &lt;code&gt;||——||&lt;/code&gt; (user must have exactly one profile) is only half-enforced. The FK &lt;code&gt;NOT NULL UNIQUE&lt;/code&gt; ensures every profile has a user and each user has at most one profile. But nothing in SQL prevents you from creating a user with no profile at all.&lt;/p&gt;

&lt;h3&gt;
  
  
  The unenforced cases
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Notation&lt;/th&gt;
&lt;th&gt;What SQL enforces&lt;/th&gt;
&lt;th&gt;What SQL cannot enforce&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;⎮⎮——⎮⎮&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Profile must have a user. At most one profile per user.&lt;/td&gt;
&lt;td&gt;User must have a profile.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;⎮⎮——⎮&amp;lt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Address must have a user.&lt;/td&gt;
&lt;td&gt;User must have at least one address.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In both cases, the gap must be handled at the application level. This is intentional and correct.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why this gap exists by design
&lt;/h3&gt;

&lt;p&gt;This is not a flaw in crow's foot notation. It is a deliberate design decision.&lt;/p&gt;

&lt;p&gt;Carlis and Maguire, in their book &lt;em&gt;Mastering Data Modeling&lt;/em&gt; (Addison-Wesley, 2001), argue explicitly against capturing minimum degree constraints on a data model diagram. Their reasoning is direct: minimum degree constraints "are so often false or are a function of processing" rather than a true property of the data itself.&lt;/p&gt;

&lt;p&gt;They give a concrete example: a hospital clerk reports that "every patient has at least one diagnosis code." This looks like a valid minimum constraint. But it is false - a patient admitted to the emergency room has no diagnosis code yet. The constraint only holds at a specific stage of the process, not at all times. It is a processing rule, not a data rule.&lt;/p&gt;

&lt;p&gt;The same logic applies to your schema. "A user must have at least one address" is almost certainly a processing rule - enforced when a user checks out, or when an order is submitted, not at the moment the user row is created. SQL enforcing it at the database level would break every user registration flow that creates the user first and collects the address later.&lt;/p&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concept&lt;/th&gt;
&lt;th&gt;Key takeaway&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Convention&lt;/td&gt;
&lt;td&gt;Parent on the left, child on the right&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reading rule&lt;/td&gt;
&lt;td&gt;Symbols describe the entity they touch, read outward in pairs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;⎮o&lt;/code&gt; vs &lt;code&gt;⎮⎮&lt;/code&gt; on parent&lt;/td&gt;
&lt;td&gt;Nullable FK vs NOT NULL FK&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;o⎮&lt;/code&gt; vs &lt;code&gt;⎮⎮&lt;/code&gt; on child&lt;/td&gt;
&lt;td&gt;Identical SQL, only intent differs both 1:1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;o&amp;lt;&lt;/code&gt; vs &lt;code&gt;⎮&amp;lt;&lt;/code&gt; on child&lt;/td&gt;
&lt;td&gt;Identical SQL, only intent differs both 1:N&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The gap&lt;/td&gt;
&lt;td&gt;Minimum degree constraints are often processing rules, not data rules&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Crow's foot notation is more expressive than SQL. That is not a flaw, it is by design. The notation captures business intent. SQL enforces what it can. Your application handles the rest.&lt;/p&gt;




&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Everest, G. C. (1976). "Basic Data Structure Models Explained with a Common Example." &lt;em&gt;Proceedings of the Fifth Texas Conference on Computing Systems&lt;/em&gt;, pages 39-46. IEEE Computer Society.&lt;/li&gt;
&lt;li&gt;Carlis, J. and Maguire, J. (2001). &lt;em&gt;Mastering Data Modeling: A User-Driven Approach&lt;/em&gt;. Addison-Wesley.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This post uses &lt;a href="https://schemity.com/" rel="noopener noreferrer"&gt;Schemity&lt;/a&gt; as the &lt;a href="https://schemity.com/" rel="noopener noreferrer"&gt;ERD tool&lt;/a&gt; for all database diagrams.&lt;/p&gt;

</description>
      <category>erd</category>
      <category>database</category>
      <category>schema</category>
      <category>databasedesign</category>
    </item>
    <item>
      <title>Design Data Model for Multi-Tenant RBAC</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Mon, 13 Apr 2026 01:51:18 +0000</pubDate>
      <link>https://dev.to/tbson87/design-data-model-for-multi-tenant-rbac-1ghh</link>
      <guid>https://dev.to/tbson87/design-data-model-for-multi-tenant-rbac-1ghh</guid>
      <description>&lt;h2&gt;
  
  
  Part 1: Identity Foundation
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Behavior Description
&lt;/h2&gt;

&lt;p&gt;Assume we are building a KPI management platform. Multiple companies can create accounts (tenants) and let their employees access the platform.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A user can be assigned to one or several roles. Their permissions are the sum of those roles' permissions.&lt;/li&gt;
&lt;li&gt;A user cannot access features outside their permissions. Even if they know the API specs and bypass the UI, they will still be blocked at the API layer.&lt;/li&gt;
&lt;li&gt;Admins can change a user's roles or a role's permissions at any time. The only inconvenience is that the affected user needs to log out and log back in for the changes to take effect.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Sounds straightforward, right? Let's look at what happens under the hood.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deriving the Data Model from Use Cases
&lt;/h2&gt;

&lt;p&gt;Before implementing, I take time to think about what the system needs to do and what data structures are required to support those use cases.&lt;/p&gt;

&lt;p&gt;The first thing we hear in the spec is "multiple tenants." A system that serves multiple companies and their users. That gives us at least two objects to start with: &lt;code&gt;tenants&lt;/code&gt; and &lt;code&gt;users&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvq0qlldxqb20juquyema.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvq0qlldxqb20juquyema.png" alt="Tenants and users entity" width="800" height="238"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note: I use the plural convention for table names throughout this post.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Defining the Relationship Between Tenants and Users
&lt;/h2&gt;

&lt;p&gt;Now we have the foundation, but the relationship between &lt;code&gt;tenants&lt;/code&gt; and &lt;code&gt;users&lt;/code&gt; is not yet defined. To define it, we need to find the right constraint. There are two cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Case 1:&lt;/strong&gt; One tenant can have many users. One user must use a unique email per tenant.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Case 2:&lt;/strong&gt; One tenant can have many users. One user can use the same email across different tenants.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Consider this scenario: you are a contractor working for two different companies that both use this platform. If we go with Case 1, the best you can do is maintain two separate emails for two separate tenants on the same system. That is a real inconvenience.&lt;/p&gt;

&lt;p&gt;Notice I used the word &lt;strong&gt;person&lt;/strong&gt; instead of &lt;strong&gt;user&lt;/strong&gt; here. Technically, every tenant is independent. There is no good reason to force a person to use a different email just to become a user of another tenant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Case 2 is the preferred design.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Tenant Identification at Login
&lt;/h2&gt;

&lt;p&gt;This raises a natural question: if a user can log in with the same email across multiple tenants, how do we know which tenant they belong to?&lt;/p&gt;

&lt;p&gt;The answer is that when a user logs in, we need to identify the tenant. There are a few approaches:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Ask the user to type a tenant code in the login form. Bad idea.&lt;/li&gt;
&lt;li&gt;Provide a typeahead field to help the user find their tenant code. Still a bad idea.&lt;/li&gt;
&lt;li&gt;Give each tenant a subdomain or sub-path, such as &lt;code&gt;tenant1.ourplatform.com&lt;/code&gt; or &lt;code&gt;ourplatform.com/tenant1&lt;/code&gt;. This is the widely accepted approach, especially the subdomain variant. The only tradeoff is that instead of a standard SSL certificate, you need a wildcard SSL certificate.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Identity Table
&lt;/h2&gt;

&lt;p&gt;Another natural question follows: if users share an email across tenants, can they also share a password?&lt;/p&gt;

&lt;p&gt;No. Password policies can differ per tenant, so sharing passwords across tenants is not acceptable. This leads us to a third object: the &lt;code&gt;identities&lt;/code&gt; table. An identity represents a user's membership in a specific tenant, containing tenant's specific information.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhg4adsc2dxv9be75iejw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhg4adsc2dxv9be75iejw.png" alt="Identities entity" width="800" height="515"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The relationship is clear: one user can have many identities, and each identity belongs to exactly one tenant. With this simple three-table model - &lt;code&gt;tenants&lt;/code&gt;, &lt;code&gt;users&lt;/code&gt;, and &lt;code&gt;identities&lt;/code&gt; - we satisfy the core constraint cleanly: a person can use the same email across multiple independent tenants.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recognizing the Many-to-Many Relationship
&lt;/h2&gt;

&lt;p&gt;Look closely at the diagram. Both the N (of 1:N) ends from &lt;code&gt;users&lt;/code&gt; and &lt;code&gt;tenants&lt;/code&gt; point to the &lt;code&gt;identities&lt;/code&gt; table. This is a sign that &lt;code&gt;users&lt;/code&gt; and &lt;code&gt;tenants&lt;/code&gt; have an N:N relationship - a user can belong to multiple tenants, and a tenant can have multiple users. The &lt;code&gt;identities&lt;/code&gt; table is effectively the join table.&lt;/p&gt;

&lt;p&gt;With that insight, we can express the data model more precisely:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpxzdgtmgpv3lgjnh9r6b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpxzdgtmgpv3lgjnh9r6b.png" alt="Identities entity" width="800" height="485"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;This post uses &lt;a href="https://schemity.com/" rel="noopener noreferrer"&gt;Schemity&lt;/a&gt; as the &lt;a href="https://schemity.com/" rel="noopener noreferrer"&gt;ERD tool&lt;/a&gt; for all database diagrams.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;In Part 2, we will build on this foundation and design the roles and permissions model.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>erd</category>
      <category>database</category>
      <category>schema</category>
      <category>databasedesign</category>
    </item>
    <item>
      <title>Schemity vs dbdiagram.io: When Your ERD Tool Needs to Outlive the Meeting</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Thu, 02 Apr 2026 10:36:02 +0000</pubDate>
      <link>https://dev.to/tbson87/schemity-vs-dbdiagramio-when-your-erd-tool-needs-to-outlive-the-meeting-28m3</link>
      <guid>https://dev.to/tbson87/schemity-vs-dbdiagramio-when-your-erd-tool-needs-to-outlive-the-meeting-28m3</guid>
      <description>&lt;p&gt;There's a moment every software engineer knows.&lt;/p&gt;

&lt;p&gt;You're explaining a new feature to a teammate. The schema is in your head. You need something visual, fast, right now. You open a browser tab, type &lt;code&gt;dbdiagram.io&lt;/code&gt;, and in three minutes you have a diagram you can paste into Slack.&lt;/p&gt;

&lt;p&gt;The meeting happens. Everyone nods. The diagram link dies in the chat history. The actual schema lives in your database, unrelated to what you drew.&lt;/p&gt;

&lt;p&gt;That's not a failure of discipline. That's the ceiling of the tool.&lt;/p&gt;

&lt;p&gt;This article is for engineers who have hit that ceiling and are wondering what comes next.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Honest Summary
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;dbdiagram.io&lt;/strong&gt; is a fast, focused ERD tool built for quick sketching. Fast, free, browser-based, zero friction. If you need a diagram for a meeting and you'll never look at it again, nothing beats it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Schemity&lt;/strong&gt; is what you use when the diagram needs to outlive the meeting. When your ERD is part of your codebase, not a link in Slack. When you want to go from sketch to production schema without switching tools.&lt;/p&gt;

&lt;p&gt;If that distinction doesn't matter to you yet, use dbdiagram.io. It's genuinely excellent at what it does.&lt;/p&gt;

&lt;p&gt;If it does matter, read on.&lt;/p&gt;




&lt;h2&gt;
  
  
  Side-by-Side
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;dbdiagram.io&lt;/th&gt;
&lt;th&gt;Schemity&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Interface&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Code-based (DBML syntax)&lt;/td&gt;
&lt;td&gt;Visual drag-and-drop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Platform&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Browser only&lt;/td&gt;
&lt;td&gt;Native desktop (macOS, Windows, Linux)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Install size&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;None (web)&lt;/td&gt;
&lt;td&gt;9 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Offline&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Full offline, air-gap ready&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Free tier&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;10 public diagrams, no private, 1 viewer&lt;/td&gt;
&lt;td&gt;2-week full trial, all features&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Privacy / diagram limits&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Upgrade to $9/month required&lt;/td&gt;
&lt;td&gt;2-week full trial, fully usable forever after&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Your data location&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Their servers&lt;/td&gt;
&lt;td&gt;Your machine, plain JSON&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Git workflow&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Manual export&lt;/td&gt;
&lt;td&gt;Native: ERD is a JSON file in your repo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Live DB sync&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Limited: export SQL scripts only, no direct sync&lt;/td&gt;
&lt;td&gt;Yes: reverse-engineer, sync, generate migrations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Migration generation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes: exact SQL diff, useful for POC and personal projects&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Pricing&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$9/month&lt;/td&gt;
&lt;td&gt;$129 one-time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Keyboard shortcuts&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Minimal&lt;/td&gt;
&lt;td&gt;Keyboard-first, every action has a shortcut&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Multiple tabs&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Browser tabs only, no isolated undo history&lt;/td&gt;
&lt;td&gt;Yes: native tabs with isolated undo history per diagram&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Constraints &amp;amp; indexes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Basic&lt;/td&gt;
&lt;td&gt;Full: unique together, check constraints, indexes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;N:N relationships&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Manual&lt;/td&gt;
&lt;td&gt;Auto-generated junction table&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Relationship line routing&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Fixed, no control&lt;/td&gt;
&lt;td&gt;Custom waypoints, smart rerouting, full manual control&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Where dbdiagram.io Wins
&lt;/h2&gt;

&lt;p&gt;Let's be honest about this. dbdiagram.io does several things better than almost any alternative.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Speed from zero.&lt;/strong&gt; No download. No account required. Open browser, start typing, diagram appears in real time. For the specific use case of "I need an ERD right now," nothing is faster.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DBML syntax.&lt;/strong&gt; If you think in code, DBML is elegant. Define a table in four lines. Copy it from your ORM model. The keyboard never leaves the home row. For engineers who find drag-and-drop slower than typing, this is genuinely the right interface.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sharing is trivial.&lt;/strong&gt; One link. Anyone can view it. No account needed for viewers. For sharing with non-technical stakeholders or across teams, the link-based sharing model is hard to beat.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Free.&lt;/strong&gt; For individual use up to 10 diagrams, you pay nothing. For a lot of engineers, that's enough.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where dbdiagram.io Hits Its Ceiling
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The diagram is not your schema. It's a picture of your schema.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is the fundamental problem. dbdiagram.io creates a visualization. It doesn't connect to your database. It doesn't track changes. It doesn't generate migrations. When your schema evolves (and it always does), your diagram falls behind. Within weeks, the link you shared is a historical artifact, not a current document.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your data lives on their servers, not yours.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every diagram you create in dbdiagram.io is stored on Holistics' infrastructure. If they shut down, your diagrams disappear. If your company requires data locality (SOC 2, ISO 27001, regulated industries), a browser tool storing your schema on a third-party server is a compliance question you'd rather not answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The free tier is a public diagram trap.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The free tier gives you 10 diagrams, all public, all visible to anyone with the link, with no private option. The moment you need diagram number 11, or your first private diagram, the pricing jumps to $9/month. For most engineers working on real projects, "public diagrams only" is a non-starter from day one. Your schema is not a public document.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No offline.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Working on a flight. Behind a corporate firewall. On a client site with restricted internet. dbdiagram.io simply doesn't work. For a tool that's supposed to help you think through your architecture, internet dependency is a real constraint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DBML has a learning curve for non-coders.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you're the senior engineer who thinks in SQL, DBML feels natural. If you're onboarding a junior, or collaborating with a data analyst who doesn't write SQL daily, the code-first interface creates friction. The visual feedback is good, but the input method is a barrier.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Three Failure Modes Schemity Was Built to Solve
&lt;/h2&gt;

&lt;p&gt;Schemity wasn't built to compete with dbdiagram.io on its own terms. It was built to solve three specific failure modes that exist across the entire ERD tool market.&lt;/p&gt;

&lt;h3&gt;
  
  
  Failure Mode 1: The tool defeats you before you start
&lt;/h3&gt;

&lt;p&gt;Visual Paradigm is a serious modeling tool. It does have a Community edition that is free. But finding it is its own obstacle. The download page is cluttered with editions, pricing tiers, and license options that make it genuinely unclear which version to download. Engineers who just want to start modeling end up reading licensing FAQs instead of drawing tables.&lt;/p&gt;

&lt;p&gt;Once you do find the right download, the installer takes over. JVM dependency, license wizard, 200+ MB download. Motivated engineers have abandoned it before creating a single entity. The tool defeated them before they opened it.&lt;/p&gt;

&lt;p&gt;Schemity is 9 MB. It starts in seconds. There is no IT ticket, no JRE installation, no license wizard, no edition confusion. One download, one version, running immediately. The activation energy is low enough that you open it before the meeting, not after.&lt;/p&gt;

&lt;h3&gt;
  
  
  Failure Mode 2: Fast sketching and serious design are mutually exclusive
&lt;/h3&gt;

&lt;p&gt;The market has forced a false choice: either use a basic tool that's fast (dbdiagram.io) or use an enterprise tool that's serious (Visual Paradigm, erwin). There is almost nothing in between.&lt;/p&gt;

&lt;p&gt;Schemity is drag-and-drop fast. Keyboard shortcuts make it faster still once you're comfortable. But the same tool that handles a five-minute sketch also handles a production schema with 100+ tables, composite unique constraints, check constraints, indexes, and SSH-connected live database sync. It can also generate SQL migrations directly from your ERD, useful for personal projects and fast POCs where you don't need a full migration framework in the way. For production team workflows, your existing migration tooling stays in charge. Schemity sits alongside it, not in front of it.&lt;/p&gt;

&lt;p&gt;You don't graduate to another tool. You just use more of the same tool.&lt;/p&gt;

&lt;h3&gt;
  
  
  Failure Mode 3: Trial expiry holds your work hostage
&lt;/h3&gt;

&lt;p&gt;This is the fear nobody talks about in ERD tool marketing. You spend two hours in a trial. The trial expires. Your diagrams are either gone or locked behind a paywall. And if you were on dbdiagram.io's free tier the whole time, your schema was public to anyone with the link. Serious, sensitive work sitting on a public URL without you realizing it.&lt;/p&gt;

&lt;p&gt;Schemity's answer to this is architectural, not a policy. Your ERDs are plain JSON files in &lt;code&gt;~/schemity/&lt;/code&gt; on your own machine. When the trial expires, Schemity stops receiving updates. You get a single friendly reminder when you open the app, once per session, not a popup that blocks your work or repeats every five minutes. Everything else works exactly as before. Your files are yours. They always were.&lt;/p&gt;

&lt;p&gt;If Schemity shuts down as a business tomorrow, your diagrams open perfectly on Monday.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Git Workflow Nobody Else Has
&lt;/h2&gt;

&lt;p&gt;This is the feature that changes how a team works with database design.&lt;/p&gt;

&lt;p&gt;In dbdiagram.io, your ERD lives at a URL. You share the link. When the schema changes, someone updates the diagram manually, if they remember. The diagram and the codebase drift apart over time. Within months, the link you shared in Slack is a historical artifact, not a current document.&lt;/p&gt;

&lt;p&gt;Schemity takes a fundamentally different approach, one borrowed from how mature engineering teams already handle infrastructure.&lt;/p&gt;

&lt;p&gt;Nobody commits Terraform files inside an application service repo. Infrastructure is a shared concern, it spans services, it has its own change cadence, it deserves its own review. So it lives in its own repo.&lt;/p&gt;

&lt;p&gt;Your database schema deserves the same treatment.&lt;/p&gt;

&lt;p&gt;The pattern Schemity enables is an ERD repo: a single, dedicated repository that holds the ERDs for every service in your company.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;company-erd-repo/
  ├── payment-service/
  ├── user-service/
  ├── notification-service/
  └── legacy-crm/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each folder is a Schemity workspace containing plain JSON files. The entire repo lives as &lt;code&gt;~/schemity/&lt;/code&gt; on every engineer's machine. Schemity opens it as a central hub.&lt;/p&gt;

&lt;p&gt;Schemity does not stand in your workflow. It does not replace your migration tool. Your schema changes still flow through whatever your team already uses: Django, Rails, Flyway, Atlas, anything. Schemity sits alongside that workflow, not in front of it.&lt;/p&gt;

&lt;p&gt;Here is what it looks like in practice:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For the engineer making the change:&lt;/strong&gt; Clone &lt;code&gt;company-erd-repo&lt;/code&gt; once into &lt;code&gt;~/schemity/&lt;/code&gt;. Run your migration as normal. Once it is applied to your local database, open Schemity, connect to it, and let it reflect the current state. Arrange the entities, update the relationships, add legends to give the diagram business meaning. Commit the updated JSON and open a PR against the ERD repo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For the engineer reviewing the change:&lt;/strong&gt; Pull the branch. Open Schemity. The diff is right there, a JSON file change that maps directly to a visual diagram. No migration file archaeology. No mental reconstruction of what the schema looked like before.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For the architect reviewing multiple services:&lt;/strong&gt; One repo. One clone. Every service's schema in one place. No hopping between repos hoping someone committed a diagram.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For the new engineer onboarding:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone git@github.com:company/erd-repo.git ~/schemity
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One command. Every service. Every relationship. Every constraint. The full picture of the company's data model, version-controlled and up to date.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What this unlocks that dbdiagram.io never can:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Schema changes get reviewed like code.&lt;/strong&gt; When a schema change requires a PR against the ERD repo, the change gets reviewed before it is applied, not after. The history of every schema decision lives in Git: who changed it, when, and why.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The database is always the source of truth.&lt;/strong&gt; Real teams miss steps. An engineer ships a migration in a busy sprint and forgets to update the ERD. It happens. With dbdiagram.io, that diagram is now wrong and nobody knows it. With Schemity, the next time anyone opens that ERD and connects to the database, Schemity reflects the current state automatically. The new tables are there. The new relationships are there. The new columns are there. The only thing missing is the visual arrangement: where the entities sit on the canvas and how the lines are routed. Drag a few boxes, tidy the lines, commit. No data lost. Nothing out of date. The diagram catches up in minutes, not sprint cycles.&lt;/p&gt;

&lt;p&gt;This is separation of concerns applied to the full engineering stack. Source code in service repos. Infrastructure in the infra repo. Schema in the ERD repo.&lt;/p&gt;

&lt;p&gt;It is not a feature dbdiagram.io can add. It would require rethinking what an ERD tool is for.&lt;/p&gt;




&lt;h2&gt;
  
  
  Pricing: One-Time vs. Monthly
&lt;/h2&gt;

&lt;p&gt;dbdiagram.io: &lt;strong&gt;$9/month&lt;/strong&gt; ($108/year) once you exceed the free tier.&lt;/p&gt;

&lt;p&gt;Schemity: &lt;strong&gt;$129 one-time&lt;/strong&gt; with $69/year renewal for updates (optional, the tool works forever without renewing).&lt;/p&gt;

&lt;p&gt;At 14 months, Schemity costs the same as dbdiagram.io's subscription. Every month after that, Schemity is free. If you use it for three years (and engineers tend to stick with tools that work), Schemity costs $129 + $138 = $267 total. dbdiagram.io costs $324 for the same period, and you still don't own it.&lt;/p&gt;

&lt;p&gt;The more important pricing difference is philosophical. Schemity's one-time model is a statement: the tool is valuable enough that you'll keep using it without being forced to. You don't stay because canceling is painful. You stay because the tool earns it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Who Should Use dbdiagram.io
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;You need a diagram in the next five minutes for a meeting&lt;/li&gt;
&lt;li&gt;You'll never look at this diagram again after today&lt;/li&gt;
&lt;li&gt;You're communicating a schema idea, not building one&lt;/li&gt;
&lt;li&gt;You think in DBML and the code-first interface is faster for you&lt;/li&gt;
&lt;li&gt;The diagram doesn't need to live anywhere except a Slack link&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;dbdiagram.io is excellent for this use case. Use it without guilt.&lt;/p&gt;




&lt;h2&gt;
  
  
  Who Should Use Schemity
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;You need the diagram to still be accurate next month&lt;/li&gt;
&lt;li&gt;Your ERD belongs in your repository, not in someone else's cloud&lt;/li&gt;
&lt;li&gt;You work offline, behind a VPN, or in a restricted environment&lt;/li&gt;
&lt;li&gt;You're graduating from a basic tool without wanting a tool that feels like an enterprise installation ceremony&lt;/li&gt;
&lt;li&gt;You want to generate migrations directly from your design for a personal project or fast POC, without a full migration framework in the way&lt;/li&gt;
&lt;li&gt;You're a senior engineer recommending a tool to a junior who shouldn't be defeated by the installer&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The Bottom Line
&lt;/h2&gt;

&lt;p&gt;dbdiagram.io is a sketch pad. It's a very good sketch pad.&lt;/p&gt;

&lt;p&gt;Schemity is a design tool that happens to be as fast as a sketch pad.&lt;/p&gt;

&lt;p&gt;The question is not which tool is better in absolute terms. The question is whether your ERD is a communication artifact that lives in a meeting, or a design artifact that lives in your codebase.&lt;/p&gt;

&lt;p&gt;If it's the latter, Schemity was built for you.&lt;/p&gt;

&lt;p&gt;If Schemity sounds like the right fit, you can grab it at &lt;a href="https://schemity.com" rel="noopener noreferrer"&gt;schemity.com&lt;/a&gt;, 9 MB, 2-week full trial, no account required.&lt;/p&gt;

</description>
      <category>erd</category>
      <category>database</category>
      <category>schema</category>
      <category>databasedesign</category>
    </item>
  </channel>
</rss>
