<?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: Jeff Tsai</title>
    <description>The latest articles on DEV Community by Jeff Tsai (@jeff377).</description>
    <link>https://dev.to/jeff377</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%2F4181868%2F2f539f0e-aeec-4ef8-ae80-a4110d0cec24.jpg</url>
      <title>DEV Community: Jeff Tsai</title>
      <link>https://dev.to/jeff377</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jeff377"/>
    <language>en</language>
    <item>
      <title>Day 10: Multi-Database Support: CRUD and Structure Upgrades</title>
      <dc:creator>Jeff Tsai</dc:creator>
      <pubDate>Tue, 03 Nov 2026 00:34:20 +0000</pubDate>
      <link>https://dev.to/jeff377/day-10-multi-database-support-crud-and-structure-upgrades-1cld</link>
      <guid>https://dev.to/jeff377/day-10-multi-database-support-crud-and-structure-upgrades-1cld</guid>
      <description>&lt;p&gt;"Supports SQL Server, PostgreSQL, MySQL, Oracle and SQLite" is one line in a specification. Inside a framework it is a question that has to be answered before anything else: what exactly is being abstracted for them.&lt;/p&gt;

&lt;p&gt;Neither direction works:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Wrapping up what every vendor can do has no end to it. Each item wrapped means filling the same gap five times over, and by the end every item is half finished&lt;/li&gt;
&lt;li&gt;Sharing nothing but a connection object is not support for multiple databases, it means every form's statements get written five times&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This framework's answer is narrow, narrow enough to be two things only.&lt;/p&gt;

&lt;p&gt;This post covers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Why there are only two shared behaviors&lt;/li&gt;
&lt;li&gt;CRUD, where the differences are small enough for a lookup table to absorb&lt;/li&gt;
&lt;li&gt;Structure upgrades, where the differences are large enough that each vendor really does get its own&lt;/li&gt;
&lt;li&gt;Which vendor is used is registered by the application, and the framework ships none&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  1. Why there are only two shared behaviors
&lt;/h2&gt;

&lt;p&gt;How many databases a framework should support has no general answer, because it depends on what is being sold. Supporting one buys depth, since that vendor's native types, bulk interfaces and dialect features are all directly available. &lt;a href="https://dev.to/jeff377/day-6-tableschema-and-database-structure-upgrades-25fn"&gt;TableSchema and Database Structure Upgrades&lt;/a&gt; said that for packaged software a short support list means a whole set of customers gone, which is what makes multi-vendor support a condition of entry.&lt;/p&gt;

&lt;p&gt;What this layer faces is five commonly used relational databases, not everything that can store data. Those five share one data model: tables, columns and rows, all speaking SQL, all with transactions. Precisely because the layer underneath is shared, "CRUD" and "keeping the structure caught up with the definition" point at the same thing on all five.&lt;/p&gt;

&lt;p&gt;Two tests, both of which have to hold:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Is this something every application does&lt;/li&gt;
&lt;li&gt;Does it keep doing it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Measured with those two, not much is left:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What got in&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CRUD against a single table&lt;/td&gt;
&lt;td&gt;There is no application that does not do it, and it happens once every time a user presses save&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Keeping the structure caught up with the definition&lt;/td&gt;
&lt;td&gt;No application can dodge it, and it comes round once at every deployment&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;What did not get in: partitioned tables, materialized views, full text search, each vendor's own analytic functions, query hints, and the dedicated interfaces for bulk loading. Every one of them is valuable, and they share one property. Not every application needs them, and the ones that do usually use them in only a handful of places.&lt;/p&gt;

&lt;p&gt;Take bulk loading as the example. Most databases have a dedicated mechanism far faster than writing row by row, but no two of them are the same shape. One hands you a class of its own, another a special statement, another wants a file written out first for the database to read itself. Designing a common interface over those yields the smallest intersection and nothing more, and the smallest intersection is usually about as fast as writing row by row. Wrapping it gains nothing and leaves five implementations to maintain.&lt;/p&gt;

&lt;p&gt;These two tests measure whether this layer should be doing something, not whether it matters. Partitioning may be a life or death question for a document table of a hundred million rows. It simply should not be an interface that has to hold for five vendors at once deciding how it is done.&lt;/p&gt;

&lt;p&gt;Once the measuring is over, what is left is all of it. The framework's entire outward abstraction is one interface, and what gets asked of a database falls into two categories: how to produce CRUD statements for a table, and how to keep the structure caught up with the definition. The second splits into four smaller pieces, being read the current state, create a new table, alter in place, and rebuild when altering will not do.&lt;/p&gt;

&lt;p&gt;The line is drawn narrowly, and what that buys is that it can be finished. One interface and two categories of question means connecting a new database is something you can read to the end of and write to the end of. If that interface had thirty members, this would never happen in practice.&lt;/p&gt;

&lt;p&gt;The two jobs sit inside the same interface, though, and the abstraction over them is nothing like the same thickness.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. CRUD, where the differences are small enough for a lookup table to absorb
&lt;/h2&gt;

&lt;p&gt;The pain: the same query looks different on all five, and the places where it differs are extremely boring. What symbol wraps table and column names, how a parameter is written, how paging is expressed. Boring, but every statement has to be right, and one mistake is a run time syntax error, of the kind that never appears at all when the test environment runs a different database.&lt;/p&gt;

&lt;p&gt;These differences are of a good kind. They are different spellings of the same thing, not different things.&lt;/p&gt;

&lt;p&gt;The reason for collecting them into one lookup table is that the cost of not collecting them is scattered. Statements get assembled in more than one place, with separate stretches for listing, reading one row, counting, and each of insert, update and delete. Every stretch judging for itself which vendor is in play means every stretch has its own chance to miss one, and the one missed shows itself only when a customer happens to run that database.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Database&lt;/th&gt;
&lt;th&gt;Identifier quoting&lt;/th&gt;
&lt;th&gt;Parameter prefix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SQL Server&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[sys_id]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PostgreSQL&lt;/td&gt;
&lt;td&gt;&lt;code&gt;"sys_id"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MySQL&lt;/td&gt;
&lt;td&gt;&lt;code&gt;`sys_id`&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Oracle&lt;/td&gt;
&lt;td&gt;&lt;code&gt;"SYS_ID"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;:&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SQLite&lt;/td&gt;
&lt;td&gt;&lt;code&gt;"sys_id"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Oracle's row carries one extra thing. An unquoted identifier gets folded to upper case there, so what the framework sends is always quoted and upper case, and on the way back the framework folds it to lower case at the boundary, so that every layer above sees exactly the same column names on all five. &lt;a href="https://dev.to/jeff377/day-9-data-and-definitions-split-apart-so-forms-need-no-types-of-their-own-505g"&gt;Data and Definitions Split Apart, So Forms Need No Types of Their Own&lt;/a&gt; said there is one set of names the whole way through, and this is that rule's closing move at the lowest level.&lt;/p&gt;

&lt;p&gt;One thing is left that a lookup table cannot solve, because the shapes really are different:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Database&lt;/th&gt;
&lt;th&gt;Taking rows 21 to 40&lt;/th&gt;
&lt;th&gt;Skipping the first 20 and taking everything after&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SQL Server and Oracle&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OFFSET 20 ROWS FETCH NEXT 20 ROWS ONLY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OFFSET 20 ROWS&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PostgreSQL and SQLite&lt;/td&gt;
&lt;td&gt;&lt;code&gt;LIMIT 20 OFFSET 20&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OFFSET 20&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MySQL&lt;/td&gt;
&lt;td&gt;&lt;code&gt;LIMIT 20 OFFSET 20&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;LIMIT 18446744073709551615 OFFSET 20&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;MySQL matches the middle row only when a range is being taken. Given a starting point and no count, it will not accept an &lt;code&gt;OFFSET&lt;/code&gt; standing on its own, so a ceiling has to be padded in, and that string of digits is the sentinel value its own documentation specifies, not something typed at random.&lt;/p&gt;

&lt;p&gt;The conclusion is in the shape of the five implementations. The five classes matching the five vendors are all but identical, each one handing the query and the delete to the same shared code and mentioning on the way past which vendor it is. The three statements used for saving go further and do not even come in five: one class assembles them while holding an enumeration value.&lt;/p&gt;

&lt;p&gt;That class is &lt;a href="https://github.com/jeff377/bee-library/blob/main/src/Bee.Db/Dml/TableSchemaCommandBuilder.cs" rel="noopener noreferrer"&gt;&lt;code&gt;TableSchemaCommandBuilder&lt;/code&gt;&lt;/a&gt;, which takes which vendor and the table's structure at construction, assembles the three, and wraps them together with that table from the &lt;code&gt;DataSet&lt;/code&gt; into one thing handed down:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;insertCmd&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;BuildInsertCommand&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;updateCmd&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;BuildUpdateCommand&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;deleteCmd&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;BuildDeleteCommand&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;DataTableUpdateSpec&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;DataTable&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dataTable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;InsertCommand&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;insertCmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;UpdateCommand&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;updateCmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;DeleteCommand&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;deleteCmd&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That same post said the state of each row decides which statement gets issued, and here is where it is honored. Inside the whole class, which vendor is in play arrives from exactly two places, being how identifiers are quoted and what prefix parameter names take, which are the two columns of the first table above. How the three statements are assembled has not one branch in it.&lt;/p&gt;

&lt;p&gt;The differences all sink to the layer below, where what can be looked up is looked up and the one case left over is taken care of by shared code.&lt;/p&gt;

&lt;p&gt;This half of the abstraction is very thin, and thin is good. Connecting one more vendor means almost nothing to write on this half.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Structure upgrades, where the differences are large enough that each vendor really does get its own
&lt;/h2&gt;

&lt;p&gt;For "widen this column from 20 characters to 50", what differs between vendors is not the spelling but whether it can be done at all. That is no longer different spellings of one thing, it is a difference in capability itself, and a difference in capability cannot be absorbed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Difference one: reading the current state
&lt;/h3&gt;

&lt;p&gt;No two of the five are alike:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Database&lt;/th&gt;
&lt;th&gt;Where it is read from&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SQL Server&lt;/td&gt;
&lt;td&gt;The &lt;code&gt;sys.*&lt;/code&gt; set of system views&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PostgreSQL&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;information_schema&lt;/code&gt; mixed with &lt;code&gt;pg_catalog&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MySQL&lt;/td&gt;
&lt;td&gt;&lt;code&gt;INFORMATION_SCHEMA&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Oracle&lt;/td&gt;
&lt;td&gt;The &lt;code&gt;USER_*&lt;/code&gt; data dictionary&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SQLite&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;sqlite_master&lt;/code&gt; plus a few &lt;code&gt;PRAGMA&lt;/code&gt;s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The reason this cannot be pulled apart is not dialect, it is how each vendor regards its own structure. SQL Server treats a column default as an object in its own right, stored separately and named separately, so reading it means joining one more system view. SQLite goes to the other extreme. It does not enforce types, and keeps the &lt;code&gt;VARCHAR(50)&lt;/code&gt; originally written down exactly as written, so for the framework to know how long a value can be, that string has to be taken apart and read again.&lt;/p&gt;

&lt;p&gt;There is a step after reading, and it is harder than the query.&lt;/p&gt;

&lt;p&gt;That post's rule that the definition holds no database's type names has its cost land here. Creating a table means translating framework types into native types, so comparing means translating the native types read back the other way, and that translation is many to one. On MySQL a &lt;code&gt;Guid&lt;/code&gt; is stored as a fixed length character column of 36, and a fixed length character column is just one way of spelling a string, while auto number and long integer both land on &lt;code&gt;BIGINT&lt;/code&gt;. Translating back, all the database reports is a &lt;code&gt;char&lt;/code&gt; with a length, or a &lt;code&gt;bigint&lt;/code&gt;. The meaning was already thrown away on the way out.&lt;/p&gt;

&lt;p&gt;So the reverse half is not the lookup table read backwards, it is recognizing the meaning again from extra clues:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A fixed length character column of exactly 36 is a &lt;code&gt;Guid&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;A numeric column of precision 19 and scale 4 is a money amount&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;BIGINT&lt;/code&gt; gets a second look for an auto increment flag&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These clues hold on the premise that the framework only ever emits those few spellings, so a table the framework built itself is always recognized again. The cost sits on that same premise. Take over a table the framework did not build and these inferences go wrong, with the symptom being a comparison that reports a difference which is not there, every time.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The forward mapping fits in one list, and the reverse half does not. Once an abstraction has thrown information away in the middle, there is no free route back.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Difference two: whether it can be altered at all
&lt;/h3&gt;

&lt;p&gt;That post's rule, altering in place within a family and rebuilding across families, is all but void on SQLite, because its &lt;code&gt;ALTER TABLE&lt;/code&gt; cannot touch an existing column. So the same change that takes seconds on another database copies the whole table's data on SQLite.&lt;/p&gt;

&lt;p&gt;The framework does not choose to smooth this over. What it does is compute the difference and show it, which is what that post's dry run is for. The same definition against different databases can yield different plans, and whether to schedule a maintenance window was always a question for that plan to answer. Smoothing it over would be worse, because what gets smoothed away is exactly the thing the user most needs to know.&lt;/p&gt;

&lt;h3&gt;
  
  
  Difference three: the hardest to spot, because it reports no error
&lt;/h3&gt;

&lt;p&gt;From 8.0.13 onward MySQL requires that a default value which is not a literal be written as an expression wrapped in parentheses. So for the framework to have a column generate a &lt;code&gt;Guid&lt;/code&gt; automatically, what goes out is &lt;code&gt;DEFAULT (UUID())&lt;/code&gt;. Writing it is fine.&lt;/p&gt;

&lt;p&gt;But at the next comparison, what &lt;code&gt;INFORMATION_SCHEMA&lt;/code&gt; reports back is &lt;code&gt;uuid()&lt;/code&gt;. The parentheses are gone, and the function name has turned lower case.&lt;/p&gt;

&lt;p&gt;So the literal comparison runs like this. The definition says &lt;code&gt;(UUID())&lt;/code&gt;, the database says &lt;code&gt;uuid()&lt;/code&gt;, the two do not match, a change is declared, one more ALTER goes out, it runs, the next comparison still gets &lt;code&gt;uuid()&lt;/code&gt; back, and there is one more ALTER again.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;An upgrade that never converges. It does not fail and it produces no error message. It quietly issues one more statement at every startup, altering a column that never changed.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The framework's answer is to normalize both sides to the same shape before comparing, stripping one layer of outer parentheses and ignoring case. SQLite has a problem of the same kind, since it stores the create statement as written and reads it back as written, and it needs a normalization of its own.&lt;/p&gt;

&lt;h3&gt;
  
  
  And one more kind: column descriptions
&lt;/h3&gt;

&lt;p&gt;Every column in the definition has a line of description meant for people, and storing that in the database gets four answers out of the five vendors:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Database&lt;/th&gt;
&lt;th&gt;How column descriptions are stored&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;MySQL&lt;/td&gt;
&lt;td&gt;Written straight after the column definition&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PostgreSQL and Oracle&lt;/td&gt;
&lt;td&gt;A separate &lt;code&gt;COMMENT ON&lt;/code&gt; statement&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SQL Server&lt;/td&gt;
&lt;td&gt;Hung on a set of things called extended properties&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SQLite&lt;/td&gt;
&lt;td&gt;Not kept at all, where writing is a no-op and reading always comes back empty&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;What the framework does about the last one is say outright that it cannot be done, and leave the description on the definition side. Pretending it had been stored is what would be the disaster, because it would never match when read back, which is that non converging loop over again.&lt;/p&gt;

&lt;p&gt;These several kinds of difference are all branches off the same abstraction on its way down, and not one of a thousand definitions needs to know about any of it. The cost is that someone has to know for them.&lt;/p&gt;

&lt;p&gt;That someone is this layer. Reading back and writing out are only the entry fee. The hard part is recognizing that what was written out and what comes back are the same thing.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Which vendor is used is registered by the application, and the framework ships none
&lt;/h2&gt;

&lt;p&gt;The pain: your system uses one database, and the framework claims support for five. So five vendors' drivers sit in the output directory and the container image is that much bigger, and the day one of them ships a security update, your vulnerability scan lists it even though you never used a line of it. Versions are harder still. A framework that folds five drivers into its own dependencies has pinned five versions on your behalf.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A dependency that is not itself using something should not be the one making that decision for all of its users.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So the framework builds in none of them. An application registers whichever one it wants to support, at startup, and the case study's line comes from &lt;a href="https://github.com/jeff377/bee-northwind-avalonia/blob/main/Bee.Northwind.Server/NorthwindBackend.cs" rel="noopener noreferrer"&gt;&lt;code&gt;NorthwindBackend.cs&lt;/code&gt;&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;DbProviderRegistry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DatabaseType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SQLite&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;SqliteProviderFactory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SqliteFactory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Instance&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What gets registered is the ADO.NET level factory, out of each vendor's own package. The same file has one more line, registering the set that produces statements and reads structures for it, which is the whole interface of the two sections above. Connecting a database the framework does not ship for means writing exactly that one part.&lt;/p&gt;

&lt;p&gt;The framework never registers any vendor itself, and that sentence is written into the source comments.&lt;/p&gt;

&lt;p&gt;Registration happens in code, not in configuration. What configuration carries is the database kind, an enumeration value, not a type name. Letting configuration name a type would mean the framework going off to find an assembly by name at run time, which moves the driver dependency out of the dependency list and into a string, where the build can no longer see it.&lt;/p&gt;

&lt;p&gt;This discipline is verifiable in the source. Not one project in the whole framework references any of the five vendors' packages.&lt;/p&gt;

&lt;p&gt;SQLite took one extra piece of work for this. Its driver does not provide the &lt;code&gt;DataAdapter&lt;/code&gt; the framework needs for writing back, so the framework supplies one, wrapping the abstract factory type and handing every other member straight through, with the instance passed in by the application at registration. Even something needed for that one vendor does not reference that vendor's package.&lt;/p&gt;

&lt;p&gt;The case study uses SQLite only, and the other four vendors' packages are not even referenced. The reason for choosing it has nothing to do with the framework's design: no database to install, one file and it runs, which is the least trouble for a demonstration.&lt;/p&gt;




&lt;h2&gt;
  
  
  The thickness of an abstraction
&lt;/h2&gt;

&lt;p&gt;Five databases, two jobs. What got in is what every application has to do and keeps doing. What did not get in is not less important, it is only needed in a few particular places.&lt;/p&gt;

&lt;p&gt;The two look nothing alike once abstracted. The CRUD half is thin enough that the five implementations are all but the same, because the differences between vendors are different spellings of one thing. The structure upgrade half is thick enough that each vendor gets its own, because the differences between vendors are differences in capability.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The thickness of an abstraction is not decided by its designer. It is decided by how far apart the things underneath it are.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This layer's return takes time to show. At the first delivery it is pure cost, being one more interface, two more lines of wiring and five more implementations. It pays back when a second customer names a different database and you find that not one word of a thousand definitions has to change.&lt;/p&gt;

&lt;p&gt;Supporting one more database does not really cost those few hundred lines of implementation. It costs the gaps that no document tells you about in advance, and how many of those there are is something you only learn by walking into them.&lt;/p&gt;

&lt;p&gt;The next post, &lt;a href="https://medium.com/@jeff377/day-11-connections-transactions-and-query-conditions-in-the-data-access-layer-5aca98e186a4" rel="noopener noreferrer"&gt;Connections, Transactions and Query Conditions in the Data Access Layer&lt;/a&gt;, is about the stretch the framework decides not to do itself: when SQL is handed back to the application to write, which things the framework does not hand over along with it.&lt;/p&gt;




&lt;p&gt;📚 Part of the thirty-post series &lt;a href="https://dev.to/jeff377/erp-architects-notes-designing-a-definition-driven-framework-series-index-4ln7"&gt;ERP Architect's Notes: Designing a Definition-Driven Framework&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;📘 Original article on HackMD, in Traditional Chinese:&lt;br&gt;
👉 &lt;a href="https://hackmd.io/@jeff377/day-10-two-common-jobs" rel="noopener noreferrer"&gt;https://hackmd.io/@jeff377/day-10-two-common-jobs&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;📢 You are welcome to republish this, please credit the source.&lt;br&gt;
📬 Follow for more engineering notes:&lt;br&gt;
&lt;a href="https://www.facebook.com/profile.php?id=61574839666569" rel="noopener noreferrer"&gt;Facebook&lt;/a&gt; ｜ &lt;a href="https://hackmd.io/@jeff377" rel="noopener noreferrer"&gt;HackMD&lt;/a&gt; ｜ &lt;a href="https://github.com/jeff377" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; ｜ &lt;a href="https://www.nuget.org/profiles/jeff377" rel="noopener noreferrer"&gt;NuGet&lt;/a&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>dotnet</category>
      <category>erp</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Day 9: Data and Definitions Split Apart, So Forms Need No Types of Their Own</title>
      <dc:creator>Jeff Tsai</dc:creator>
      <pubDate>Mon, 02 Nov 2026 01:18:41 +0000</pubDate>
      <link>https://dev.to/jeff377/day-9-data-and-definitions-split-apart-so-forms-need-no-types-of-their-own-505g</link>
      <guid>https://dev.to/jeff377/day-9-data-and-definitions-split-apart-so-forms-need-no-types-of-their-own-505g</guid>
      <description>&lt;p&gt;One request travels front end, API, business object, repository, database, and every layer has to receive the same record. What most systems put here are types they wrote themselves: an Entity per table, a DTO on each side, and a layer of Mapper configuration in between.&lt;/p&gt;

&lt;p&gt;This framework puts two things there: a data container (&lt;code&gt;DataSet&lt;/code&gt;) plus a definition running alongside it. The data is in the container, and everything said about the data is in &lt;code&gt;FormSchema&lt;/code&gt;. What that split buys is one thing. Eight forms or a thousand, not one extra type appears in between.&lt;/p&gt;

&lt;p&gt;This post covers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Why a data container rather than strong typing&lt;/li&gt;
&lt;li&gt;Writing no DTO means pulling data and definition apart&lt;/li&gt;
&lt;li&gt;What crosses layers is data plus its description&lt;/li&gt;
&lt;li&gt;What checks the things the compiler cannot, in two stops&lt;/li&gt;
&lt;li&gt;These costs are paid for the sake of a thousand forms&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://dev.to/jeff377/day-1-why-erp-needs-a-definition-driven-framework-1c2b"&gt;Why ERP Needs a Definition-Driven Framework&lt;/a&gt; counted an expected delivery date on the order landing in thirteen places, four of which were the Entity, the DTO, the Mapper and the front-end type. Why those four can be empty is what these five sections add up to.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Why a data container rather than strong typing
&lt;/h2&gt;

&lt;p&gt;The pain: a user asks for an expected delivery date on the order, with no calculation and nothing linked to it. You open the Entity and add a property, open the DTO and add one, open the Mapper and add a line, open the front-end type and add one more. Four files, one compile, one deployment.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;That field does nothing at all in business terms, and it has left four signatures in the type system.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Then multiply by a thousand tables, and by dozens of requests like it a year.&lt;/p&gt;

&lt;p&gt;Whether a cross layer container should be strongly typed has had two camps with their own reasons for years, and this is not judging between them. What strong typing buys is clear, namely that the compiler watches the names for you, and that is exactly what gets paid here. This section only answers what kind of thing this framework needs in this position.&lt;/p&gt;

&lt;p&gt;The answer is a container that does not yet know how many fields it has at the moment of compilation, for two reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;When the shape is settled: which fields a form has is written in the definition, and the definition is data, read in at run time. At the moment of compilation, the fact of which fields an order has does not exist. It also carries on changing, because that data is there precisely so that consultants, tools and even users can edit it.&lt;/li&gt;
&lt;li&gt;When the type has to exist: an earlier post, &lt;a href="https://dev.to/jeff377/day-2-an-overview-of-definition-driven-architecture-31hi"&gt;An Overview of Definition-Driven Architecture&lt;/a&gt;, drew a line between producing code at build time and producing objects at run time. A strongly typed cross layer container means that type has to exist at build time, so every definition change means regenerating, recompiling and redeploying. However cleanly everything upstream derives, one compile time type in the last mile grows the whole delivery path back.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The first post said that going from a definition change to it taking effect involves no compiling, packaging or deploying. Half of whether that sentence holds is staked on this layer.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Writing no DTO means pulling data and definition apart
&lt;/h2&gt;

&lt;p&gt;The second pain is less glaring and costs more time.&lt;/p&gt;

&lt;p&gt;What a field actually has to state goes well beyond what it is called and what type it is. Is it required? Does it show in the list? How wide? Is it currency, and to how many decimal places? It points at the customer form, so once one is picked which values get written back into which fields? When picking through a lookup window, should only the active ones be listed?&lt;/p&gt;

&lt;p&gt;None of that has a place ready for it in a DTO. What a type can express is a property name and a property type, and everything else has to be stacked on as a pile of annotations, read by layers that are not this one. So they scatter:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;This thing&lt;/th&gt;
&lt;th&gt;Where it scatters to&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Required field rules&lt;/td&gt;
&lt;td&gt;The validation layer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;List columns&lt;/td&gt;
&lt;td&gt;Front-end configuration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Relationships&lt;/td&gt;
&lt;td&gt;Foreign keys in the database&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Display formatting&lt;/td&gt;
&lt;td&gt;Some shared utility method&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The cost of scattering is not more files, it is that no one place is authoritative. The same thing is declared separately in several places, any two of them can fall out of line, and when they do nobody reports an error.&lt;/p&gt;

&lt;p&gt;The framework pulls the two apart: data goes in the container, and everything said about the data goes in the definition.&lt;/p&gt;

&lt;p&gt;Once they are apart, the description no longer has to be squeezed into the type system. An earlier post sorted what a single field can declare into six categories, being structure, behavior, presentation, semantics, relationships and security, and what a property can write down by itself is the first two items of the first one: the field name and the data type.&lt;/p&gt;

&lt;p&gt;Relationships among those six need a few more words. That they are declared between forms, pointing at the customer program, came up in &lt;a href="https://dev.to/jeff377/day-3-the-northwind-case-study-eight-forms-and-a-custom-business-object-3i33"&gt;The Northwind Case Study: Eight Forms and a Custom Business Object&lt;/a&gt;. A type can hold a reference to another type, but that reference says only that it points there. It cannot say which columns the list shows, what a person sees in a lookup window, which fields are queryable, or whether to add conditions of its own. The relationship between forms is wider than the relationship between types, and forcing it into types pushes those extra things somewhere outside the types.&lt;/p&gt;

&lt;p&gt;Pulling them apart returns one more thing:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A description on a DTO can only be read by the compiler. A description pulled out of it can be read by anyone.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  3. What crosses layers is data plus its description
&lt;/h2&gt;

&lt;p&gt;Writing no DTO does not mean throwing the data into a shapeless bag. The shape is still there. It just is not a type, it is a description traveling with the data.&lt;/p&gt;

&lt;p&gt;On the way out, one table carries:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Its own name&lt;/li&gt;
&lt;li&gt;A field list&lt;/li&gt;
&lt;li&gt;The values of each row&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Whoever receives it does not have to know it in advance. Because the description arrives with the data, there is no such thing as a type mismatch, and because every layer receives the same object, there is not one turn A into B step between reading from the database and feeding a front-end control. What is gone is not just those few lines of Mapper configuration, it is every error those lines can make.&lt;/p&gt;

&lt;p&gt;A container can hold more than one table, and almost every document in an ERP needs that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The order's container holds two tables, one master and one detail, with the detail hanging back onto the master by &lt;code&gt;sys_master_rowid&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Open an order and master and detail come back together; press save and they go out together&lt;/li&gt;
&lt;li&gt;How many tables to send, which one is the master, and which column the detail hangs back by, are all things &lt;code&gt;FormSchema&lt;/code&gt; has already said&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every row also carries two more things: whether it is added, modified, deleted or untouched, and what its original values were. That lets the save trip travel with no explanation attached. The user changed two fields on the master, deleted one detail line and added another, and what goes out is the container itself, with the server deciding which statement to issue from each row's state.&lt;/p&gt;

&lt;p&gt;The cost was recorded in an earlier post's boundaries of use: every response carries the container's field structure with it. For one order that is worth it. For an event stream at tens of thousands per second it is not.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. What checks the things the compiler cannot
&lt;/h2&gt;

&lt;p&gt;Once data and definition are apart, the joint between them is the field name, and a field name is a string.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;row["amout"]&lt;/code&gt; is one letter short and the compiler says nothing, throwing only when that line is actually executed. Worse, failure has more than one shape. A typo in a list column just means one column missing on screen, while the same name going into a query fails outright when the SELECT is assembled. A typo in a lookup window column is silent from start to finish: the column does not appear, and the log is clean too.&lt;/p&gt;

&lt;p&gt;The same name written inconsistently across layers is harder still to track down, and some front-end controls bind case sensitively. Column names in the container are therefore always lowercase, lined up with the definition files, the database columns, the expression identifiers and the front-end binding keys. There is one set of names the whole way through, so errors of the casing kind have no room to happen.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The compile time gate cannot be won back, and there is no need to pretend it can. Asking to check at compile time a shape that is decided at run time is a contradiction inside the request itself.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;What can be done is to move the moment of discovery forward, to the point where the shape is already settled. Once the definition is written, the shape is settled. Moving it forward has two stops.&lt;/p&gt;

&lt;h3&gt;
  
  
  The first stop: build time
&lt;/h3&gt;

&lt;p&gt;The framework puts these checks in &lt;a href="https://github.com/jeff377/bee-library/tree/main/src/Bee.Analyzers" rel="noopener noreferrer"&gt;&lt;code&gt;Bee.Analyzers&lt;/code&gt;&lt;/a&gt;, shipped inside the definition layer package, in effect as soon as it is referenced, with nothing extra to install. Most of them read not code but the XML of the definition files themselves.&lt;/p&gt;

&lt;p&gt;Nineteen rules in four groups:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Whether a single definition contradicts itself&lt;/li&gt;
&lt;li&gt;Whether several definitions line up with each other&lt;/li&gt;
&lt;li&gt;Whether the code holds to framework conventions&lt;/li&gt;
&lt;li&gt;Whether the shape of types taking part in transport is right&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The two below are actual output. Type &lt;code&gt;ref_customer_name&lt;/code&gt; in the order's list columns as &lt;code&gt;ref_customer_nmae&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;error BEE1004: FormSchema 'Order' lists 'ref_customer_nmae' in ListFields,
but the schema declares no such field. The list layout drops the unknown
column, but the query keeps it: building the SELECT throws
InvalidOperationException at run time. Fix: correct the name, or declare
the field.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The message has three parts: where it is wrong, how it will fail at run time if left alone, and how to fix it. The middle one is what most has to be there, because how it breaks at run time is exactly what decides how easy the error is to track down.&lt;/p&gt;

&lt;p&gt;The second answers a cost from &lt;a href="https://dev.to/jeff377/day-6-tableschema-and-database-structure-upgrades-25fn"&gt;TableSchema and Database Structure Upgrades&lt;/a&gt;, a field added to &lt;code&gt;FormSchema&lt;/code&gt; and forgotten in &lt;code&gt;TableSchema&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;error BEE2006: FormSchema 'Order' declares persisted field 'required_date'
on table 'ft_order', but the table schema has no such column. Queries
touching that field fail at run time. Fix: add the column to the table
schema, or mark the field Type="VirtualField" if it is not persisted.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both builds stop on that line of the definition file. Missing one of the three places the first post counted for adding a field now ends as one failed build, rather than a support ticket after go live.&lt;/p&gt;

&lt;h3&gt;
  
  
  The second stop: integration testing
&lt;/h3&gt;

&lt;p&gt;The first stop catches a definition written wrongly. What it cannot catch is the definitions all being right while what this document produces comes out wrong, and that takes data. The approach:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;From the definitions plus the existing data, export an Excel import format for each kind of data. Which columns this sheet needs, what type each is, which ones a relationship brings in, and which are computed and need no input, are all already answered in the definitions.&lt;/li&gt;
&lt;li&gt;Work the test data up in full in Excel, one file per kind. This step is the key one: whoever does it does not have to be a programmer. How many kinds of customer an order should cover, how many discount combinations, which boundary values simply have to be tried, are known best by the people who understand the business, and Excel is what they already use.&lt;/li&gt;
&lt;li&gt;The test imports them in order, master data first and documents after, which is the order of the real operation. The data can be fed to the API or straight to the business object, and the container both entry points receive has the same shape.&lt;/li&gt;
&lt;li&gt;Finally take a few key reports, turn the results into Excel, and compare them cell by cell against an Excel file already known to be right.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The first three steps only prove the data goes in. The last one is what asks whether the result is right. Pin both ends once each and the whole stretch in between is under test, without an assertion written for every step of it.&lt;/p&gt;

&lt;p&gt;This can be written as a tool because the description was pulled out. It is data a program can read, obtained by asking the API once, so the tool does not have to know any form in advance, and adding a form or changing a field does not move it either. After a release, run the whole batch again and you know whether anything broke.&lt;/p&gt;

&lt;p&gt;The route skips the front end on purpose, and the reason is not to save effort. The controls on screen are a set the framework owns, which control a field uses is computed from the definition, and a field of a given type grows the same control on every form and runs the same code.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;So the shape of how errors are distributed changes: when some control behavior breaks, it breaks across the board, not on one form.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A point everything converges on is what &lt;a href="https://dev.to/jeff377/day-7-formlayout-and-screen-generation-for-multiple-front-ends-1edf"&gt;FormLayout and Screen Generation for Multiple Front Ends&lt;/a&gt; called the rendering layer, where a hand written screen gets one screen wrong at a time and the rendering layer gets all of them wrong at once. That was said about risk. In testing it is the other side of the same thing: something that goes wrong everywhere cannot hide, and opening any form at all runs into it. Clicking through screens one at a time is the same code on its Nth execution.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. These costs are paid for the sake of a thousand forms
&lt;/h2&gt;

&lt;p&gt;A complete packaged ERP routinely has thousands of business tables, heavily interlinked. At that scale a set of types per form is not an inconvenience, it is unmanageable, and the costs Definition-Driven asks for are only worth paying at that scale either.&lt;/p&gt;

&lt;p&gt;The first return is that adding a form takes no code: three new files plus three rows of registration and it grows. Seven of the case study's eight forms came from exactly that, with data traveling from the database all the way to the screen through every layer in between and not one line written by the application. The remaining one is the order, which added a few rules the definitions cannot state.&lt;/p&gt;

&lt;p&gt;The second return is cost turning from multiplication into addition. Add a field and &lt;code&gt;FormSchema&lt;/code&gt; gains a line, &lt;code&gt;TableSchema&lt;/code&gt; gains a line, &lt;code&gt;FormLayout&lt;/code&gt; gains a line, and then the database has one more column, the CRUD statements one more, the container one more, the field list sent to the front end one more, and the screen one more control. There is not one intermediate type anywhere along it. Four of the thirteen places the first post counted are not easier to edit in this model, they do not exist.&lt;/p&gt;

&lt;p&gt;Adding a relationship or a front end is addition too. In the world of types, every extra relationship means deciding how to express it, when it loads and who maintains it. Here it is one more declaration in the definition, and the display columns it brings back are a few more columns in the container. An earlier post said supporting one more front end costs one rendering layer, and that front end does not have to bring types of its own, because the data it receives carries its own description.&lt;/p&gt;




&lt;h2&gt;
  
  
  The three parts of the split, and that bill
&lt;/h2&gt;

&lt;p&gt;Data and definitions splitting apart, so forms need no types of their own: what that sentence really says is not that a few files are saved. It is the sum of three things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The container's shape is decided by the definition, so it does not have to exist at compile time&lt;/li&gt;
&lt;li&gt;The description travels with the data, so no layer in between has to know it again&lt;/li&gt;
&lt;li&gt;Once the description is pulled out, those kinds of statement are no longer readable only by the compiler&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Take away any one of the three and the split does not hold.&lt;/p&gt;

&lt;p&gt;The split has a cost. The compiler now watches one category less for you, and that bill has not disappeared, it has moved to those two stops to be paid. Errors visible from the definition itself are caught at build time. What cannot be caught there gets a run with data prepared from the same definitions, with the reports compared cell by cell at the end.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Whether a design is honest is not about whether it has a cost, it is about whether it knows where its cost lands.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The next post, &lt;a href="https://medium.com/@jeff377/day-10-multi-database-support-crud-and-structure-upgrades-9d1cebf6f8db" rel="noopener noreferrer"&gt;Multi-Database Support: CRUD and Structure Upgrades&lt;/a&gt;, is about how this data finally gets into a database: how a framework that knows no database driver in advance still produces CRUD statements for every one of them, and can change their structures too.&lt;/p&gt;




&lt;p&gt;📚 Part of the thirty-post series &lt;a href="https://dev.to/jeff377/erp-architects-notes-designing-a-definition-driven-framework-series-index-4ln7"&gt;ERP Architect's Notes: Designing a Definition-Driven Framework&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;📘 Original article on HackMD, in Traditional Chinese:&lt;br&gt;
👉 &lt;a href="https://hackmd.io/@jeff377/day-09-data-and-schema-split" rel="noopener noreferrer"&gt;https://hackmd.io/@jeff377/day-09-data-and-schema-split&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;📢 You are welcome to republish this, please credit the source.&lt;br&gt;
📬 Follow for more engineering notes:&lt;br&gt;
&lt;a href="https://www.facebook.com/profile.php?id=61574839666569" rel="noopener noreferrer"&gt;Facebook&lt;/a&gt; ｜ &lt;a href="https://hackmd.io/@jeff377" rel="noopener noreferrer"&gt;HackMD&lt;/a&gt; ｜ &lt;a href="https://github.com/jeff377" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; ｜ &lt;a href="https://www.nuget.org/profiles/jeff377" rel="noopener noreferrer"&gt;NuGet&lt;/a&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>dotnet</category>
      <category>erp</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Day 8: Declarative Business Rules and Computed Fields</title>
      <dc:creator>Jeff Tsai</dc:creator>
      <pubDate>Sun, 01 Nov 2026 02:35:00 +0000</pubDate>
      <link>https://dev.to/jeff377/day-8-declarative-business-rules-and-computed-fields-4jj9</link>
      <guid>https://dev.to/jeff377/day-8-declarative-business-rules-and-computed-fields-4jj9</guid>
      <description>&lt;p&gt;What the last few posts grew out of definitions were concrete products: tables, CRUD statements, screens on four front ends. Today's item builds nothing. It takes the expressions written into the definition and evaluates them, either writing the result back into a field (&lt;code&gt;ValueExpression&lt;/code&gt;) or stopping the whole save (&lt;code&gt;FormRule&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Required fields, range checks, arithmetic between fields: the content differs at every company and the shape is identical. All of them are take a few fields on this row, work something out, and stop if it does not hold. An identical shape is something a mechanism can absorb, so changing simple rules and calculations of this kind takes no code at all.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What gets standardized is the shape, not the content.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This post covers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Why &lt;code&gt;FormRule&lt;/code&gt; and &lt;code&gt;ValueExpression&lt;/code&gt; live in the same definition as the fields&lt;/li&gt;
&lt;li&gt;The four points where evaluation happens, and the asymmetry of validation running only on the back end&lt;/li&gt;
&lt;li&gt;How the front end and the back end share one expression&lt;/li&gt;
&lt;li&gt;The two kinds an expression cannot state, and why they differ in nature&lt;/li&gt;
&lt;li&gt;Declarative as the default rather than the only route, with application code stacking on top&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  1. FormRule lives in the same definition as the fields
&lt;/h2&gt;

&lt;p&gt;Declarative logic has two homes. The two extracts below come from the case study's order form, &lt;a href="https://github.com/jeff377/bee-northwind-avalonia/blob/main/Define/FormSchema/Order.FormSchema.xml" rel="noopener noreferrer"&gt;&lt;code&gt;Order.FormSchema.xml&lt;/code&gt;&lt;/a&gt;. One hangs off a field:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;FormField&lt;/span&gt; &lt;span class="na"&gt;FieldName=&lt;/span&gt;&lt;span class="s"&gt;"amount"&lt;/span&gt; &lt;span class="na"&gt;DbType=&lt;/span&gt;&lt;span class="s"&gt;"Currency"&lt;/span&gt; &lt;span class="na"&gt;NumberKind=&lt;/span&gt;&lt;span class="s"&gt;"Amount"&lt;/span&gt; &lt;span class="na"&gt;ReadOnly=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;
           &lt;span class="na"&gt;ValueExpression=&lt;/span&gt;&lt;span class="s"&gt;"quantity * unit_price * (1 - discount)"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The other hangs under the &lt;code&gt;FormSchema&lt;/code&gt; as a whole:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;Rules&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;FormRule&lt;/span&gt; &lt;span class="na"&gt;RuleId=&lt;/span&gt;&lt;span class="s"&gt;"customer_required"&lt;/span&gt; &lt;span class="na"&gt;Trigger=&lt;/span&gt;&lt;span class="s"&gt;"BeforeSave"&lt;/span&gt;
            &lt;span class="na"&gt;When=&lt;/span&gt;&lt;span class="s"&gt;"status != &amp;amp;quot;Draft&amp;amp;quot;"&lt;/span&gt;
            &lt;span class="na"&gt;Condition=&lt;/span&gt;&lt;span class="s"&gt;"customer_rowid != Guid.Empty"&lt;/span&gt;
            &lt;span class="na"&gt;Message=&lt;/span&gt;&lt;span class="s"&gt;"Please select a customer for the order."&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;FormRule&lt;/span&gt; &lt;span class="na"&gt;RuleId=&lt;/span&gt;&lt;span class="s"&gt;"quantity_positive"&lt;/span&gt; &lt;span class="na"&gt;Trigger=&lt;/span&gt;&lt;span class="s"&gt;"BeforeSave"&lt;/span&gt; &lt;span class="na"&gt;TargetTable=&lt;/span&gt;&lt;span class="s"&gt;"OrderDetail"&lt;/span&gt;
            &lt;span class="na"&gt;Condition=&lt;/span&gt;&lt;span class="s"&gt;"quantity &amp;amp;gt; 0"&lt;/span&gt;
            &lt;span class="na"&gt;Message=&lt;/span&gt;&lt;span class="s"&gt;"Every detail line must have a quantity greater than zero."&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/Rules&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both live in &lt;code&gt;FormSchema&lt;/code&gt;, and there is no separate rules file. One &lt;code&gt;FormRule&lt;/code&gt; reads as a single judgment, and the order of its attributes is the order of its meaning:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Trigger&lt;/code&gt;: when to check. Both rules in the example check before saving, and checking before a delete has to be marked separately&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;When&lt;/code&gt;: whether this check applies this time. If it does not hold, the whole rule is skipped&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Condition&lt;/code&gt;: the condition that has to hold&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Message&lt;/code&gt;: the sentence the user sees when it does not&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ValueExpression&lt;/code&gt;: not part of that judgment at all. It hangs off a field, and what it computes is written back into a column&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;When&lt;/code&gt; in the example means a customer can be left blank while the order is still a draft, and is required once it leaves draft. It is a separate attribute from &lt;code&gt;Condition&lt;/code&gt; rather than folded into one sentence because the folded shape is easy to write backwards, and a rule written backwards does not report an error. It just passes, from then on, always.&lt;/p&gt;

&lt;p&gt;Living alongside the fields is deliberate. Apart from &lt;code&gt;Trigger&lt;/code&gt;, the subject of every one of those attributes is a field. Rename a field, delete it or change its type, and the &lt;code&gt;FormRule&lt;/code&gt; has to move with it. Two separate files means one of them goes stale first, and the stale one does not report an error either. It quietly stops stopping anything, one day.&lt;/p&gt;

&lt;p&gt;One effect is especially visible on &lt;code&gt;FormRule&lt;/code&gt;. Among the things that change most often after a system goes live, validation conditions come near the top: the maximum discount used to be 20 percent and has been loosened to 30; this document used to be saveable at zero value and now is not. The change is small enough to state in one sentence, and going through code costs a whole round of compiling, packaging and deploying. &lt;code&gt;FormRule&lt;/code&gt; is data, so what changes is one string.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Evaluation happens at only a few points
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Point&lt;/th&gt;
&lt;th&gt;Where&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;When a row is created&lt;/td&gt;
&lt;td&gt;Front end&lt;/td&gt;
&lt;td&gt;Default value expressions are evaluated once and filled into the fields still empty&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;When a field is edited&lt;/td&gt;
&lt;td&gt;Front end&lt;/td&gt;
&lt;td&gt;The computed fields that depend on it are recalculated immediately, and the amount on screen moves with them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Before a save&lt;/td&gt;
&lt;td&gt;Back end&lt;/td&gt;
&lt;td&gt;New rows get their still empty default values filled in first, then new and modified rows recalculate their computed fields, and last the rules whose &lt;code&gt;Trigger&lt;/code&gt; is &lt;code&gt;BeforeSave&lt;/code&gt; run. Rows nobody touched are not recalculated, because recalculating would mark them as touched&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Before a delete&lt;/td&gt;
&lt;td&gt;Back end&lt;/td&gt;
&lt;td&gt;The rules whose &lt;code&gt;Trigger&lt;/code&gt; is &lt;code&gt;BeforeDelete&lt;/code&gt; run. What they check against is not the data in the user's hands, it is the record the framework reads back from the database separately&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The front end is doing arithmetic too, which raises a question: at save time, should the values it worked out be trusted?&lt;/p&gt;

&lt;p&gt;The framework's answer is no. That recalculation before saving is unconditional, and every &lt;code&gt;ValueExpression&lt;/code&gt; result sent up from the front end is overwritten. Data integrity cannot be entrusted to the front end. It may have calculated wrongly, and it may not even be the front end you wrote.&lt;/p&gt;

&lt;p&gt;That decision buys a property: the front-end half can be absent entirely. A front end that cannot run live evaluation is simply one where the amount does not move as you type. The operation is unchanged, and the value stored in the database is identical. Live preview is experience, not part of correctness.&lt;/p&gt;

&lt;h3&gt;
  
  
  An asymmetry: validation runs only on the back end
&lt;/h3&gt;

&lt;p&gt;Computed fields are calculated on both sides. &lt;code&gt;FormRule&lt;/code&gt; runs only at the business object step on the back end, and never once on the front end.&lt;/p&gt;

&lt;p&gt;The difference is what happens when each one is wrong:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A computed field the front end got wrong costs nothing, because the recalculation before saving overwrites it.&lt;/li&gt;
&lt;li&gt;Validation has no overwriting step. What the front end blocks, the back end never sees at all, and what the front end lets through, the back end blocks anyway.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So moving a copy of validation to the front end buys the user one round trip of waiting, at the cost of the same rule being evaluated in two places. Sooner or later the two stop lining up, and then there is one more question to answer: whose message counts.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;FormRule&lt;/code&gt; has exactly one line of defense, the step before saving on the back end. What the front end is responsible for is letting the user watch the numbers move.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Where these points sit inside the save flow, and how they relate to the part that writes to the database, is the subject of &lt;a href="https://medium.com/@jeff377/day-14-extension-points-on-a-business-object-and-when-to-override-them-0131cbcbcb45" rel="noopener noreferrer"&gt;Extension Points on a Business Object and When to Override Them&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. One expression, and both sides have to get the same answer
&lt;/h2&gt;

&lt;p&gt;If the same expression is written once on each side, then next year somebody changes only one of them, the screen shows one number and what gets stored is another. Each side looks correct on its own, which is what makes it take so long to track down.&lt;/p&gt;

&lt;p&gt;The framework has both sides reference the same code. One line of &lt;code&gt;ValueExpression&lt;/code&gt; applies to the front end and the back end at once. The evaluation code sits in the definition layer with no server only dependencies, so what the back end calls before saving and what the front end calls on every keystroke are the same method on the same class. Whether the two could produce different answers is structurally not a question.&lt;/p&gt;

&lt;p&gt;That one line of expression gives something else as well. To recalculate live, the front end first has to know which computed fields to recalculate when a given field is edited, and that mapping is not configured separately. It is worked backwards by parsing the &lt;code&gt;ValueExpression&lt;/code&gt; once and taking the names out of it.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. The two kinds an expression cannot state
&lt;/h2&gt;

&lt;p&gt;What an expression can see is the batch of data this operation is holding, and for now only this row of it is opened up. An amount is this row's quantity times this row's unit price, and a required field rule looks at whether this row filled it in.&lt;/p&gt;

&lt;p&gt;That range decides what currently cannot be declared, and those things happen to fall into two kinds that are nothing like each other:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Kind&lt;/th&gt;
&lt;th&gt;Examples&lt;/th&gt;
&lt;th&gt;Nature&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cross row&lt;/td&gt;
&lt;td&gt;There must be at least one detail line, or the master total equals the sum of the detail amounts&lt;/td&gt;
&lt;td&gt;The data is already in hand, so this one is a matter of progress&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Has to ask the database&lt;/td&gt;
&lt;td&gt;A status cannot go back from shipped to draft, or assign an order number&lt;/td&gt;
&lt;td&gt;The data is not in hand, and this one is left outside deliberately&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The cross row kind is reachable by adding an aggregate function, letting an expression state the sum of the detail amounts, since those rows are in the same batch as the master to begin with. The hard part is that evaluation then has to queue up again: the master total depends on the detail amounts, a detail amount is itself an expression, and the ordering needs rules of its own. There is a solution for that. It is simply not implemented yet.&lt;/p&gt;

&lt;p&gt;The other kind is what genuinely cannot be stated. The moment an expression can query the database it stops being portable, the front end can no longer evaluate it, and the property from the previous section, both sides referencing the same code, is the first thing to break. So the line is not drawn at one row versus several rows. It is drawn at whether the answer is inside this batch of data. What is inside will be reachable eventually. What requires leaving it to go and ask is withheld on purpose.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Declarative is the default, not the only route
&lt;/h2&gt;

&lt;p&gt;The doubt raised back in &lt;a href="https://dev.to/jeff377/day-3-the-northwind-case-study-eight-forms-and-a-custom-business-object-3i33"&gt;The Northwind Case Study: Eight Forms and a Custom Business Object&lt;/a&gt; is at its most direct here: if validation can only be written as a &lt;code&gt;Condition&lt;/code&gt;, what happens to whatever an expression cannot state?&lt;/p&gt;

&lt;p&gt;Half the answer is in the previous section, which is that what cannot be stated gets written as code. The other half is that declarative was never the only route. It is the default.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;That whole evaluation sequence is itself the default behavior of the business object's before saving step, and that step is open to being overridden.&lt;/li&gt;
&lt;li&gt;An application that overrides it calls the default part first, so the rules still run and the computed fields are still calculated, and then continues into its own code. The two stack. It is not a choice between them.&lt;/li&gt;
&lt;li&gt;One level further down there is a finer granularity again. &lt;code&gt;FormRule&lt;/code&gt; is data, and each rule carries its own &lt;code&gt;Enabled&lt;/code&gt; and &lt;code&gt;Order&lt;/code&gt;, so a single rule can be disabled and the order within one point can be specified. Turning one rule off does not mean touching the whole definition, let alone changing code.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Flexibility sits in the same shape here as it does at the layout layer, covered in &lt;a href="https://dev.to/jeff377/day-7-formlayout-and-screen-generation-for-multiple-front-ends-1edf"&gt;FormLayout and Screen Generation for Multiple Front Ends&lt;/a&gt;: not in the capability, in the granularity.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. The case study: what the definition absorbed, and what stayed in code
&lt;/h2&gt;

&lt;p&gt;One &lt;code&gt;ValueExpression&lt;/code&gt; plus three &lt;code&gt;FormRule&lt;/code&gt; entries is everything the case study currently declares. What they replaced was code that used to sit in the order's business object: the per row amount calculation, and three checks on customer, product and quantity.&lt;/p&gt;

&lt;p&gt;Four things are still in code, and they split two and two across the two kinds above:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cross row: at least one detail line, and the master total&lt;/li&gt;
&lt;li&gt;Has to ask the database: the status transition, and the order number&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The case study post cut the same pile once before, asking whether each thing ought to be written by the application. Today's line asks why each one cannot be declared. The two are not asking the same question, so an item does not have to land in the matching place. The status transition and the order number were on opposite sides back then, one being a business judgment that differs at every company and the other being something that should be absorbed and has not been yet, while today's line puts them both in the same kind.&lt;/p&gt;

&lt;h3&gt;
  
  
  That line has moved, and nobody will tell you
&lt;/h3&gt;

&lt;p&gt;The order's business object does one more thing that the case study did not count: on create, it sets the order date to today. The framework already does that. A date typed field gets today's date when a row is created, with nothing declared at all. The difference is that when the other items moved into definitions somebody went back and deleted the code, and for this one nobody did. That line still runs, the tests are still green, and it simply has no reason to exist any more.&lt;/p&gt;

&lt;p&gt;Every notch the declarative side moves forward turns a batch of things on the code side from necessary into residue.&lt;/p&gt;

&lt;p&gt;The case study put it as what stays in code being partly what belonged there all along, and partly what has simply not been taken over yet. There is a third kind: already taken over, with nobody having gone back to remove it. Of the three that one is the hardest to find, because everything about it is fine. It is just written for nothing.&lt;/p&gt;




&lt;h2&gt;
  
  
  In closing
&lt;/h2&gt;

&lt;p&gt;Of the four things left in the case study's code, only one is a rule this company set for itself. The other three are not business judgments, they are places where the declarative side failed to catch them. And that answer has a shelf life: the cross row half disappears once aggregate functions and evaluation ordering arrive, and only the half that has to ask the database is outside on purpose.&lt;/p&gt;

&lt;p&gt;So which parts should be written as code is never a list, it is a line that moves. What a framework designer can do is not to draw it in the right place once, it is to make sure that when it does move forward, not one word of the hundreds of definitions already written has to change.&lt;/p&gt;

&lt;p&gt;The next post, &lt;a href="https://medium.com/@jeff377/day-9-data-and-definitions-split-apart-so-forms-need-no-types-of-their-own-5e76d8564d72" rel="noopener noreferrer"&gt;Data and Definitions Split Apart, So Forms Need No Types of Their Own&lt;/a&gt;, is about the data container that travels all the way across the layers: why a thousand tables do not each get a type of their own, and how the bill for losing compile time checking gets paid.&lt;/p&gt;




&lt;p&gt;📚 Part of the thirty-post series &lt;a href="https://dev.to/jeff377/erp-architects-notes-designing-a-definition-driven-framework-series-index-4ln7"&gt;ERP Architect's Notes: Designing a Definition-Driven Framework&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;📘 Original article on HackMD, in Traditional Chinese:&lt;br&gt;
👉 &lt;a href="https://hackmd.io/@jeff377/day-08-declarative-rules" rel="noopener noreferrer"&gt;https://hackmd.io/@jeff377/day-08-declarative-rules&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;📢 You are welcome to republish this, please credit the source.&lt;br&gt;
📬 Follow for more engineering notes:&lt;br&gt;
&lt;a href="https://www.facebook.com/profile.php?id=61574839666569" rel="noopener noreferrer"&gt;Facebook&lt;/a&gt; ｜ &lt;a href="https://hackmd.io/@jeff377" rel="noopener noreferrer"&gt;HackMD&lt;/a&gt; ｜ &lt;a href="https://github.com/jeff377" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; ｜ &lt;a href="https://www.nuget.org/profiles/jeff377" rel="noopener noreferrer"&gt;NuGet&lt;/a&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>dotnet</category>
      <category>erp</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>ERP Architect's Notes: Designing a Definition-Driven Framework · Series Index</title>
      <dc:creator>Jeff Tsai</dc:creator>
      <pubDate>Sat, 31 Oct 2026 01:10:08 +0000</pubDate>
      <link>https://dev.to/jeff377/erp-architects-notes-designing-a-definition-driven-framework-series-index-4ln7</link>
      <guid>https://dev.to/jeff377/erp-architects-notes-designing-a-definition-driven-framework-series-index-4ln7</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;The complexity of an ERP is not in how hard any single feature is. It is in how many times the same fact gets restated, multiplied by how many tables, multiplied again by how many customizations.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A thirty-post series written for &lt;a href="https://ithelp.ithome.com.tw/users/20007956/ironman/9251" rel="noopener noreferrer"&gt;iThome Ironman 2026&lt;/a&gt;, a thirty-day technical writing challenge in Taiwan, published from 2026-08-17 to 2026-09-15. The series is complete.&lt;/p&gt;

&lt;p&gt;This series is not about building one form with a framework. It is about designing a framework that can drive hundreds of forms: the single source of truth lives in definitions rather than code, and the database structure, SQL, screens for multiple front ends, validation and computation, multiple languages, multi-tenancy and customization are all derived from the same layer of definitions. The Northwind case study verifies it, but the case study is evidence, not the subject.&lt;/p&gt;

&lt;p&gt;It is about mechanisms and trade-offs, not a usage tutorial, and the cost of every design is written as plainly as its benefits.&lt;/p&gt;




&lt;h2&gt;
  
  
  Chapter One · Why Definition-Driven
&lt;/h2&gt;

&lt;p&gt;What the problem is, where the paradigms diverge, and the case study's first appearance. This chapter sets side by side how many places one field touches under each paradigm.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Day 1 · &lt;a href="https://dev.to/jeff377/day-1-why-erp-needs-a-definition-driven-framework-1c2b"&gt;Why ERP Needs a Definition-Driven Framework&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Day 2 · &lt;a href="https://dev.to/jeff377/day-2-an-overview-of-definition-driven-architecture-31hi"&gt;An Overview of Definition-Driven Architecture&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Day 3 · &lt;a href="https://dev.to/jeff377/day-3-the-northwind-case-study-eight-forms-and-a-custom-business-object-3i33"&gt;The Northwind Case Study: Eight Forms and a Custom Business Object&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Chapter Two · Definition Layer
&lt;/h2&gt;

&lt;p&gt;What kinds of definition files there are, why their lifecycles differ, and how one structural description grows into the database structure, CRUD SQL, screens for multiple front ends and business rules.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Day 4 · &lt;a href="https://dev.to/jeff377/day-4-the-purpose-and-classification-of-definition-files-3i6n"&gt;The Purpose and Classification of Definition Files&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Day 5 · &lt;a href="https://dev.to/jeff377/day-5-how-formschema-drives-sql-4b4f"&gt;How FormSchema Drives SQL&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Day 6 · &lt;a href="https://dev.to/jeff377/day-6-tableschema-and-database-structure-upgrades-25fn"&gt;TableSchema and Database Structure Upgrades&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Day 7 · &lt;a href="https://dev.to/jeff377/day-7-formlayout-and-screen-generation-for-multiple-front-ends-1edf"&gt;FormLayout and Screen Generation for Multiple Front Ends&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Day 8 · &lt;a href="https://dev.to/jeff377/day-8-declarative-business-rules-and-computed-fields-4jj9"&gt;Declarative Business Rules and Computed Fields&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Chapter Three · Data Access and Caching
&lt;/h2&gt;

&lt;p&gt;Without writing DTOs, how data crosses layers; the only two things multiple databases have in common; and how a cache knows its source has changed.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Day 9 · &lt;a href="https://dev.to/jeff377/day-9-data-and-definitions-split-apart-so-forms-need-no-types-of-their-own-505g"&gt;Data and Definitions Split Apart, So Forms Need No Types of Their Own&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Day 10 · &lt;a href="https://dev.to/jeff377/day-10-multi-database-support-crud-and-structure-upgrades-1cld"&gt;Multi-Database Support: CRUD and Structure Upgrades&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Day 11 · Connections, Transactions and Query Conditions in the Data Access Layer&lt;/li&gt;
&lt;li&gt;Day 12 · Cross-Node Invalidation for Definition and Data Caches&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Chapter Four · Business Logic and API
&lt;/h2&gt;

&lt;p&gt;Who takes over the part definitions cannot express, where the extension points open, and what a call passes through on its way from the caller to the business object.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Day 13 · Session Management and Lifecycle&lt;/li&gt;
&lt;li&gt;Day 14 · Extension Points on a Business Object and When to Override Them&lt;/li&gt;
&lt;li&gt;Day 15 · API Contract Layering and the Type Registry&lt;/li&gt;
&lt;li&gt;Day 16 · JSON-RPC 2.0: Protocol Choice and the Request Pipeline&lt;/li&gt;
&lt;li&gt;Day 17 · The Connector and the Convergence of Client Responsibilities&lt;/li&gt;
&lt;li&gt;Day 18 · The Error Contract and User Visible Messages&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Chapter Five · Multi-Tenancy and Customization
&lt;/h2&gt;

&lt;p&gt;How one codebase lets every company look different without branching and without recompiling or redeploying. Where the customization work currently stops is stated plainly too.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Day 19 · The Scope of the Customization Layer and Overlay Granularity&lt;/li&gt;
&lt;li&gt;Day 20 · Multilingual Customization and Resource Structure&lt;/li&gt;
&lt;li&gt;Day 21 · Business Logic Customization and the Four Plugin Stages&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Chapter Six · Transport, Security and Audit
&lt;/h2&gt;

&lt;p&gt;What data looks like when it is sent, which protections it passes through, who is allowed to call, and what can be looked up afterward.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Day 22 · The Division of Labor Between XML, JSON and MessagePack&lt;/li&gt;
&lt;li&gt;Day 23 · The API Payload Security Pipeline: Order, Protection Levels and Keys&lt;/li&gt;
&lt;li&gt;Day 24 · The Three Axes of Permissions: Action, Row and Column&lt;/li&gt;
&lt;li&gt;Day 25 · The Audit Trail and the Before and After Values of a Change&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Chapter Seven · Data Semantics
&lt;/h2&gt;

&lt;p&gt;Amounts, quantities, dates and times of day: how the meaning of these values is kept from the definition all the way to the database, without any layer quietly changing it along the way.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Day 26 · Numeric Semantics, Multiple Currencies and Units of Measure&lt;/li&gt;
&lt;li&gt;Day 27 · The Semantic Boundaries Between Date, DateTime and Time&lt;/li&gt;
&lt;li&gt;Day 28 · Time Zone Conversion of DateTime&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Chapter Eight · Case Review and The Big Picture
&lt;/h2&gt;

&lt;p&gt;The Northwind application examined item by item: which part definitions, the framework and application code each own. Then a single big picture, and the systems this design does not suit.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Day 29 · Which Part Definitions, the Framework and Application Code Each Own&lt;/li&gt;
&lt;li&gt;Day 30 · Three Depths of Development and the Big Picture of the Framework&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  About the framework behind this series
&lt;/h2&gt;

&lt;p&gt;The mechanisms discussed in the series are all built into an open source framework, &lt;a href="https://github.com/jeff377/bee-library" rel="noopener noreferrer"&gt;bee-library&lt;/a&gt;. The case study is a separate repository, &lt;a href="https://github.com/jeff377/bee-northwind-avalonia" rel="noopener noreferrer"&gt;bee-northwind-avalonia&lt;/a&gt;, which pulls the framework in as a package and holds only the application's own definition files and code.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Update: Bee.NET (bee-library), the framework used when this series was written, has since been taken over by &lt;a href="https://github.com/polhem-dev/polhem" rel="noopener noreferrer"&gt;Polhem&lt;/a&gt;. The mechanisms carry over, the framework has been substantially refactored, and it is open source under the MIT license. The bee-library and bee-northwind-avalonia repositories are archived; type names and code in the posts follow bee-library as it was at the time of writing.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The framework's only role in this series is to show that these ideas are not just talk on paper. You can skip it entirely, take away only the idea of letting definitions be the source, and build that idea in the languages and tools you know.&lt;/p&gt;




&lt;p&gt;📘 Original index on HackMD, in Traditional Chinese:&lt;br&gt;
👉 &lt;a href="https://hackmd.io/@jeff377/ironman-2026" rel="noopener noreferrer"&gt;https://hackmd.io/@jeff377/ironman-2026&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;📢 You are welcome to republish this, please credit the source.&lt;br&gt;
📬 Follow for more engineering notes:&lt;br&gt;
&lt;a href="https://www.facebook.com/profile.php?id=61574839666569" rel="noopener noreferrer"&gt;Facebook&lt;/a&gt; ｜ &lt;a href="https://hackmd.io/@jeff377" rel="noopener noreferrer"&gt;HackMD&lt;/a&gt; ｜ &lt;a href="https://github.com/jeff377" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; ｜ &lt;a href="https://www.nuget.org/profiles/jeff377" rel="noopener noreferrer"&gt;NuGet&lt;/a&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>dotnet</category>
      <category>erp</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Day 7: FormLayout and Screen Generation for Multiple Front Ends</title>
      <dc:creator>Jeff Tsai</dc:creator>
      <pubDate>Sat, 31 Oct 2026 00:55:16 +0000</pubDate>
      <link>https://dev.to/jeff377/day-7-formlayout-and-screen-generation-for-multiple-front-ends-1edf</link>
      <guid>https://dev.to/jeff377/day-7-formlayout-and-screen-generation-for-multiple-front-ends-1edf</guid>
      <description>&lt;p&gt;&lt;a href="https://dev.to/jeff377/day-6-tableschema-and-database-structure-upgrades-25fn"&gt;TableSchema and Database Structure Upgrades&lt;/a&gt; ended at physical tables, where one change means moving data and working out beforehand whether you need downtime. Today is back on the screen side, where the character is the exact opposite. Changing a screen means changing a definition, with no new release, and one change lands on every front end at once.&lt;/p&gt;

&lt;p&gt;The order form in the case study works exactly like that. Desktop, browser, iOS and Android all read the same &lt;code&gt;FormLayout&lt;/code&gt; and each grows its own look, with not one screen written by hand.&lt;/p&gt;

&lt;p&gt;This post covers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Why &lt;code&gt;FormLayout&lt;/code&gt;'s vocabulary is deliberately small&lt;/li&gt;
&lt;li&gt;From one field to one native control&lt;/li&gt;
&lt;li&gt;One rendering layer per front end&lt;/li&gt;
&lt;li&gt;Why this layer stands on its own: it expires&lt;/li&gt;
&lt;li&gt;Why narrow screen reshaping is not written into the definition&lt;/li&gt;
&lt;li&gt;Screens come in two kinds, automatic and manual layout, and the data binding is the same for both&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  1. FormLayout's vocabulary is deliberately small
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;FormLayout&lt;/code&gt; is made of two kinds of section:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Master sections: there can be several, and they all share one column count&lt;/li&gt;
&lt;li&gt;Detail sections: there can be several or none at all, and they always run the full width below the master&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A master section holds fields, and about a field the layout states only this much: which group it belongs to, what it comes after, whether it takes extra width, which kind of control it uses, whether it is visible, whether it is read only, and whether it is required. A detail section holds columns, and it can state much the same set, plus a width.&lt;/p&gt;

&lt;p&gt;The layout does not talk about position. There is not one coordinate in the whole definition, and nothing that says put this at row three, column two. Position falls out of the rendering layer following the order: fields fill in one after another, a full row moves on to the next one, and a field marked as spanning two columns takes two cells. Even the column count is not a promise. It is an upper bound, and a narrow screen pushes it down.&lt;/p&gt;

&lt;p&gt;Here is an extract from the case study's order layout, the full file being &lt;a href="https://github.com/jeff377/bee-northwind-avalonia/blob/main/Define/FormLayout/Order.FormLayout.xml" rel="noopener noreferrer"&gt;&lt;code&gt;Order.FormLayout.xml&lt;/code&gt;&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;LayoutSection&lt;/span&gt; &lt;span class="na"&gt;Name=&lt;/span&gt;&lt;span class="s"&gt;"Main"&lt;/span&gt; &lt;span class="na"&gt;Caption=&lt;/span&gt;&lt;span class="s"&gt;"Order"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;Fields&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;LayoutField&lt;/span&gt; &lt;span class="na"&gt;FieldName=&lt;/span&gt;&lt;span class="s"&gt;"order_date"&lt;/span&gt; &lt;span class="na"&gt;Caption=&lt;/span&gt;&lt;span class="s"&gt;"Order Date"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;LayoutField&lt;/span&gt; &lt;span class="na"&gt;FieldName=&lt;/span&gt;&lt;span class="s"&gt;"customer_rowid"&lt;/span&gt; &lt;span class="na"&gt;Caption=&lt;/span&gt;&lt;span class="s"&gt;"Customer"&lt;/span&gt; &lt;span class="na"&gt;ControlType=&lt;/span&gt;&lt;span class="s"&gt;"ButtonEdit"&lt;/span&gt;
                 &lt;span class="na"&gt;DisplayFields=&lt;/span&gt;&lt;span class="s"&gt;"ref_customer_id,ref_customer_name"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;LayoutField&lt;/span&gt; &lt;span class="na"&gt;FieldName=&lt;/span&gt;&lt;span class="s"&gt;"total_amount"&lt;/span&gt; &lt;span class="na"&gt;Caption=&lt;/span&gt;&lt;span class="s"&gt;"Total Amount"&lt;/span&gt; &lt;span class="na"&gt;ReadOnly=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/Fields&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/LayoutSection&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A layout answers four questions and no more: which fields this form has, in what order, in which groups, and which kind of control each field uses. Visible, read only and required are states hanging off a field, and they do not change the shape.&lt;/p&gt;

&lt;p&gt;A small vocabulary is this layer's specification, not a sign that it has yet to grow up. The layout is not the final thing to be drawn, it is the thing in the middle. Its downstream is not one kind of screen, it is each front end's own arrangement mechanism, and those mechanisms have nothing in common with each other: a layout control on the desktop on one side, the browser's grid on the other, and whatever the next front-end generation uses is unknown today. For an intermediate that has to feed all of them, the vocabulary has to be small enough that every one of them can translate it.&lt;/p&gt;

&lt;p&gt;So the things that are not in it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Placement, meaning alignment, margins and spacing&lt;/li&gt;
&lt;li&gt;Styles, templates and animation, which are about what it gets drawn as, and belong to the rendering layer&lt;/li&gt;
&lt;li&gt;Sections inside sections. A layout opened up is flat, so it can be scanned in bulk and checked programmatically&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What is left out matters more than what is kept. A field pinned to a pixel position breaks on the next screen size, and if a definition can express that at all, what breaks is a thousand definitions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hundreds of screens that look the same
&lt;/h3&gt;

&lt;p&gt;The users of an ERP, HRM or CRM system are almost all internal staff. They do not pick a screen to use, they are assigned a whole module, and before it goes live they have to learn dozens or hundreds of the forms in it.&lt;/p&gt;

&lt;p&gt;If screens are written by hand one at a time, every one of them is free to look different. Details on the right here, underneath there. Picking a customer through a magnifying glass that opens a window here, a drop down there. The add button above the grid here, bottom right there. Each difference on its own is nothing. Added up, they are what the user has to remember.&lt;/p&gt;

&lt;p&gt;With a small layout vocabulary, those differences have nowhere to happen. What the user learns is not hundreds of screens, it is one screen plus hundreds of sets of fields. Training cost therefore does not scale with the number of forms, and training has never been a small line in a rollout budget. Replace the staff or add a department and it gets paid again.&lt;/p&gt;

&lt;p&gt;The cost: buying consistency means no screen is ever optimized for itself. A form used far more often than the rest can have its layout adjusted, but only within this vocabulary. Whether that trade is worth it depends on how many forms there are. Ten screens each optimized for itself is a good thing. Hundreds each optimized for itself is hundreds of operating habits.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Fields map to controls
&lt;/h2&gt;

&lt;p&gt;If a field does not name a control, the kind of control is decided by its type in &lt;code&gt;FormSchema&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type declared in &lt;code&gt;FormSchema&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;Default kind of control&lt;/th&gt;
&lt;th&gt;Desktop&lt;/th&gt;
&lt;th&gt;Web&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Boolean&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CheckEdit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Check box control&lt;/td&gt;
&lt;td&gt;Check box input element&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Date and datetime&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DateEdit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Date input control&lt;/td&gt;
&lt;td&gt;Date input element&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Long text&lt;/td&gt;
&lt;td&gt;&lt;code&gt;MemoEdit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Multiline text control&lt;/td&gt;
&lt;td&gt;Multiline text element&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Small integer, integer, big integer, decimal and currency&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NumericEdit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Numeric input control&lt;/td&gt;
&lt;td&gt;Text input element&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Everything else&lt;/td&gt;
&lt;td&gt;&lt;code&gt;TextEdit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Text input control&lt;/td&gt;
&lt;td&gt;Text input element&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The kind of control gets translated into each front end's native control, which is what the two right hand columns of the table above are, and beyond the name the two sides have nothing in common.&lt;/p&gt;

&lt;p&gt;One rule outranks the type when picking a control. A field that declares a cross form relationship and does not name a control gets a lookup window editor by default. An earlier post, &lt;a href="https://dev.to/jeff377/day-5-how-formschema-drives-sql-4b4f"&gt;How FormSchema Drives SQL&lt;/a&gt;, said the same relationship declaration is also at work on the screen side, and this is where it lands. That one control binds several fields at once. The value goes onto the relation key, what shows on screen is the display columns it brings back, and one pick writes all of them back together, so those display columns do not each take a cell of their own.&lt;/p&gt;

&lt;p&gt;When a control is bound it reads back from &lt;code&gt;FormSchema&lt;/code&gt;. A text control fetches the maximum length, a drop down fetches the option list, and a lookup window editor fetches which program it should open. So the layout only has to record which kind of control, and never restates what the field means.&lt;/p&gt;

&lt;p&gt;The only part tied to a UI framework is that translation into native controls, and all of it sits inside the rendering layer. The parts before and after deal only with definitions, so changing front ends does not move a single word of them. &lt;code&gt;LanguageResource&lt;/code&gt; owns the text, &lt;code&gt;FormLayout&lt;/code&gt; owns the arrangement, and the ViewModel handles data binding.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. One FormLayout, one rendering layer per front end
&lt;/h2&gt;

&lt;p&gt;The layout layer does not know that UI frameworks exist, and that claim can be checked the same way &lt;a href="https://dev.to/jeff377/day-4-the-purpose-and-classification-of-definition-files-3i6n"&gt;The Purpose and Classification of Definition Files&lt;/a&gt; checked it: the definition layer package has an extremely short reference list, and no UI framework is anywhere on it.&lt;/p&gt;

&lt;p&gt;So what it turns into happens entirely inside each front end's own rendering layer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The column count is just a number in the layout. The desktop turns it into equal width columns on a layout control, and the web turns it into a few lines of browser grid style declarations.&lt;/li&gt;
&lt;li&gt;A list layout is handed to a grid control to draw on the desktop, and laid out as an HTML table on the web. The columns, the order and the widths both sides read are the same ones.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The case study's four front ends are put together exactly that way. Desktop, browser, iOS and Android share one UI project, and each platform entry point is down to startup and the few files of the platform shell.&lt;/p&gt;

&lt;p&gt;Writing a rendering layer sounds vague. Taken apart it is four things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Arrange the two kinds of section&lt;/li&gt;
&lt;li&gt;Map control kinds onto this framework's native controls&lt;/li&gt;
&lt;li&gt;Bind the controls to the ViewModel&lt;/li&gt;
&lt;li&gt;Decide how it reshapes on a narrow screen&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of those is easy, but all of them are finite, and once they are done this front end runs every form.&lt;/p&gt;

&lt;p&gt;How to communicate with the back end is not on that list, because that part does not get rewritten. Every front end goes through the same client layer, so one more front-end framework moves not one line on the server. That client layer is the subject of &lt;a href="https://medium.com/@jeff377/day-17-the-connector-and-the-convergence-of-client-responsibilities-1a2156273b60" rel="noopener noreferrer"&gt;The Connector and the Convergence of Client Responsibilities&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The rendering layer those four share is &lt;code&gt;Bee.UI.Avalonia&lt;/code&gt;, and even the browser front end is the same code compiled to run there. Supporting one more front end costs one rendering layer. Not zero.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. This layer stands on its own because it expires
&lt;/h2&gt;

&lt;p&gt;The comparison in &lt;a href="https://dev.to/jeff377/day-1-why-erp-needs-a-definition-driven-framework-1c2b"&gt;Why ERP Needs a Definition-Driven Framework&lt;/a&gt; had a row for what a new front-end generation costs. Today makes good on it.&lt;/p&gt;

&lt;p&gt;Packaged ERP and HRM products have life cycles of ten or twenty years and up, and over that time the system has to grow a different look with each turn of front-end technology. Desktop applications, web, mobile apps: every time a generation arrives the question is the same one. Those hundreds of screens, do they all get rebuilt?&lt;/p&gt;

&lt;p&gt;If screens are written by hand one at a time, the answer is only ever rebuild, the volume is proportional to the number of forms, and it has nothing at all to do with whether the business changed. A company's order form gained two fields in ten years, and its screen has been rewritten three times.&lt;/p&gt;

&lt;p&gt;That is the question the split is there to answer. Structure is stable and follows the business; layout is volatile and follows the front-end generation.&lt;/p&gt;

&lt;p&gt;The two rhythms differ in the shape of the spending:&lt;/p&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;Rhythm of change&lt;/th&gt;
&lt;th&gt;Shape of the spending&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Structure&lt;/td&gt;
&lt;td&gt;Continuous&lt;/td&gt;
&lt;td&gt;Small amounts: a few more fields and a few changed rules a year, touching one or two forms each time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Layout&lt;/td&gt;
&lt;td&gt;Intermittent&lt;/td&gt;
&lt;td&gt;One large amount: nothing for ten years, then all of it at once&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Hand written screens tie both rhythms to the same file, so that once a decade large payment gets multiplied by the total number of forms every time. Once they are separated, what gets paid is a fixed cost, once.&lt;/p&gt;

&lt;p&gt;This is not free. Somebody has to write the rendering layer and get it right, and it is a point everything converges on: a hand written screen gets one screen wrong at a time, and the rendering layer gets all of them wrong at once. Whether it is worth it depends on how many forms there are. On a system with a thousand the gap is too wide to need calculating, and on a system with ten, writing them yourself is quicker.&lt;/p&gt;

&lt;p&gt;Do the layout files themselves expire? They do. The difference is that they are data. Changing a piece of data is not the same thing as changing screen code and then recompiling, repackaging and redeploying.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Narrow screens reshape themselves, with no second layout
&lt;/h2&gt;

&lt;p&gt;The usual approach is a separate layout for phones, so one form has two layouts, and the two evolve apart.&lt;/p&gt;

&lt;p&gt;This framework has the rendering layer reshape itself instead:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Single record screens: the master column count is pushed down to one, and detail sections switch from editing in place to tapping a row and opening a row edit form&lt;/li&gt;
&lt;li&gt;Lists: wide, it is a table with one row per record; narrow, it is one card per record, with each field taking a line on the card, caption on the left and value on the right&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;iOS&lt;/th&gt;
&lt;th&gt;Android&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&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%2Fvxjnklgghclyq0t2pbb3.png" alt="The Northwind order list on iOS" width="516" height="1023"&gt;&lt;/td&gt;
&lt;td&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%2Fwady1lqylswse82nnpz9.png" alt="The Northwind order list on Android" width="479" height="827"&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The fields on the card are not a second list written somewhere else. The wide table and the narrow card read the same list layout, the same set of columns in the same order, and the only difference is what it gets drawn as.&lt;/p&gt;

&lt;p&gt;There are not two definitions kept in sync, there is one definition with two ways of drawing it, and that is its most practical value. One more copy is one more chance of the two not lining up, and a list missing a column is usually only found when a user asks why the amount is not visible on their phone.&lt;/p&gt;

&lt;p&gt;Not one word of those reshaping decisions is written in the layout definition. The layout says which fields there are and how many columns they sit in; how narrow counts as narrow, and how details get edited once it is narrow, are the rendering layer's own judgment. The framework source leaves a comment right there: how details are edited is the UI layer's business, and the shared layout definition stays neutral.&lt;/p&gt;

&lt;p&gt;There is a reason the line is drawn here. The moment a layout definition starts describing what happens on a phone, it has written a set of assumptions about screens into the definition. Today's assumption is two widths, phone and desktop. The next generation may be some other shape, and changing an assumption written into definitions means changing a thousand of them.&lt;/p&gt;

&lt;p&gt;The cost: the same layout does not come out looking identical on different rendering layers. That is deliberate. Demanding pixel level sameness across front ends is demanding that every one of them give up its own platform conventions, and users expect platform conventions far more strongly than they expect sameness.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. The standard is set, so where is the flexibility
&lt;/h2&gt;

&lt;p&gt;The doubt raised in &lt;a href="https://dev.to/jeff377/day-3-the-northwind-case-study-eight-forms-and-a-custom-business-object-3i33"&gt;The Northwind Case Study: Eight Forms and a Custom Business Object&lt;/a&gt;, that a fully standardized flow leaves no room for flexibility, is sharper at this layer, because screens are what users look at every day. Screens come in two kinds, automatic layout and manual layout, and the part where data is connected is the same for both.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Where the screen comes from&lt;/th&gt;
&lt;th&gt;The route for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Automatic layout&lt;/td&gt;
&lt;td&gt;Goes through &lt;code&gt;FormLayout&lt;/code&gt;. A layout is produced once at design time and adjusted as needed after that: how many sections, how many columns, which field spans two, which ones are hidden, which column is read only, and which columns a detail shows. It is data, so nothing is recompiled when it changes&lt;/td&gt;
&lt;td&gt;Standard forms, which is to say the vast majority of them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Manual layout&lt;/td&gt;
&lt;td&gt;Placing controls yourself and deciding positions yourself, without going through the layout layer at all&lt;/td&gt;
&lt;td&gt;Shapes the layout vocabulary cannot arrange&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Manual layout does not shut the framework out. Once the controls are placed, filling in the table name and field name is enough for reading and writing values, change notification, maximum length and option lists to connect as usual. The only difference between the two is whether the screen was arranged or placed by hand. The binding part is identical.&lt;/p&gt;

&lt;p&gt;The layout vocabulary does not set out to describe every shape. It cannot, and it should not: dashboards, charts, drag and drop scheduling and maps all go through manual layout. Layout is where people most often ask for an exception to the line that a framework that can do anything has made no decisions for you. One more kind of section, one more style attribute, and one more kind of screen is covered. Every one added is one more thing every rendering layer has to deal with.&lt;/p&gt;

&lt;p&gt;The flexibility is in the granularity, not in the capability. There is no cliff between automatic and manual layout, because the part where data is connected is the same on both sides.&lt;/p&gt;




&lt;h2&gt;
  
  
  7. The case study: what the order layout says and does not say
&lt;/h2&gt;

&lt;p&gt;The case study's definition folder holds eight layouts, one per form, all produced at design time and adjusted afterwards. The order layout extracted earlier says this much:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One master section, two columns, one detail section&lt;/li&gt;
&lt;li&gt;The three relationship fields on the master and the one on the detail all use a lookup window editor, and the display columns they bring back no longer take cells of their own&lt;/li&gt;
&lt;li&gt;The amount column is marked read only in &lt;code&gt;FormSchema&lt;/code&gt; and came across when the layout was produced, so on screen it simply cannot be typed into&lt;/li&gt;
&lt;li&gt;Four front ends read the same one and grow four platform specific looks&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What the order layout does not say outweighs what it does. It does not say where each field ends up on screen, what a lookup window editor looks like, how things are arranged on a phone, which language the captions get translated into, or how details are edited on a narrow screen. Precisely because none of that is its responsibility, it translates onto four front ends.&lt;/p&gt;




&lt;h2&gt;
  
  
  What gets rewritten that day
&lt;/h2&gt;

&lt;p&gt;One layout definition growing into screens on four front ends: the real content of that sentence is not that screens can be described by definitions too. Being describable is only the entry ticket. The content is the sum of three things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;This layer's vocabulary is small, small enough for every front end to translate, and small enough that hundreds of forms look alike&lt;/li&gt;
&lt;li&gt;It does not know what it will be translated into, so one more front end means one more rendering layer rather than going back to edit definitions&lt;/li&gt;
&lt;li&gt;It is the part of the definition set most likely to expire, and when it does expire what gets replaced is the rendering layer, not the structure&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I do not know what front ends will exist in ten years, and twenty years ago nobody guessed things would look like this. There is only one thing you can arrange in advance: that when that day comes, what has to be rewritten is finite, and unrelated to how many forms there are.&lt;/p&gt;

&lt;p&gt;The next post, &lt;a href="https://medium.com/@jeff377/declarative-business-rules-and-computed-fields-0e1fc9381f51" rel="noopener noreferrer"&gt;Declarative Business Rules and Computed Fields&lt;/a&gt;, covers computed fields and rules that run before saving, and how far both can be pushed into declarations.&lt;/p&gt;




&lt;p&gt;📚 Part of the thirty-post series &lt;a href="https://dev.to/jeff377/erp-architects-notes-designing-a-definition-driven-framework-series-index-4ln7"&gt;ERP Architect's Notes: Designing a Definition-Driven Framework&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;📘 Original article on HackMD, in Traditional Chinese:&lt;br&gt;
👉 &lt;a href="https://hackmd.io/@jeff377/day-07-one-layout-many-uis" rel="noopener noreferrer"&gt;https://hackmd.io/@jeff377/day-07-one-layout-many-uis&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;📢 You are welcome to republish this, please credit the source.&lt;br&gt;
📬 Follow for more engineering notes:&lt;br&gt;
&lt;a href="https://www.facebook.com/profile.php?id=61574839666569" rel="noopener noreferrer"&gt;Facebook&lt;/a&gt; ｜ &lt;a href="https://hackmd.io/@jeff377" rel="noopener noreferrer"&gt;HackMD&lt;/a&gt; ｜ &lt;a href="https://github.com/jeff377" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; ｜ &lt;a href="https://www.nuget.org/profiles/jeff377" rel="noopener noreferrer"&gt;NuGet&lt;/a&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>dotnet</category>
      <category>erp</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Day 6: TableSchema and Database Structure Upgrades</title>
      <dc:creator>Jeff Tsai</dc:creator>
      <pubDate>Fri, 30 Oct 2026 00:31:36 +0000</pubDate>
      <link>https://dev.to/jeff377/day-6-tableschema-and-database-structure-upgrades-25fn</link>
      <guid>https://dev.to/jeff377/day-6-tableschema-and-database-structure-upgrades-25fn</guid>
      <description>&lt;p&gt;Yesterday's SQL was all about accessing data. Today is about something else: where those tables came from in the first place.&lt;/p&gt;

&lt;p&gt;Which columns a table has, how long each one is, and which ones get an index, are authoritative in &lt;code&gt;TableSchema&lt;/code&gt;. The physical structure in the database is a product made from it. This is the layer where that is hardest to honor, because the database already contains data, and data does not change shape just because a definition did.&lt;/p&gt;

&lt;p&gt;This post covers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Why &lt;code&gt;TableSchema&lt;/code&gt; is separate from &lt;code&gt;FormSchema&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Why an upgrade compares the current state rather than running generated scripts&lt;/li&gt;
&lt;li&gt;Why compare, plan and execute are three separate steps&lt;/li&gt;
&lt;li&gt;How to work out how long this upgrade will take before starting it&lt;/li&gt;
&lt;li&gt;ALTER or rebuild, and who decides&lt;/li&gt;
&lt;li&gt;Five stages, each in its own transaction, and how a failure is cleaned up&lt;/li&gt;
&lt;li&gt;The things deliberately not done, and the principle they share&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  1. Why &lt;code&gt;TableSchema&lt;/code&gt; is a definition of its own
&lt;/h2&gt;

&lt;p&gt;The two care about different things:&lt;/p&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;&lt;code&gt;FormSchema&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;TableSchema&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What it cares about&lt;/td&gt;
&lt;td&gt;Business semantics: what a field is called, what type it is, whether it is required, which form it points at&lt;/td&gt;
&lt;td&gt;Physical storage: how long a column is, how many decimal places it holds, which columns get indexes and whether those are unique&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Who decides&lt;/td&gt;
&lt;td&gt;The people who understand the business&lt;/td&gt;
&lt;td&gt;The people who understand this database&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The same order number looks like this in the case study's &lt;a href="https://github.com/jeff377/bee-northwind-avalonia/blob/main/Define/TableSchema/company/ft_order.TableSchema.xml" rel="noopener noreferrer"&gt;&lt;code&gt;ft_order.TableSchema.xml&lt;/code&gt;&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;DbField&lt;/span&gt; &lt;span class="na"&gt;FieldName=&lt;/span&gt;&lt;span class="s"&gt;"sys_id"&lt;/span&gt; &lt;span class="na"&gt;Caption=&lt;/span&gt;&lt;span class="s"&gt;"Order No"&lt;/span&gt; &lt;span class="na"&gt;DbType=&lt;/span&gt;&lt;span class="s"&gt;"String"&lt;/span&gt; &lt;span class="na"&gt;Length=&lt;/span&gt;&lt;span class="s"&gt;"20"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;DbTableIndex&lt;/span&gt; &lt;span class="na"&gt;Name=&lt;/span&gt;&lt;span class="s"&gt;"uk_{0}"&lt;/span&gt; &lt;span class="na"&gt;Unique=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;IndexFields&amp;gt;&amp;lt;IndexField&lt;/span&gt; &lt;span class="na"&gt;FieldName=&lt;/span&gt;&lt;span class="s"&gt;"sys_id"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&amp;lt;/IndexFields&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/DbTableIndex&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;{0}&lt;/code&gt; in the index name is where the table name goes, producing &lt;code&gt;uk_ft_order&lt;/code&gt;. An earlier post, &lt;a href="https://dev.to/jeff377/day-3-the-northwind-case-study-eight-forms-and-a-custom-business-object-3i33"&gt;The Northwind Case Study: Eight Forms and a Custom Business Object&lt;/a&gt;, said the framework guarantees the uniqueness of &lt;code&gt;sys_id&lt;/code&gt;, and this unique index is where that guarantee lives.&lt;/p&gt;

&lt;p&gt;The practical return on splitting them: tuning an index, changing a precision, or adding a composite index for one report touches only &lt;code&gt;TableSchema&lt;/code&gt;. &lt;code&gt;FormSchema&lt;/code&gt; is not involved, and neither is whoever originally wrote that form.&lt;/p&gt;

&lt;p&gt;As mentioned yesterday, this file does not have to be written from scratch. The framework can produce a matching &lt;code&gt;TableSchema&lt;/code&gt; straight from a &lt;code&gt;FormSchema&lt;/code&gt;. After that the two are maintained separately, which is what gives those adjustments somewhere to live.&lt;/p&gt;

&lt;h3&gt;
  
  
  This definition contains no database's type names
&lt;/h3&gt;

&lt;p&gt;What is written above is &lt;code&gt;DbType="String" Length="20"&lt;/code&gt;, not &lt;code&gt;nvarchar(20)&lt;/code&gt;, and the whole file is like that throughout.&lt;/p&gt;

&lt;p&gt;The types a developer can write are a set the framework provides. The common ones are string, long text, small integer, integer, big integer, decimal, currency, date, datetime, boolean, &lt;code&gt;Guid&lt;/code&gt;, binary and auto number. Which native type each becomes is decided at the moment a statement is generated, and the databases differ more than you would expect:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Framework type&lt;/th&gt;
&lt;th&gt;SQL Server&lt;/th&gt;
&lt;th&gt;PostgreSQL&lt;/th&gt;
&lt;th&gt;MySQL&lt;/th&gt;
&lt;th&gt;Oracle&lt;/th&gt;
&lt;th&gt;SQLite&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Guid&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;uniqueidentifier&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;uuid&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CHAR(36)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;RAW(16)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;UUID&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Boolean&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;bit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;boolean&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;TINYINT(1)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NUMBER(1)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BOOLEAN&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Currency&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;decimal(19,4)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;numeric(19,4)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DECIMAL(19,4)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NUMBER(19,4)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NUMERIC(19,4)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The same &lt;code&gt;Guid&lt;/code&gt; column is a built in type in one, text in another and binary in a third. Folding that decision into the mechanism buys four things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Whoever writes the definition does not have to become an expert in this database first. Picking a native type takes experience. Using a floating point type for money, storing dates as text, or choosing too little precision for a timestamp are exactly where senior and junior diverge, and once it lands it grows into the data. The framework collapses the options into a set of types that talk about meaning, and the wrong one is not on the list at all.&lt;/li&gt;
&lt;li&gt;The same kind of column looks identical across the whole system. You will not have a &lt;code&gt;Guid&lt;/code&gt; stored as text in one table and as binary in another, and discover it when a cross table query refuses to line up.&lt;/li&gt;
&lt;li&gt;One definition can run on several databases. Which ones a packaged product supports is stated openly, and the customer picks: their existing environment, the people who can maintain it, and the license fee a commercial database keeps charging. A short list is a whole category of customers you do not have.&lt;/li&gt;
&lt;li&gt;Changing which native type a framework type maps to means editing one mapping table, not a thousand definitions. Writing native types into definitions would mean copying that decision a thousand times the moment it is written.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Comparison gets simpler for the same reason. Columns read back from the database are translated into framework types first, then compared against the definition. The definition says &lt;code&gt;Guid&lt;/code&gt;, MySQL reports &lt;code&gt;CHAR(36)&lt;/code&gt;, translating back gives &lt;code&gt;Guid&lt;/code&gt;, and identical means no change.&lt;/p&gt;

&lt;p&gt;That type set is deliberately narrow. Vendor specific features are not in it, and using one means going outside definitions. Being able to express less buys the same definition holding everywhere.&lt;/p&gt;

&lt;h3&gt;
  
  
  The cost: the two have to line up
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://dev.to/jeff377/day-5-how-formschema-drives-sql-4b4f"&gt;How FormSchema Drives SQL&lt;/a&gt; covered CRUD statements being assembled from &lt;code&gt;FormSchema&lt;/code&gt;'s field set, while table creation and upgrades go through &lt;code&gt;TableSchema&lt;/code&gt;. So:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A field added to &lt;code&gt;FormSchema&lt;/code&gt; but forgotten in &lt;code&gt;TableSchema&lt;/code&gt; means the column is not in the database, while the save statement insists on writing it, and the error surfaces at run time&lt;/li&gt;
&lt;li&gt;The other direction, an extra column, is harmless. It gets created, and the form path never touches it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nothing reconciles those two definitions at run time. What holds it together is the order of edits: the field goes into &lt;code&gt;FormSchema&lt;/code&gt; first, and &lt;code&gt;TableSchema&lt;/code&gt; follows with the physical column. When &lt;a href="https://dev.to/jeff377/day-4-the-purpose-and-classification-of-definition-files-3i6n"&gt;The Purpose and Classification of Definition Files&lt;/a&gt; described that order it read like a habit. It is the operational version of this cost. There is a build time gate that watches for exactly this, covered in &lt;a href="https://medium.com/@jeff377/day-9-data-and-definitions-split-apart-so-forms-need-no-types-of-their-own-5e76d8564d72" rel="noopener noreferrer"&gt;Data and Definitions Split Apart, So Forms Need No Types of Their Own&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. An upgrade is not a generated script, it is a comparison against the current state
&lt;/h2&gt;

&lt;p&gt;The usual approach is migrations: change the model, have a tool generate a script, commit it, execute in order, and have the database record which version it reached.&lt;/p&gt;

&lt;p&gt;This framework has no scripts and no version numbers. Every upgrade asks the database what it looks like right now, compares that against the definition, computes the difference, and executes it.&lt;/p&gt;

&lt;p&gt;The difference shows up in three places:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No need to know which version this database last stopped at&lt;/li&gt;
&lt;li&gt;No need to guarantee every version's script ran, in order&lt;/li&gt;
&lt;li&gt;Someone having edited the database by hand does not throw everything afterwards out of alignment, because the next comparison sees the edited state&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What that buys is a very short contract for success:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;When an upgrade returns successfully, the database has at least every column in the definition, with matching type, length and nullability.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Being safe to rerun is a consequence of the same thing. On a second run the current state is already the upgraded one, so the computed difference is empty. That property is used again when failure handling comes up.&lt;/p&gt;

&lt;p&gt;Three costs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Comparison has to ask the database about its own structure, and every vendor asks differently, so that part is written once per database&lt;/li&gt;
&lt;li&gt;"Drop a column" cannot be expressed. A column absent from the definition is simply ignored, which the seventh section returns to&lt;/li&gt;
&lt;li&gt;With no script, there is no file saying what this deployment will do to the database, available for review. On the migration route that script appears in the change list, and an experienced pair of eyes can see what it touches at a glance. Here the change list is a definition diff, where "a column went from 50 to 100" is readable but "does that trigger a rebuild" has to be asked separately&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The third cost does have an answer, the compute without executing usage in the fourth section, except that the deployment process has to reach for it. It does not surface on its own.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Compare, plan, execute, cut into three
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Produces&lt;/th&gt;
&lt;th&gt;Touches the database?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Compare&lt;/td&gt;
&lt;td&gt;A structured difference: which columns to add, which columns changed definition, which one is being renamed, which indexes to create or drop. There is no SQL in it&lt;/td&gt;
&lt;td&gt;Reads&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plan&lt;/td&gt;
&lt;td&gt;How this will be done: the mode, how many stages, which statements per stage, and any warnings&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Execute&lt;/td&gt;
&lt;td&gt;Actually runs&lt;/td&gt;
&lt;td&gt;Writes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The main reason for cutting it into three is not tidy layering. It is that the middle product is meant to be read by a person.&lt;/p&gt;

&lt;p&gt;All three are called by the application. In production, the deployment tool runs through every table registered in &lt;code&gt;DbCategorySettings&lt;/code&gt; at release time. The entry point is &lt;code&gt;TableSchemaBuilder&lt;/code&gt;, and the case study's version is in &lt;a href="https://github.com/jeff377/bee-northwind-avalonia/blob/main/Bee.Northwind.Server/NorthwindSchemaSeeder.cs" rel="noopener noreferrer"&gt;&lt;code&gt;NorthwindSchemaSeeder.cs&lt;/code&gt;&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;TableSchemaBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;defineAccess&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;connectionManager&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Tables&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TableName&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one &lt;code&gt;Execute&lt;/code&gt; is the three steps run end to end. Computing without executing means taking them apart, which is called a dry run: &lt;code&gt;CompareToDiff&lt;/code&gt; for the difference and &lt;code&gt;TableUpgradeOrchestrator.Plan&lt;/code&gt; for the plan, then decide whether to go further.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Run the plan first and you know how long this will take
&lt;/h2&gt;

&lt;p&gt;The plan computes one of four modes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;Impact&lt;/th&gt;
&lt;th&gt;What to do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No change&lt;/td&gt;
&lt;td&gt;No impact&lt;/td&gt;
&lt;td&gt;Nothing to do&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Create table&lt;/td&gt;
&lt;td&gt;A new table with no data&lt;/td&gt;
&lt;td&gt;Just run it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Alter table&lt;/td&gt;
&lt;td&gt;Seconds, with at most a brief lock on a single column&lt;/td&gt;
&lt;td&gt;It can run during normal hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rebuild&lt;/td&gt;
&lt;td&gt;The whole table's data is moved and the table is locked throughout&lt;/td&gt;
&lt;td&gt;Schedule a maintenance window and estimate the move time first&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A rebuild works by creating a temporary table, moving the data in bulk, dropping the old table and renaming the temporary one. On a table with tens of millions of rows that can take anywhere from tens of minutes to hours. So what to look at before deployment is not the SQL, it is the mode. Do a dry run, see whether the mode is rebuild, and only then print the SQL for human review.&lt;/p&gt;

&lt;p&gt;Besides the mode, the plan carries a list of warnings, such as which columns are being narrowed. So there are two things to read: the mode decides whether you need downtime, and the warnings decide whether you need to go check the data first. This question used to be answered by experience, as in asking whoever is more senior. Now it is a return value, and it can be written into a deployment script.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. ALTER or rebuild, decided by the framework
&lt;/h2&gt;

&lt;p&gt;The framework sorts column types into coarse families, being string, numeric, boolean, datetime, &lt;code&gt;Guid&lt;/code&gt; and binary, with auto number in a class of its own. The mapping rules are short:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;This kind of change&lt;/th&gt;
&lt;th&gt;Route&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Adding a column, adding an index, dropping an index&lt;/td&gt;
&lt;td&gt;ALTER&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A change within one family, such as lengthening a string or moving from integer to big integer&lt;/td&gt;
&lt;td&gt;ALTER&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nullability and default values&lt;/td&gt;
&lt;td&gt;ALTER&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Crossing families, such as string to numeric, or anything to boolean&lt;/td&gt;
&lt;td&gt;Rebuild&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Turning auto number on or off&lt;/td&gt;
&lt;td&gt;Rebuild&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Then it checks each change in turn. If all of them can ALTER, it alters. If a single one needs a rebuild, the whole table is rebuilt. If a single one is something this database cannot do, it aborts and reports, rather than doing half of it.&lt;/p&gt;

&lt;p&gt;This is deliberately not offered as a choice, because it has exactly one right answer and that answer is determined by the content of the change. Making it an option only hands something computable over to guesswork, and a wrong guess costs data.&lt;/p&gt;

&lt;p&gt;The families are coarse on purpose, erring on the safe side. Judging something alterable when it actually needs a rebuild, and discovering that halfway through execution, is far worse than spending one more rebuild's worth of time. Differences in vendor capability are absorbed the same way. The extreme case is SQLite, whose &lt;code&gt;ALTER TABLE&lt;/code&gt; can only add, rename and drop columns. Change a type, a nullability or a default and the whole table has to be rebuilt. The same definition and the same change is a seconds long ALTER on SQL Server and a rebuild on SQLite, and the calling code is identical.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. Five stages, each in its own transaction
&lt;/h2&gt;

&lt;p&gt;On the ALTER route the SQL is not sent in one go. It is cut into ordered stages, each in its own transaction:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Drop the indexes whose definitions are about to change&lt;/li&gt;
&lt;li&gt;Modify existing columns, being name, type, length and nullability&lt;/li&gt;
&lt;li&gt;Add new columns&lt;/li&gt;
&lt;li&gt;Create new indexes, and rebuild the ones dropped in step one&lt;/li&gt;
&lt;li&gt;Sync the column descriptions&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There is no other possible order. A column cannot be modified while an index sits on it, so the index has to get out of the way and go back afterwards.&lt;/p&gt;

&lt;p&gt;If a stage fails, that stage rolls back, the rest do not run, and the exception propagates. Stages already committed are not rolled back. That sounds dangerous, so why not wrap the whole thing in one transaction? Because for statements like creating and altering tables, wholesale rollback is not reliable on most databases, and some do not enroll them in a transaction at all. Wrapping them buys a promise that looks safe and does not hold.&lt;/p&gt;

&lt;p&gt;So the framework does not make that promise. It allows stopping partway and cleans up by rerunning. The property held over from the second section is used here: fix the cause and run again, and the completed stages compare as no change and are skipped automatically. Being safe to rerun is more practical than being able to roll back. A flow that reruns safely lets you fix the problem at three in the morning and press the button again. One that claims wholesale rollback but partially takes effect only leaves you unsure where you are.&lt;/p&gt;




&lt;h2&gt;
  
  
  7. The things deliberately not done
&lt;/h2&gt;

&lt;p&gt;For an automated upgrade mechanism, the boundary of its capability matters more than the capability, because it is automatic, and nobody shouts stop at the cut that goes too far.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No dropping columns. A column present in the database and absent from the definition is always kept. The reason is that it is not necessarily garbage. It could have been added by another system, or be an extension column not yet folded into the definition, and a deletion cannot be taken back.&lt;/li&gt;
&lt;li&gt;No foreign keys, and no touching triggers or views. Business rules do not go into the database, which is the same decision carried through.&lt;/li&gt;
&lt;li&gt;Narrowing a column is refused by default. It is honored only if that option is explicitly turned on, and even then it is recorded as a warning. The reason is that silent truncation is the worst failure mode: it reports nothing, it just cuts a piece off the data, and it is usually discovered months later when someone asks why half a line of this address is missing.&lt;/li&gt;
&lt;li&gt;Renaming a column has to be stated. The framework does not guess that something is probably a rename. To rename, mark the old name in the definition. It also guarantees only a single hop, and a chain of renames accumulated across several versions is not supported.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A simpler way to say the no dropping rule: the database may have more than the definition, never less. "Definitions are the single source of truth" has a precise meaning at this layer. It guarantees that everything declared in the definition is in the database. It does not guarantee that there is nothing else there. Demanding the reverse, that the database equal the definition exactly, costs the framework being given the authority to delete columns it does not recognize, and nobody wants to hand it that.&lt;/p&gt;

&lt;p&gt;Not creating foreign keys brings an incidental benefit. The circle between department and employee from an earlier post is not even a question on the table creation side. With no foreign keys there is no ordering, and tables are created one at a time. A world with foreign key constraints has to work out which comes first. This one does not.&lt;/p&gt;

&lt;p&gt;Together those rules are one principle: automation may add, and subtraction is left to people. Add a column wrongly and you add another one to fix it. Drop a column wrongly and the data is gone.&lt;/p&gt;




&lt;h2&gt;
  
  
  Back to Northwind
&lt;/h2&gt;

&lt;p&gt;The case study creates its tables at startup, running the loop from the third section, comparing and upgrading each registered table in turn.&lt;/p&gt;

&lt;p&gt;So adding a table does not change that code. Two of the six places counted in the previous post live here, being the new &lt;code&gt;TableSchema&lt;/code&gt; and the row in &lt;code&gt;DbCategorySettings&lt;/code&gt;, and table creation follows.&lt;/p&gt;

&lt;p&gt;The first run is a fresh creation. Every run after that is a comparison, and with no changes it does nothing. Change a column length or add an index during development and the next startup catches up, with nobody having hand written a create or alter statement in between.&lt;/p&gt;




&lt;h2&gt;
  
  
  The boundary of the truth
&lt;/h2&gt;

&lt;p&gt;Back to the opening sentence: the definition is authoritative, and the structure in the database is the product. That says more than "the framework generates your create statements." Generating statements is the easy part. The real content is these things together. The structure is described once, and the description does not mention which database vendor it is for. To know what it should look like right now, reading the definition is enough. And when the two disagree, it is the database that gets changed.&lt;/p&gt;

&lt;p&gt;This layer has one thing the earlier ones did not: there is data here, so the word truth needs a boundary. The database may have more than the definition, never less. At other layers, definitions being the single source of truth is about clearing away duplication. At this layer, it is about knowing what it must not touch.&lt;/p&gt;

&lt;p&gt;The next post, &lt;a href="https://medium.com/@jeff377/formlayout-and-screen-generation-for-multiple-front-ends-3eee75c07a1b" rel="noopener noreferrer"&gt;FormLayout and Screen Generation for Multiple Front Ends&lt;/a&gt;, returns to the screen side: how one definition grows into screens on four front ends.&lt;/p&gt;




&lt;p&gt;📚 Part of the thirty-post series &lt;a href="https://dev.to/jeff377/erp-architects-notes-designing-a-definition-driven-framework-series-index-4ln7"&gt;ERP Architect's Notes: Designing a Definition-Driven Framework&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;📘 Original article on HackMD, in Traditional Chinese:&lt;br&gt;
👉 &lt;a href="https://hackmd.io/@jeff377/day-06-schema-single-truth" rel="noopener noreferrer"&gt;https://hackmd.io/@jeff377/day-06-schema-single-truth&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;📢 You are welcome to republish this, please credit the source.&lt;br&gt;
📬 Follow for more engineering notes:&lt;br&gt;
&lt;a href="https://www.facebook.com/profile.php?id=61574839666569" rel="noopener noreferrer"&gt;Facebook&lt;/a&gt; ｜ &lt;a href="https://hackmd.io/@jeff377" rel="noopener noreferrer"&gt;HackMD&lt;/a&gt; ｜ &lt;a href="https://github.com/jeff377" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; ｜ &lt;a href="https://www.nuget.org/profiles/jeff377" rel="noopener noreferrer"&gt;NuGet&lt;/a&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>dotnet</category>
      <category>erp</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Day 5: How FormSchema Drives SQL</title>
      <dc:creator>Jeff Tsai</dc:creator>
      <pubDate>Thu, 29 Oct 2026 04:56:27 +0000</pubDate>
      <link>https://dev.to/jeff377/day-5-how-formschema-drives-sql-4b4f</link>
      <guid>https://dev.to/jeff377/day-5-how-formschema-drives-sql-4b4f</guid>
      <description>&lt;p&gt;The previous post, &lt;a href="https://dev.to/jeff377/day-4-the-purpose-and-classification-of-definition-files-3i6n"&gt;The Purpose and Classification of Definition Files&lt;/a&gt;, laid the definition files out one by one, and &lt;code&gt;FormSchema&lt;/code&gt; is the hub among them. Today, a concrete question: when a form queries and when it saves, where do those SQL statements come from?&lt;/p&gt;

&lt;p&gt;This post covers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The six places a new form touches, with zero lines of code&lt;/li&gt;
&lt;li&gt;Querying: the join list and paging, both decided on every request&lt;/li&gt;
&lt;li&gt;Saving: how a set of fields becomes INSERT, UPDATE and DELETE&lt;/li&gt;
&lt;li&gt;Why this is not an ORM, and why the missing pieces are not omissions&lt;/li&gt;
&lt;li&gt;The difference between a thousand definitions and a thousand classes&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  1. One more form, six places to touch
&lt;/h2&gt;

&lt;p&gt;The first post, &lt;a href="https://dev.to/jeff377/day-1-why-erp-needs-a-definition-driven-framework-1c2b"&gt;Why ERP Needs a Definition-Driven Framework&lt;/a&gt;, counted adding one field to an existing form. This counts a form that is entirely new:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What to touch&lt;/th&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/jeff377/bee-library/blob/main/src/Bee.Definition/Forms/FormSchema.cs" rel="noopener noreferrer"&gt;&lt;code&gt;FormSchema&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;A new one, with fields, relationships, details and rules&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/jeff377/bee-library/blob/main/src/Bee.Definition/Database/TableSchema.cs" rel="noopener noreferrer"&gt;&lt;code&gt;TableSchema&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;A new one per physical table, with columns, lengths and indexes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/jeff377/bee-library/blob/main/src/Bee.Definition/Layouts/FormLayout.cs" rel="noopener noreferrer"&gt;&lt;code&gt;FormLayout&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;A new one, arranging those fields on screen&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/jeff377/bee-library/blob/main/src/Bee.Definition/Settings/DbCategorySettings/DbCategorySettings.cs" rel="noopener noreferrer"&gt;&lt;code&gt;DbCategorySettings&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;One row, saying which category this table belongs to and which database carries it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/jeff377/bee-library/blob/main/src/Bee.Definition/Settings/ProgramSettings/ProgramSettings.cs" rel="noopener noreferrer"&gt;&lt;code&gt;ProgramSettings&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;One row, the program identifier and a display name&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/jeff377/bee-library/blob/main/src/Bee.Definition/Settings/MenuSettings/MenuSettings.cs" rel="noopener noreferrer"&gt;&lt;code&gt;MenuSettings&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;One entry, where the user clicks in&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three new files, three added rows, zero lines of code, plus one more &lt;code&gt;TableSchema&lt;/code&gt; if the master has details. The only one that has to be written from scratch is &lt;code&gt;FormSchema&lt;/code&gt;. The other two can be derived from it and then adjusted. Seven of the eight forms in the case study grew exactly this way.&lt;/p&gt;

&lt;p&gt;None of those six is a restatement. Each answers a different question: what this form has, what the physical storage looks like, how it is arranged on screen, which database it belongs to, who executes it, and where you click in.&lt;/p&gt;

&lt;p&gt;Given that not a line of code was written, what runs when you press query, or press save? The same &lt;code&gt;FormSchema&lt;/code&gt; holds up both, using completely different parts of itself:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Querying uses the relationship declarations, to assemble joins&lt;/li&gt;
&lt;li&gt;Saving uses the field set, to assemble INSERT, UPDATE and DELETE&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  2. Querying: the join is looked up, not written
&lt;/h2&gt;

&lt;p&gt;An earlier post, &lt;a href="https://dev.to/jeff377/day-3-the-northwind-case-study-eight-forms-and-a-custom-business-object-3i33"&gt;The Northwind Case Study: Eight Forms and a Custom Business Object&lt;/a&gt;, covered relationships being declared between forms, with no table name in the declaration. What the framework does with it is short:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RelationProgId (the program identifier)
  -&amp;gt; fetch that program's FormSchema
    -&amp;gt; take its master table
      -&amp;gt; take the physical table name
        -&amp;gt; join on that table's sys_rowid
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That extra hop returns two things.&lt;/p&gt;

&lt;p&gt;One when writing. What you declare is that an order points at a customer, not that some column of &lt;code&gt;ft_order&lt;/code&gt; points at the primary key of &lt;code&gt;ft_customer&lt;/code&gt;. When relationships live between tables, the author has to translate the business relationship into storage terms first. Between forms, what you write down is the thing you had in mind, with one less translation in between.&lt;/p&gt;

&lt;p&gt;One when changing. The physical table name is written only inside that form's own structure, and other forms point at it with a program identifier. Renaming the customer table means editing the customer's definition, and every form pointing at it stays untouched. With table names hard coded everywhere, that becomes a global search that nobody can certify as complete.&lt;/p&gt;

&lt;h3&gt;
  
  
  The join list is computed fresh every time
&lt;/h3&gt;

&lt;p&gt;The order list shows six columns, and the query the framework assembles is:&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;SELECT&lt;/span&gt; &lt;span class="n"&gt;A&lt;/span&gt;&lt;span class="p"&gt;.[&lt;/span&gt;&lt;span class="n"&gt;sys_id&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;A&lt;/span&gt;&lt;span class="p"&gt;.[&lt;/span&gt;&lt;span class="n"&gt;order_date&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
       &lt;span class="n"&gt;B&lt;/span&gt;&lt;span class="p"&gt;.[&lt;/span&gt;&lt;span class="n"&gt;sys_name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ref_customer_name&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="k"&gt;C&lt;/span&gt;&lt;span class="p"&gt;.[&lt;/span&gt;&lt;span class="n"&gt;sys_name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ref_employee_name&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
       &lt;span class="n"&gt;A&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="n"&gt;A&lt;/span&gt;&lt;span class="p"&gt;.[&lt;/span&gt;&lt;span class="n"&gt;total_amount&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ft_order&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;A&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ft_customer&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;B&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;A&lt;/span&gt;&lt;span class="p"&gt;.[&lt;/span&gt;&lt;span class="n"&gt;customer_rowid&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;B&lt;/span&gt;&lt;span class="p"&gt;.[&lt;/span&gt;&lt;span class="n"&gt;sys_rowid&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;st_employee&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;C&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;A&lt;/span&gt;&lt;span class="p"&gt;.[&lt;/span&gt;&lt;span class="n"&gt;employee_rowid&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;C&lt;/span&gt;&lt;span class="p"&gt;.[&lt;/span&gt;&lt;span class="n"&gt;sys_rowid&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The order master declares three relationships, and this statement joins two tables. The list does not show the shipper, so that table never enters.&lt;/p&gt;

&lt;p&gt;There is only one rule:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The framework first collects the fields this query will touch into one list, from three sources, being the fields to select, the fields appearing in conditions, and the fields used for sorting. Whichever relationship fields appear in that list decide which tables are joined.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So "used" is not the same as "displayed":&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Filtering the list by customer name joins the customer table for the WHERE clause, even though it is not among the displayed columns&lt;/li&gt;
&lt;li&gt;Opening a single record brings in all three joins, because all three columns are displayed&lt;/li&gt;
&lt;li&gt;Selecting only the master's own columns produces no joins at all&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Condition values always go through parameters and never into the SQL text.&lt;/p&gt;

&lt;p&gt;Nowhere is the answer to "which tables does an order join" stored. The cost is moving "what does this SQL look like" from build time to every single request, which adds a statement assembly step per query. It is the fixed overhead of this route, not something that can be optimized away.&lt;/p&gt;

&lt;p&gt;The same declaration is also at work on the screen side. A field with a relationship becomes a lookup window editor in that cell, picking one record from the source form. The declaration the join reads and the declaration the editor reads are the same one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Display columns do not exist in the table
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;ref_customer_name&lt;/code&gt; in that statement is an alias. The physical order table has only &lt;code&gt;customer_rowid&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;When the front end finishes picking, it writes the code and the name into the data it is holding, purely so the screen updates immediately&lt;/li&gt;
&lt;li&gt;Those columns are not written to the database on save, and the next read has the server join them in again&lt;/li&gt;
&lt;li&gt;The authoritative value is always on the query side&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So there is never a case of a customer changing its name and old orders still showing the old one. The cost is one join per display.&lt;/p&gt;

&lt;p&gt;Which is correct depends on whether the field wants the value as it was or the value as it is. A name for display wants the current one; the unit price agreed at the time wants the historical one, and that one should have been a column on the order itself anyway. This line has to be drawn by the framework first, or each form decides for itself and one system ends up with two behaviors.&lt;/p&gt;

&lt;h3&gt;
  
  
  Declared one level deep, executed several levels deep
&lt;/h3&gt;

&lt;p&gt;The employee form brings in the department name, and also the manager's name. Its relationship field points at department, and one of the mappings names &lt;code&gt;ref_manager_name&lt;/code&gt; as its source field, which on the department form is itself a relationship field pointing at employee, for the manager. So it has to go one level further down:&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;FROM&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;st_employee&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;A&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;st_department&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;B&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;A&lt;/span&gt;&lt;span class="p"&gt;.[&lt;/span&gt;&lt;span class="n"&gt;dept_rowid&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;B&lt;/span&gt;&lt;span class="p"&gt;.[&lt;/span&gt;&lt;span class="n"&gt;sys_rowid&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;st_employee&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;C&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;B&lt;/span&gt;&lt;span class="p"&gt;.[&lt;/span&gt;&lt;span class="n"&gt;manager_rowid&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;C&lt;/span&gt;&lt;span class="p"&gt;.[&lt;/span&gt;&lt;span class="n"&gt;sys_rowid&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;st_employee&lt;/code&gt; appears twice. A is the employee, C is their manager.&lt;/p&gt;

&lt;p&gt;Every form declares only the level it points at directly, and that level is how the business already talks about it, so whoever writes the definition does not have to be a developer to read it. The field of view is fixed at one level, the framework expands along the chain of forms recursively, and the SQL depth is unbounded. In a system with thousands of forms this is decisive. Relationships weave into a very large graph, and nobody needs to hold it in their head.&lt;/p&gt;

&lt;p&gt;If the department side changes its own relationship, the employee form does not change by a single character, and the next query picks up or drops a level on its own. Whoever writes the declaration describes only the business level relationship. How deep it reaches, and how many tables it touches, is the framework's problem.&lt;/p&gt;

&lt;h3&gt;
  
  
  The circular relationship is not a problem here
&lt;/h3&gt;

&lt;p&gt;The circle left in earlier, from department to employee and back, was already traversed by the statement above.&lt;/p&gt;

&lt;p&gt;It causes no problem, because queries are computed fresh every time. The most a circle leaves to decide here is how many levels this particular query joins, and the level count is decided by the requested fields, not by the shape of the model. When nobody asks for the manager's name, that level never appears.&lt;/p&gt;

&lt;p&gt;Generate strongly typed classes at build time instead, and the same circle becomes types referencing each other and a question of initialization order. One circle, and in a run time derivation world it is the depth of one query, while in a build time generation world it is a structural problem that has to be solved first.&lt;/p&gt;

&lt;h3&gt;
  
  
  Paging is computed fresh too
&lt;/h3&gt;

&lt;p&gt;Which page, how many rows, and whether to count the total are all sent by the caller, on the same object:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;bo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetList&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;GetListArgs&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;SelectFields&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"sys_id,order_date,ref_customer_name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;SortFields&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;SortField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"order_date"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SortDirection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Desc&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
    &lt;span class="n"&gt;Paging&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;PagingOptions&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Page&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PageSize&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;20&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 framework normalizes those values before using them. The page number and page size are clamped into a sensible range, and the &lt;code&gt;result.Paging&lt;/code&gt; that comes back is the set that actually took effect. The total is not counted by default; fetching one extra row is enough to know whether more follow, and an exact number costs one more COUNT statement. Paging always requires a sort, and if none is given the master's sequence column is supplied. Only when even that is absent does the caller have to say what it wants.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Saving: the field set is the write statements
&lt;/h2&gt;

&lt;p&gt;The framework builds a table description on the spot from the master's field set, and from each detail's, and all three write statements come out of it. The order's UPDATE:&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;Update&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ft_order&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;Set&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;sys_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;=@&lt;/span&gt;&lt;span class="n"&gt;sys_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;order_date&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;=@&lt;/span&gt;&lt;span class="n"&gt;order_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;customer_rowid&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;=@&lt;/span&gt;&lt;span class="n"&gt;customer_rowid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;Where&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;sys_rowid&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;=@&lt;/span&gt;&lt;span class="n"&gt;sys_rowid&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are four things going on in that statement:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The sequence column is not in it, because the database assigns it&lt;/li&gt;
&lt;li&gt;Display columns are not in it either, since the previous section covered their not having physical columns&lt;/li&gt;
&lt;li&gt;The condition is always &lt;code&gt;sys_rowid&lt;/code&gt;, and DELETE is the same&lt;/li&gt;
&lt;li&gt;The whole row is written, not only the columns that changed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fourth is clearest on details. A document has ten detail rows, the user edits one and deletes one, and the whole set is submitted. Only two statements reach the database, and the other eight rows are never touched. The UPDATE that does run writes every column of the edited row, not only the one that changed.&lt;/p&gt;

&lt;p&gt;When master and details arrive together, all changes are wrapped in one transaction, and the order of saving and deleting is exactly reversed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Saving: master first, details after&lt;/li&gt;
&lt;li&gt;Deleting: details first, master after&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The framework does not need to know what this document means to the business. It needs to know which table is the master, which are the details, and which column hangs the details back on the master.&lt;/p&gt;

&lt;p&gt;One more thing, and the naming makes it easy to get wrong: all three statements are produced from &lt;code&gt;FormSchema&lt;/code&gt;, not read out of &lt;code&gt;TableSchema&lt;/code&gt;. &lt;code&gt;TableSchema&lt;/code&gt; governs how the physical table is created, how lengths and indexes are tuned, and how the database keeps up with the definition, which is the next post's subject.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Why this is not an ORM
&lt;/h2&gt;

&lt;p&gt;This section is not saying ORMs are bad, nor comparing their merits. An ORM works well in the setting it was designed for. The problem is quantity, as an earlier post covered.&lt;/p&gt;

&lt;p&gt;The difference is not in how much each can do. It is in what sits at the two ends of the mapping:&lt;/p&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;ORM&lt;/th&gt;
&lt;th&gt;This mechanism&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;The application side&lt;/td&gt;
&lt;td&gt;Classes and properties&lt;/td&gt;
&lt;td&gt;A structural description that exists at run time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The database side&lt;/td&gt;
&lt;td&gt;Tables and columns&lt;/td&gt;
&lt;td&gt;Tables and columns&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Different starting points grow different things, and three things are missing here.&lt;/p&gt;

&lt;h3&gt;
  
  
  No entity classes
&lt;/h3&gt;

&lt;p&gt;Query results are not restored into objects. Data travels as rows, carrying its structure with it. With no classes there is no "one more table means one more class," and the multiply by a thousand problem from the first post simply does not arise at this layer. The cost is that compile time type checking goes with it, and &lt;a href="https://medium.com/@jeff377/day-9-data-and-definitions-split-apart-so-forms-need-no-types-of-their-own-5e76d8564d72" rel="noopener noreferrer"&gt;Data and Definitions Split Apart, So Forms Need No Types of Their Own&lt;/a&gt; covers how that is repaid.&lt;/p&gt;

&lt;h3&gt;
  
  
  No relationships expressed as properties, therefore no lazy loading
&lt;/h3&gt;

&lt;p&gt;There is no equivalent here of walking from an order to its customer to that customer's salesperson in one expression. Which fields you want has to be stated at query time, so there is no accident where touching one extra property fires one database round trip per row. In the other direction, wanting a column that was never declared means adding a mapping to the definition first.&lt;/p&gt;

&lt;h3&gt;
  
  
  Change tracking did not disappear, it moved
&lt;/h3&gt;

&lt;p&gt;An ORM holds loaded entities in a context object and compares properties at save time. Here there is nothing to compare. Each row records for itself whether it is new, edited, deleted or untouched, and an edited row keeps the old values of the changed columns too. The state is recorded on the row rather than per column, which is why the UPDATE in the previous section writes the whole row.&lt;/p&gt;

&lt;p&gt;Those three are not half finished work. This mechanism has no place for them to begin with. There are also a few limits of a different nature: joins are always single column equality against a physical table, and subqueries and non equality joins are not in scope. Those are not impossibilities. This route only governs the standardized form segment. Reports, statistics and batch imports run on a different track, writing their own SQL, and &lt;a href="https://medium.com/@jeff377/day-11-connections-transactions-and-query-conditions-in-the-data-access-layer-5aca98e186a4" rel="noopener noreferrer"&gt;Connections, Transactions and Query Conditions in the Data Access Layer&lt;/a&gt; takes that apart.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. A thousand definitions versus a thousand classes
&lt;/h2&gt;

&lt;p&gt;Thousands of business tables under an object mapping model are thousands of classes, plus their DTOs, mapping configuration and repositories. Those have to be compiled, maintained, and kept in step with the database structure, and no tool can fully take that last part over, because it involves what a column means to the business.&lt;/p&gt;

&lt;p&gt;The same thousands of tables under Definition-Driven are thousands of structurally identical pieces of data. The difference is not the count. It is who can process them in bulk.&lt;/p&gt;

&lt;p&gt;A definition is structured data, and that has three direct consequences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A batch script can sweep them. "Are the decimal places set correctly on every amount column" is one global search plus one test.&lt;/li&gt;
&lt;li&gt;Tools can produce them. &lt;code&gt;TableSchema&lt;/code&gt; and &lt;code&gt;FormLayout&lt;/code&gt; are both derived from &lt;code&gt;FormSchema&lt;/code&gt;, and that is a transformation program, not a code generation step followed by a compile.&lt;/li&gt;
&lt;li&gt;AI can work with them. Asked to produce an eleventh definition following the pattern of ten existing ones, what it reads is a document with relationships and rules already declared, not a pile of classes whose intent has to be inferred from code.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Checking can move earlier too. A thousand definitions will still contain mistakes, pointing at a form that does not exist, mapping to a column that does not exist, or misspelling a field name in a rule. The difference is that those are errors a program can find at build time, and the framework does find some of them, as a later post covers.&lt;/p&gt;

&lt;p&gt;A thousand wrong definitions are as bad as a thousand wrong classes. Being data does not make a definition automatically correct. What changes is how it gets fixed: edit one piece of data, rather than edit code and go through compilation, packaging and deployment again.&lt;/p&gt;

&lt;p&gt;Definitions have not left ordinary engineering practice either. They sit in the same source tree as the code, go into version control the same way, show which lines changed the same way, and go through review before merging the same way. How they are changed is different. How they are governed is not.&lt;/p&gt;

&lt;p&gt;One more item is not on the cost sheet at all: a thousand definitions share one set of conventions. Understanding how one form declares a relationship means understanding all of them, and whoever inherits the system does not have to learn each module's original author's habits one at a time. On a system maintained for ten or twenty years, where people have turned over several times and modules have been added in batches, that piece of work is paid again every time. That is how maintenance cost accumulates.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. Back to the order form
&lt;/h2&gt;

&lt;p&gt;The most complex form in the case study is one file in definition terms, &lt;a href="https://github.com/jeff377/bee-northwind-avalonia/blob/main/Define/FormSchema/Order.FormSchema.xml" rel="noopener noreferrer"&gt;&lt;code&gt;Order.FormSchema.xml&lt;/code&gt;&lt;/a&gt;: a master with one detail, three relationships on the master, one per detail row, three validation rules and one computed field, with how those last two are evaluated being the subject of &lt;a href="https://medium.com/@jeff377/declarative-business-rules-and-computed-fields-0e1fc9381f51" rel="noopener noreferrer"&gt;Declarative Business Rules and Computed Fields&lt;/a&gt;. Multiple relationships, master and detail, and a lookup window on every detail row, all declared, with no UI or CRUD code.&lt;/p&gt;

&lt;p&gt;It is also the one form that needed code. Its repository exists for two SQL statements, reading the order's state in the database and finding the highest order number this month. What the declarations cannot express, you write. The standardized segment is definition driven, and those two statements do not have to take a detour because of the framework. Inherit a repository, add them, done.&lt;/p&gt;

&lt;p&gt;Whether a form can avoid code does not depend on how clever the framework is. It depends on whether the framework recognized the part that is the same on every form and converged it into one mechanism. Which tables a query joins, and which statements a save issues, both fall inside that part.&lt;/p&gt;

&lt;p&gt;The next post, &lt;a href="https://medium.com/@jeff377/tableschema-and-database-structure-upgrades-cc9953a4863b" rel="noopener noreferrer"&gt;TableSchema and Database Structure Upgrades&lt;/a&gt;, is about how a &lt;code&gt;FormSchema&lt;/code&gt; becomes one, and how the physical database safely keeps up with the definition.&lt;/p&gt;




&lt;p&gt;📚 Part of the thirty-post series &lt;a href="https://dev.to/jeff377/erp-architects-notes-designing-a-definition-driven-framework-series-index-4ln7"&gt;ERP Architect's Notes: Designing a Definition-Driven Framework&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;📘 Original article on HackMD, in Traditional Chinese:&lt;br&gt;
👉 &lt;a href="https://hackmd.io/@jeff377/day-05-schema-drives-sql" rel="noopener noreferrer"&gt;https://hackmd.io/@jeff377/day-05-schema-drives-sql&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;📢 You are welcome to republish this, please credit the source.&lt;br&gt;
📬 Follow for more engineering notes:&lt;br&gt;
&lt;a href="https://www.facebook.com/profile.php?id=61574839666569" rel="noopener noreferrer"&gt;Facebook&lt;/a&gt; ｜ &lt;a href="https://hackmd.io/@jeff377" rel="noopener noreferrer"&gt;HackMD&lt;/a&gt; ｜ &lt;a href="https://github.com/jeff377" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; ｜ &lt;a href="https://www.nuget.org/profiles/jeff377" rel="noopener noreferrer"&gt;NuGet&lt;/a&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>dotnet</category>
      <category>erp</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Day 4: The Purpose and Classification of Definition Files</title>
      <dc:creator>Jeff Tsai</dc:creator>
      <pubDate>Wed, 28 Oct 2026 00:41:19 +0000</pubDate>
      <link>https://dev.to/jeff377/day-4-the-purpose-and-classification-of-definition-files-3i6n</link>
      <guid>https://dev.to/jeff377/day-4-the-purpose-and-classification-of-definition-files-3i6n</guid>
      <description>&lt;p&gt;"Definitions are the single source of truth" sounds like there is one file holding all of the truth. Open a project's definition directory and what you actually see is thirteen definition types. Some are a single file at the root, and some are one file per program, scattered through subdirectories.&lt;/p&gt;

&lt;p&gt;This post is a map. Every mechanism discussed later uses a few of these, so meeting them here means not having to stop and explain each time. At the end of the fourth section there is a quick reference covering all thirteen, marked with which post expands each one. If a name is unfamiliar later, come back here.&lt;/p&gt;

&lt;p&gt;This post covers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The three boot files: &lt;code&gt;SystemSettings&lt;/code&gt;, &lt;code&gt;DatabaseSettings&lt;/code&gt;, &lt;code&gt;DbCategorySettings&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;The three files for one form: &lt;code&gt;FormSchema&lt;/code&gt;, &lt;code&gt;TableSchema&lt;/code&gt;, &lt;code&gt;FormLayout&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Binding and navigation: &lt;code&gt;ProgramSettings&lt;/code&gt;, &lt;code&gt;MenuSettings&lt;/code&gt;, &lt;code&gt;PluginSettings&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;The four shared by every form: &lt;code&gt;LanguageResource&lt;/code&gt;, &lt;code&gt;PermissionModels&lt;/code&gt;, &lt;code&gt;CurrencySettings&lt;/code&gt;, &lt;code&gt;UnitSettings&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Four things all thirteen have in common: storage, caching, immutability, and what happens when one is missing&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each name below links to its source.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. The three boot files: what this deployment runs on
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Definition&lt;/th&gt;
&lt;th&gt;Stored as&lt;/th&gt;
&lt;th&gt;What it governs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/jeff377/bee-library/blob/main/src/Bee.Definition/Settings/SystemSettings/SystemSettings.cs" rel="noopener noreferrer"&gt;&lt;code&gt;SystemSettings&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;A single file&lt;/td&gt;
&lt;td&gt;Where the master key comes from, how the payload is packed and encrypted, and debug mode&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/jeff377/bee-library/blob/main/src/Bee.Definition/Settings/DatabaseSettings/DatabaseSettings.cs" rel="noopener noreferrer"&gt;&lt;code&gt;DatabaseSettings&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;A single file&lt;/td&gt;
&lt;td&gt;Which physical databases this deployment has, and how to connect to each&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/jeff377/bee-library/blob/main/src/Bee.Definition/Settings/DbCategorySettings/DbCategorySettings.cs" rel="noopener noreferrer"&gt;&lt;code&gt;DbCategorySettings&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;A single file&lt;/td&gt;
&lt;td&gt;Which category a table belongs to, and which database carries that category&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These three are the precondition for the system starting at all, and they are read in a fixed order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SystemSettings (names where the master key is)
  -&amp;gt; the master key decrypts the key that protects settings
    -&amp;gt; which decrypts the connection passwords in DatabaseSettings
      -&amp;gt; only then do the database identifiers in DbCategorySettings resolve to anything
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The order is not a convention. It is the only solution the dependencies allow. The reverse path does not exist: before a database can be reached, there is no opportunity to look a key up in a database.&lt;/p&gt;

&lt;p&gt;The first link in the chain has to be readable when nothing else has started, so it cannot be encrypted itself. That is why the master key is not stored inside &lt;code&gt;SystemSettings&lt;/code&gt;. That file records only where the key is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;MasterKeySource&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;Type&amp;gt;&lt;/span&gt;Environment&lt;span class="nt"&gt;&amp;lt;/Type&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;Value&amp;gt;&lt;/span&gt;BEE_MASTER_KEY&lt;span class="nt"&gt;&amp;lt;/Value&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/MasterKeySource&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The framework separates that one startup read accordingly: no service, no cache, straight off the file, because at that moment nothing else exists yet.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why the database concerns are two files
&lt;/h3&gt;

&lt;p&gt;They answer different questions:&lt;/p&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;&lt;code&gt;DatabaseSettings&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;DbCategorySettings&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What it answers&lt;/td&gt;
&lt;td&gt;Which databases this deployment has and how to connect&lt;/td&gt;
&lt;td&gt;Which category of data lands in which database&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What it is&lt;/td&gt;
&lt;td&gt;A fact about the deployment environment&lt;/td&gt;
&lt;td&gt;A fact about the application's structure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;When it changes&lt;/td&gt;
&lt;td&gt;Moving data centers, swapping database servers, repointing a test environment&lt;/td&gt;
&lt;td&gt;Adding a table or moving a body of data to a different database&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;How sensitive it is&lt;/td&gt;
&lt;td&gt;Holds connection passwords and needs encryption and access control&lt;/td&gt;
&lt;td&gt;Holds no secrets, and developers edit it daily&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Splitting them lets each side change on its own, and separates the sensitive from the everyday as a side effect.&lt;/p&gt;

&lt;p&gt;How many databases the data actually lands in, and how that relates to the table name prefix, is the subject of &lt;a href="https://medium.com/@jeff377/day-11-connections-transactions-and-query-conditions-in-the-data-access-layer-5aca98e186a4" rel="noopener noreferrer"&gt;Connections, Transactions and Query Conditions in the Data Access Layer&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The definition layer holds database settings without knowing databases exist
&lt;/h3&gt;

&lt;p&gt;A file recording connection strings, filed under the definition layer, looks like a layering violation. It is not, because what it holds is a description, not a connection.&lt;/p&gt;

&lt;p&gt;The definition layer package has an extremely short reference list. It does not know a database exists, or HTTP, or a UI. A connection string, as far as it is concerned, is a piece of text with an identifier attached. What actually opens a connection is the data access layer. The definition layer only declares an interface that can hand over &lt;code&gt;DatabaseSettings&lt;/code&gt;, and lets the data access layer use it.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. The three definitions of one form
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Definition&lt;/th&gt;
&lt;th&gt;Stored as&lt;/th&gt;
&lt;th&gt;What it governs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/jeff377/bee-library/blob/main/src/Bee.Definition/Forms/FormSchema.cs" rel="noopener noreferrer"&gt;&lt;code&gt;FormSchema&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;One per program&lt;/td&gt;
&lt;td&gt;The hub: fields, types, cross table relationships, master and detail, computed fields and validation rules&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/jeff377/bee-library/blob/main/src/Bee.Definition/Database/TableSchema.cs" rel="noopener noreferrer"&gt;&lt;code&gt;TableSchema&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;One per table&lt;/td&gt;
&lt;td&gt;The physical table's columns, types, lengths, nullability and indexes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/jeff377/bee-library/blob/main/src/Bee.Definition/Layouts/FormLayout.cs" rel="noopener noreferrer"&gt;&lt;code&gt;FormLayout&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;One per layout&lt;/td&gt;
&lt;td&gt;How those fields are arranged on screen&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The three edits for adding a required date to orders in &lt;a href="https://dev.to/jeff377/day-1-why-erp-needs-a-definition-driven-framework-1c2b"&gt;Why ERP Needs a Definition-Driven Framework&lt;/a&gt; are these three files.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;FormSchema&lt;/code&gt; is the hub. The other two each face one direction, one toward the database and one toward the screen.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;FormSchema&lt;/code&gt; declares which fields exist, what type each is, which ones point at other forms, which details hang off the master, which values are calculated, and what to check before saving. The framework uses it to assemble CRUD SQL and to decide the shape of data crossing layers.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;TableSchema&lt;/code&gt; cares about physical storage rather than business semantics, so lengths, indexes and precision can be tuned separately by whoever knows the database.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;FormLayout&lt;/code&gt; is the only one of the three that concerns the screen alone. How things are arranged is a design time decision, written into a layout definition and followed at run time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The relationship between the three also fixes the order of edits. &lt;code&gt;FormSchema&lt;/code&gt; describes which fields the form has, so a change starts there. &lt;code&gt;TableSchema&lt;/code&gt; follows with the physical column. If it needs to be visible, it then goes into &lt;code&gt;FormLayout&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Binding and navigation: who handles this program, and where it appears
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Definition&lt;/th&gt;
&lt;th&gt;Stored as&lt;/th&gt;
&lt;th&gt;What it governs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/jeff377/bee-library/blob/main/src/Bee.Definition/Settings/ProgramSettings/ProgramSettings.cs" rel="noopener noreferrer"&gt;&lt;code&gt;ProgramSettings&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;A single file&lt;/td&gt;
&lt;td&gt;Which business object and which repository a program identifier binds to. Left blank, the framework default applies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/jeff377/bee-library/blob/main/src/Bee.Definition/Settings/MenuSettings/MenuSettings.cs" rel="noopener noreferrer"&gt;&lt;code&gt;MenuSettings&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;A single file&lt;/td&gt;
&lt;td&gt;The navigation tree's grouping, ordering, captions and visibility, with each item pointing at a program&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/jeff377/bee-library/blob/main/src/Bee.Definition/Settings/PluginSettings/PluginSettings.cs" rel="noopener noreferrer"&gt;&lt;code&gt;PluginSettings&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;A single file&lt;/td&gt;
&lt;td&gt;Which plugins run at save and delete for each program, executed in declaration order&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ProgramSettings&lt;/code&gt; is a registry, one row per program, deciding who handles that program's requests. Leave the two binding columns blank and the framework's default CRUD flow applies. Name a class and that class takes over. When &lt;a href="https://dev.to/jeff377/day-3-the-northwind-case-study-eight-forms-and-a-custom-business-object-3i33"&gt;The Northwind Case Study: Eight Forms and a Custom Business Object&lt;/a&gt; said that of the eight forms only the order needs code, this registry is what it was reading.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;MenuSettings&lt;/code&gt; is separate from the registry because the two are read by different ends. The registry is read by the server and holds class names from the code. The menu is read by the front end and holds ordering, captions and visibility. On top of that, the same program can legitimately appear at several places in the menu, so a node carries its own identifier rather than using the program identifier as its key.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PluginSettings&lt;/code&gt; records which plugins run before and after save and delete for each program, and in what order. The difference from inheritance and overriding came up in an earlier post: inheritance replaces and there can be only one, while plugins layer and there can be several. The details get their own post later.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  4. The ones every form reaches for
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Definition&lt;/th&gt;
&lt;th&gt;Stored as&lt;/th&gt;
&lt;th&gt;What it governs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/jeff377/bee-library/blob/main/src/Bee.Definition/Language/LanguageResource.cs" rel="noopener noreferrer"&gt;&lt;code&gt;LanguageResource&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;One set per language, split by form or category&lt;/td&gt;
&lt;td&gt;Localized captions and option text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/jeff377/bee-library/blob/main/src/Bee.Definition/Settings/Permission/PermissionModels.cs" rel="noopener noreferrer"&gt;&lt;code&gt;PermissionModels&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;A single file&lt;/td&gt;
&lt;td&gt;Which permission models exist, which actions each governs, and how data scope is delimited&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/jeff377/bee-library/blob/main/src/Bee.Definition/Settings/CurrencySettings/CurrencySettings.cs" rel="noopener noreferrer"&gt;&lt;code&gt;CurrencySettings&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;A single file&lt;/td&gt;
&lt;td&gt;The decimal places and smallest circulating denomination of each currency&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/jeff377/bee-library/blob/main/src/Bee.Definition/Settings/UnitSettings/UnitSettings.cs" rel="noopener noreferrer"&gt;&lt;code&gt;UnitSettings&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;A single file&lt;/td&gt;
&lt;td&gt;The display decimal places of each unit of measure&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These four belong to no particular form, and every form reaches for them.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;LanguageResource&lt;/code&gt; is applied one entry at a time. &lt;code&gt;FormSchema&lt;/code&gt; already carries field captions; the language resource supplies per language versions. Where one matches it replaces the caption, and where none matches the original stands. A project that does not need multiple languages never has to touch it, and adding it does not force you to translate every entry.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PermissionModels&lt;/code&gt; registers what permissions there are to talk about. Which actions require authorization and how data scope is delimited are models. Who has been granted what is data rather than definition, and does not live in this file. A later post takes that apart.&lt;/li&gt;
&lt;li&gt;Currency and unit of measure are system level: how many decimal places a currency has, how many a unit displays, consistently across the system. Which currencies a given company may use, and what its cash rounding is, are company level settings that come from the database with the company's data rather than from a definition file. Both levels have their place, and a later post on numeric semantics uses them.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The thirteen at a glance
&lt;/h3&gt;

&lt;p&gt;The four sections above group by purpose. Here all thirteen are laid out together in the same order:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Definition&lt;/th&gt;
&lt;th&gt;What it governs&lt;/th&gt;
&lt;th&gt;Where it is expanded&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SystemSettings&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Master key source, payload options, debug mode&lt;/td&gt;
&lt;td&gt;This post&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DatabaseSettings&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Which databases this deployment has and how each connects&lt;/td&gt;
&lt;td&gt;This post&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DbCategorySettings&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Which category of data lands in which database&lt;/td&gt;
&lt;td&gt;This post&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FormSchema&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The hub, being fields, types, relationships, master and detail, rules and computed fields&lt;/td&gt;
&lt;td&gt;The next post&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TableSchema&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The physical table, being columns, lengths, nullability and indexes&lt;/td&gt;
&lt;td&gt;&lt;a href="https://medium.com/@jeff377/tableschema-and-database-structure-upgrades-cc9953a4863b" rel="noopener noreferrer"&gt;TableSchema and Database Structure Upgrades&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FormLayout&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;How this form is arranged on screen&lt;/td&gt;
&lt;td&gt;&lt;a href="https://medium.com/@jeff377/formlayout-and-screen-generation-for-multiple-front-ends-3eee75c07a1b" rel="noopener noreferrer"&gt;FormLayout and Screen Generation for Multiple Front Ends&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ProgramSettings&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Which business object and repository a program binds to. Server side only&lt;/td&gt;
&lt;td&gt;&lt;a href="https://medium.com/@jeff377/day-15-api-contract-layering-and-the-type-registry-b45176dc7536" rel="noopener noreferrer"&gt;API Contract Layering and the Type Registry&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MenuSettings&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The navigation menu's grouping, ordering, captions and visibility&lt;/td&gt;
&lt;td&gt;&lt;a href="https://medium.com/@jeff377/day-15-api-contract-layering-and-the-type-registry-b45176dc7536" rel="noopener noreferrer"&gt;API Contract Layering and the Type Registry&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PluginSettings&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Which plugins hang off each program, in declaration order&lt;/td&gt;
&lt;td&gt;&lt;a href="https://medium.com/@jeff377/day-21-business-logic-customization-and-the-four-plugin-stages-038d5a6c630f" rel="noopener noreferrer"&gt;Business Logic Customization and the Four Plugin Stages&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;LanguageResource&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Captions and option text, one set per language&lt;/td&gt;
&lt;td&gt;&lt;a href="https://medium.com/@jeff377/day-20-multilingual-customization-and-resource-structure-16d5199f3767" rel="noopener noreferrer"&gt;Multilingual Customization and Resource Structure&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PermissionModels&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;What permissions there are to talk about, being actions and data scope policy&lt;/td&gt;
&lt;td&gt;&lt;a href="https://medium.com/@jeff377/day-24-the-three-axes-of-permissions-action-row-and-column-dc55d934637a" rel="noopener noreferrer"&gt;The Three Axes of Permissions: Action, Row and Column&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CurrencySettings&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Decimal places and smallest circulating denomination per currency&lt;/td&gt;
&lt;td&gt;&lt;a href="https://medium.com/@jeff377/day-26-numeric-semantics-multiple-currencies-and-units-of-measure-fcb1f4e37e20" rel="noopener noreferrer"&gt;Numeric Semantics, Multiple Currencies and Units of Measure&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;UnitSettings&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Display decimal places per unit of measure&lt;/td&gt;
&lt;td&gt;&lt;a href="https://medium.com/@jeff377/day-26-numeric-semantics-multiple-currencies-and-units-of-measure-fcb1f4e37e20" rel="noopener noreferrer"&gt;Numeric Semantics, Multiple Currencies and Units of Measure&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These names recur over the next twenty odd posts, always in their original form. A descriptive phrase cannot identify which one is meant. "The category file" could be &lt;code&gt;DbCategorySettings&lt;/code&gt; or it could be &lt;code&gt;PermissionModels&lt;/code&gt;, and "the settings file" fits eight of the thirteen.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Four things all thirteen have in common
&lt;/h2&gt;

&lt;h3&gt;
  
  
  They are all XML, and where they are stored is a separate question
&lt;/h3&gt;

&lt;p&gt;The content is XML. Where it lives can be swapped: a file on disk, or a row in a database table with the XML in a column. The framework reads through one interface, so changing the storage means no layer above has to know.&lt;/p&gt;

&lt;p&gt;What gets used at run time is not that XML. The framework reads it and restores it into a definition object, and everything after that is a question asked of the object. The XML is only what it looks like while lying on a disk or in a database.&lt;/p&gt;

&lt;h3&gt;
  
  
  Once read, they stay in memory
&lt;/h3&gt;

&lt;p&gt;What sits in the cache is those restored objects, one slot per definition, evicted by usage. Reading one resets its timer, and only a definition nobody has touched for a while is dropped.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The forms in daily use keep their definitions in memory almost permanently&lt;/li&gt;
&lt;li&gt;The one opened twice a year is evicted not long after&lt;/li&gt;
&lt;li&gt;The server does not carry the definitions of forms nobody opens&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is one of the returns on cutting definitions into separate pieces. The unit of caching is the unit of reading, so memory tracks actual usage rather than the total volume of definitions.&lt;/p&gt;

&lt;p&gt;Keeping them does create one obligation: what happens when the source changes. Each cached definition watches its own source. A file is watched by modification time, and a database goes through a different notification mechanism. When the source changes, the next use reads a fresh copy. &lt;a href="https://medium.com/@jeff377/day-12-cross-node-invalidation-for-definition-and-data-caches-dbef9c7f4b82" rel="noopener noreferrer"&gt;Cross-Node Invalidation for Definition and Data Caches&lt;/a&gt; takes this apart.&lt;/p&gt;

&lt;h3&gt;
  
  
  A definition you were handed must not be modified
&lt;/h3&gt;

&lt;p&gt;Asking the framework for a definition gets you the shared one, not a copy. On the same server, every user gets the same object.&lt;/p&gt;

&lt;p&gt;The price is that the object cannot be touched from then on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Want a view for one user only, with captions in their language? Copy first, then modify.&lt;/li&gt;
&lt;li&gt;Want to persist a change? Go through the framework's save method, which writes the file and invalidates the cache.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The only thing forbidden is modifying the shared copy in place, and that was always wrong anyway, since what it modifies is everyone's.&lt;/p&gt;

&lt;h3&gt;
  
  
  Missing comes in two kinds
&lt;/h3&gt;

&lt;p&gt;The framework embeds a set of default definitions, and at run time they take no part at all. The framework reads only the definition directory you point it at. The embedded set is a starting point for a new project, copied in with a tool, once.&lt;/p&gt;

&lt;p&gt;Not falling back is deliberate. If a missing &lt;code&gt;FormSchema&lt;/code&gt; silently switched to the embedded one, the system would start up fine and then run on a definition you never deployed. "You forgot to deploy that file" would be rewritten into "the system does not behave the way you think," and the second one costs an order of magnitude more to track down.&lt;/p&gt;

&lt;p&gt;But missing does not mean only one thing, so all thirteen are sorted into two kinds:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Kind&lt;/th&gt;
&lt;th&gt;Behavior&lt;/th&gt;
&lt;th&gt;Which ones&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Missing means misconfigured&lt;/td&gt;
&lt;td&gt;Report it immediately with a message naming the file&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;FormSchema&lt;/code&gt;, &lt;code&gt;TableSchema&lt;/code&gt;, &lt;code&gt;ProgramSettings&lt;/code&gt;, &lt;code&gt;PermissionModels&lt;/code&gt; and the three boot files&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Missing is a legitimate answer&lt;/td&gt;
&lt;td&gt;Return nothing and let the caller take it from there&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;FormLayout&lt;/code&gt;, &lt;code&gt;LanguageResource&lt;/code&gt; where the caption in the structure is used, &lt;code&gt;CurrencySettings&lt;/code&gt; and &lt;code&gt;UnitSettings&lt;/code&gt; where default decimal places apply, &lt;code&gt;MenuSettings&lt;/code&gt; and &lt;code&gt;PluginSettings&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That classification lives in the code, and it has to be decided by the framework rather than interpreted by each application. Otherwise the same oversight shows up differently in different modules, one reporting an error and one quietly using a default, which is much harder to diagnose than both reporting errors.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. The case study: copy it in, then add on top
&lt;/h2&gt;

&lt;p&gt;Northwind uses the department and employee forms the framework provides, and the way it does that is the one time action from the previous section: copy the default definitions into the application's own definition directory.&lt;/p&gt;

&lt;p&gt;After copying, the application added two fields of its own to employee. Those two lines are in the case study's &lt;a href="https://github.com/jeff377/bee-northwind-avalonia/blob/main/Define/FormSchema/Employee.FormSchema.xml" rel="noopener noreferrer"&gt;&lt;code&gt;Employee.FormSchema.xml&lt;/code&gt;&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;FormField&lt;/span&gt; &lt;span class="na"&gt;FieldName=&lt;/span&gt;&lt;span class="s"&gt;"title"&lt;/span&gt; &lt;span class="na"&gt;Caption=&lt;/span&gt;&lt;span class="s"&gt;"Title"&lt;/span&gt; &lt;span class="na"&gt;DbType=&lt;/span&gt;&lt;span class="s"&gt;"String"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;FormField&lt;/span&gt; &lt;span class="na"&gt;FieldName=&lt;/span&gt;&lt;span class="s"&gt;"hire_date"&lt;/span&gt; &lt;span class="na"&gt;Caption=&lt;/span&gt;&lt;span class="s"&gt;"Hire Date"&lt;/span&gt; &lt;span class="na"&gt;DbType=&lt;/span&gt;&lt;span class="s"&gt;"Date"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once added, that definition belongs entirely to the application and can be changed however you like. Conversely, fields the framework later adds to its default definition will not appear on their own, and there will be no notice. Whether to follow along is your edit to make.&lt;/p&gt;

&lt;p&gt;The framework's system fields cannot be removed. They are what the permission and organization mechanisms recognize.&lt;/p&gt;

&lt;p&gt;This is what "definitions are data" looks like from a maintenance point of view. Copying a piece of data out and maintaining it yourself and branching a program and maintaining it yourself cost different things. The first has no merge conflicts and no need to carry trunk fixes across version by version, and the price is that new things do not follow along automatically.&lt;/p&gt;




&lt;h2&gt;
  
  
  Thirteen files, one truth
&lt;/h2&gt;

&lt;p&gt;The shape of the thirteen is roughly this. Three decide what the system runs on, three describe one form, three decide who executes and where the entrance is, and four are shared data every form reaches for.&lt;/p&gt;

&lt;p&gt;These are not thirteen configuration files. They are thirteen aspects of one truth. They point at each other, with &lt;code&gt;DbCategorySettings&lt;/code&gt; pointing at &lt;code&gt;DatabaseSettings&lt;/code&gt;, a &lt;code&gt;FormSchema&lt;/code&gt; pointing at another &lt;code&gt;FormSchema&lt;/code&gt;, and &lt;code&gt;ProgramSettings&lt;/code&gt; and &lt;code&gt;MenuSettings&lt;/code&gt; each pointing at the same program, and they do not collide, because each one answers only the question that belongs to it.&lt;/p&gt;

&lt;p&gt;The next post, &lt;a href="https://medium.com/@jeff377/how-formschema-drives-sql-bd0a83e80273" rel="noopener noreferrer"&gt;How FormSchema Drives SQL&lt;/a&gt;, is about which tables a query joins and which statements a save issues.&lt;/p&gt;




&lt;p&gt;📚 Part of the thirty-post series &lt;a href="https://dev.to/jeff377/erp-architects-notes-designing-a-definition-driven-framework-series-index-4ln7"&gt;ERP Architect's Notes: Designing a Definition-Driven Framework&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;📘 Original article on HackMD, in Traditional Chinese:&lt;br&gt;
👉 &lt;a href="https://hackmd.io/@jeff377/day-04-definition-files" rel="noopener noreferrer"&gt;https://hackmd.io/@jeff377/day-04-definition-files&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;📢 You are welcome to republish this, please credit the source.&lt;br&gt;
📬 Follow for more engineering notes:&lt;br&gt;
&lt;a href="https://www.facebook.com/profile.php?id=61574839666569" rel="noopener noreferrer"&gt;Facebook&lt;/a&gt; ｜ &lt;a href="https://hackmd.io/@jeff377" rel="noopener noreferrer"&gt;HackMD&lt;/a&gt; ｜ &lt;a href="https://github.com/jeff377" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; ｜ &lt;a href="https://www.nuget.org/profiles/jeff377" rel="noopener noreferrer"&gt;NuGet&lt;/a&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>dotnet</category>
      <category>erp</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Day 3: The Northwind Case Study: Eight Forms and a Custom Business Object</title>
      <dc:creator>Jeff Tsai</dc:creator>
      <pubDate>Tue, 27 Oct 2026 02:01:08 +0000</pubDate>
      <link>https://dev.to/jeff377/day-3-the-northwind-case-study-eight-forms-and-a-custom-business-object-3i33</link>
      <guid>https://dev.to/jeff377/day-3-the-northwind-case-study-eight-forms-and-a-custom-business-object-3i33</guid>
      <description>&lt;p&gt;The first two posts were about the argument. Today the case study comes on stage.&lt;/p&gt;

&lt;p&gt;The case study is Northwind, Microsoft's sample database that has been passed down since the Access era. The reason for choosing it is practical: customers, suppliers, products and orders are tables most people recognize without explanation, so the conversation can go straight to what this structure puts the framework through, without first explaining what the business does.&lt;/p&gt;

&lt;p&gt;This post covers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The four conditions a case study has to meet before it can test a framework&lt;/li&gt;
&lt;li&gt;Eight forms in three shapes, and the framework's system fields&lt;/li&gt;
&lt;li&gt;Why relationships are declared between forms rather than between tables&lt;/li&gt;
&lt;li&gt;The one form that needed code, and why it also came with a repository&lt;/li&gt;
&lt;li&gt;What is inside that business object&lt;/li&gt;
&lt;li&gt;The framework runs the standard flow; the application takes over only the part it cannot reach&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;First, what it looks like. This is the order, the most complex of the eight, rendered from one definition on four front ends.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Desktop&lt;/th&gt;
&lt;th&gt;Browser&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&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%2F12mk2qrx18s2w9pxd5x1.png" alt="The Northwind order form on desktop" width="800" height="490"&gt;&lt;/td&gt;
&lt;td&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%2Ffmorxwfd3wh5regtbk6x.png" alt="The Northwind order form in the browser" width="800" height="579"&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;iOS&lt;/th&gt;
&lt;th&gt;Android&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&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%2Fycooxrdy3qd5cwauybhr.png" alt="The Northwind order form on iOS" width="516" height="1023"&gt;&lt;/td&gt;
&lt;td&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%2Fale365ty2hbtzrv0yemm.png" alt="The Northwind order form on Android" width="479" height="827"&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One master with one detail. Customer, employee and shipper are all lookup windows that bring one record back, each detail row picks a product, and the amount is not typed by anyone. The four front ends share one UI project and read the same definition. Not one of those screens has screen code behind it. The only difference is the thin platform shell on the outside.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. What a case study has to put a framework through
&lt;/h2&gt;

&lt;p&gt;Framework demos almost all look the same: one table, fields in a row, create, read, update and delete work. That demo proves nothing, because every framework can do it. Not one of the places that actually break is on that screen.&lt;/p&gt;

&lt;p&gt;A case study worth using as verification has to satisfy four conditions at once:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Forms have to vary in difficulty. A plain master, a master with cross table relationships, and a document with a master and details all have to be present. A framework that only holds up on the simplest of those is just a form generator.&lt;/li&gt;
&lt;li&gt;Relationships cannot have only one shape. One table pointing at another is the basic case. You also need two tables pointing at each other, and an application table pointing at one of the framework's own tables, before the design of the relationship mechanism is forced into the open.&lt;/li&gt;
&lt;li&gt;There has to be one form that needs custom logic. If a framework claims most things need no code, and its case study contains no code at all, that is not proof of the claim. It is avoidance of it.&lt;/li&gt;
&lt;li&gt;It has to run on more than one front end. A layout definition that claims not to be tied to a UI framework does not get to make that claim until one definition has actually grown two or more different screens.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Northwind holds up on all four. Its complexity sits in a good place: complex enough to test the mechanisms, small enough that the whole thing can be laid out and reconciled item by item.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Eight forms, three shapes
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Shape&lt;/th&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;th&gt;Forms&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Plain master, meaning fields, a list and CRUD, with no relationships and no rules&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Category, supplier, customer, shipper&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Master with cross table relationships&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Product, pointing at supplier and category; department, pointing at employee as its manager; and employee, pointing at department&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A document with a master and details&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;The order&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Department and employee point at each other. That circle was left in deliberately.&lt;/p&gt;

&lt;p&gt;Eight forms map onto nine tables, since the order takes two. Two of those nine are tables the framework provides: department and employee. The permission and organization mechanisms use them, and the application copies their definitions over and uses them as they are. How framework tables and application tables are told apart, and which database each lands in, is the subject of &lt;a href="https://medium.com/@jeff377/day-11-connections-transactions-and-query-conditions-in-the-data-access-layer-5aca98e186a4" rel="noopener noreferrer"&gt;Connections, Transactions and Query Conditions in the Data Access Layer&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  These nine tables are not Northwind's original shape
&lt;/h3&gt;

&lt;p&gt;What is borrowed is the business case and the sample data, not the table structure. The original Northwind uses business columns as primary keys, with &lt;code&gt;CustomerID='ALFKI'&lt;/code&gt;, integer product ids and a composite key on order details. This application kept none of that.&lt;/p&gt;

&lt;p&gt;What went in instead are the framework's system fields, and the framework's mechanisms hang off these exact names:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;What the framework uses it for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sys_rowid&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Cross table joins, the WHERE clause of UPDATE and DELETE, auditing, data scope permissions, and the row the user clicked on. All of them compare against this column&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sys_master_rowid&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;How details are fetched, and how a whole document is saved together&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sys_no&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The primary key and the default sort order&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sys_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Gets a unique index automatically at table creation, which is what guarantees business code uniqueness&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sys_name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The column shown in a lookup window&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So these names cannot be changed, and that is not a naming convention issue. Everything listed above is what the framework uses them to recognize.&lt;/p&gt;

&lt;p&gt;Two design decisions, and what they cost:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The primary key and the relation key are separate. The primary key is an auto incrementing &lt;code&gt;sys_no&lt;/code&gt;; the relation key is a &lt;code&gt;Guid&lt;/code&gt; in &lt;code&gt;sys_rowid&lt;/code&gt;. A relation key has to be immutable, because a changed code must not break old documents, and it has to exist before anything is written to the database, because an order is saved in one go and the details need to point at the master first. The cost is one more unique index, and a fatter key.&lt;/li&gt;
&lt;li&gt;Details do not use composite keys. If the shape of master and detail differed from case to case, the mechanism that reads and writes a whole document could not be common to every form.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One more cost, and this one is heavier: an existing system has to change its table structure before it can be brought in. Writing the definitions is not enough to run on the original database.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Relationships are declared between forms, not between tables
&lt;/h2&gt;

&lt;p&gt;Traditionally a relationship is a table concern: the order table gets a customer column and a foreign key pointing at the customer table's primary key. What that declaration expresses is exactly what it says on its face, that the value in this column must be findable in that one.&lt;/p&gt;

&lt;p&gt;The framework moves the relationship up a level:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;FormField&lt;/span&gt; &lt;span class="na"&gt;FieldName=&lt;/span&gt;&lt;span class="s"&gt;"customer_rowid"&lt;/span&gt; &lt;span class="na"&gt;Caption=&lt;/span&gt;&lt;span class="s"&gt;"Customer"&lt;/span&gt; &lt;span class="na"&gt;DbType=&lt;/span&gt;&lt;span class="s"&gt;"Guid"&lt;/span&gt; &lt;span class="na"&gt;RelationProgId=&lt;/span&gt;&lt;span class="s"&gt;"Customer"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;RelationFieldMappings&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;FieldMapping&lt;/span&gt; &lt;span class="na"&gt;SourceField=&lt;/span&gt;&lt;span class="s"&gt;"sys_id"&lt;/span&gt; &lt;span class="na"&gt;DestinationField=&lt;/span&gt;&lt;span class="s"&gt;"ref_customer_id"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;FieldMapping&lt;/span&gt; &lt;span class="na"&gt;SourceField=&lt;/span&gt;&lt;span class="s"&gt;"sys_name"&lt;/span&gt; &lt;span class="na"&gt;DestinationField=&lt;/span&gt;&lt;span class="s"&gt;"ref_customer_name"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/RelationFieldMappings&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/FormField&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;RelationProgId="Customer"&lt;/code&gt; points at the customer program, not at the &lt;code&gt;ft_customer&lt;/code&gt; table. No table name appears anywhere in that declaration. Which table to join is something the framework works out by taking that identifier and looking up the customer's own &lt;code&gt;FormSchema&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Pointing at a form and pointing at a table can connect to very different amounts of information:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A table knows only which columns it has&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;FormSchema&lt;/code&gt; also knows which columns the list displays, what a person sees when picking from a lookup window, which fields are queryable, and whether it wants to add conditions of its own, such as listing only active suppliers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So once that one declaration is written, four things are in place at once:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The field automatically becomes a lookup window editor on screen&lt;/li&gt;
&lt;li&gt;Picking a record fills the customer code and name back in&lt;/li&gt;
&lt;li&gt;When querying the list, the framework assembles the join that fetches those two columns&lt;/li&gt;
&lt;li&gt;What the database stores is the relation key&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Traditionally those four are recorded in three places, the foreign key in the database, the picker configuration on the front end, and the join in the query layer, and all three can disagree.&lt;/p&gt;

&lt;h3&gt;
  
  
  There are no foreign keys in the database
&lt;/h3&gt;

&lt;p&gt;The framework does not create foreign key constraints. Those three relationship columns on the order have only indexes in the database.&lt;/p&gt;

&lt;p&gt;That follows necessarily from moving relationships into the definition layer. The relationship's authority is the definition, and the database is a projection of it. Creating a foreign key as well would give that one declaration two sources, and two sources eventually disagree.&lt;/p&gt;

&lt;p&gt;The cost: the database will not block orphaned rows for you. If someone deletes a customer with raw SQL, the orders pointing at it are not stopped. Whether that is acceptable depends on whether your database is written to by this application alone. The assumption that definitions are the single source of truth has to be carried all the way down to the database. You cannot give half of it to definitions and half to constraints.&lt;/p&gt;

&lt;h3&gt;
  
  
  Display columns are not the authoritative value
&lt;/h3&gt;

&lt;p&gt;The two fields &lt;code&gt;ref_customer_id&lt;/code&gt; and &lt;code&gt;ref_customer_name&lt;/code&gt; do not exist in the table.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The physical order table holds only the customer's relation key&lt;/li&gt;
&lt;li&gt;The code and the name are joined in by the server at query time&lt;/li&gt;
&lt;li&gt;When the front end finishes picking, it writes the values into the data it is holding, purely so the screen updates immediately. They are not persisted on save&lt;/li&gt;
&lt;li&gt;If the customer changes its name, the value shown on the order is correct on the next read&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A typical trade-off: one more join per query, in exchange for the screen always showing the current value. The cost is that there has to be an explicit rule about which side is authoritative, and that rule has to be set by the framework rather than decided by each form separately.&lt;/p&gt;

&lt;h3&gt;
  
  
  How many this application declares
&lt;/h3&gt;

&lt;p&gt;Eight, counted directly. Three on the order master, being customer, employee and shipper; one per order detail row, being the product; two on product, being supplier and category; one on employee, being department; and one on department, being the manager.&lt;/p&gt;

&lt;p&gt;The three cross table relationships mentioned in the first post are the three on the order master, and that is also the scope used for reconciliation later.&lt;/p&gt;

&lt;p&gt;One of the eight crosses a line. The employee an order points at is one of the two framework tables mentioned above. The salesperson on an order is the framework's employee, and the application does not build an employee table of its own.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why the circular relationship was left in
&lt;/h3&gt;

&lt;p&gt;Department points at employee, for the manager, and employee points at department. In the data model that is a circle. It was left in deliberately, because any mechanism that grows something out of definitions needs a defined answer for circles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Generating strongly typed classes at build time: the circle forces a type initialization ordering problem into the open&lt;/li&gt;
&lt;li&gt;Assembling queries dynamically from definitions at run time: the circle only affects how many levels that one query joins&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the first post's build time versus run time distinction, projected onto the data model.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. The order: the one form that needed code
&lt;/h2&gt;

&lt;p&gt;Seven of the eight forms have zero lines of application code. On the registry those seven declare only a program identifier and a display name, which means they run the framework's default flow. Only the order row names two types:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;ProgramItem&lt;/span&gt; &lt;span class="na"&gt;ProgId=&lt;/span&gt;&lt;span class="s"&gt;"Order"&lt;/span&gt; &lt;span class="na"&gt;DisplayName=&lt;/span&gt;&lt;span class="s"&gt;"Orders"&lt;/span&gt;
             &lt;span class="na"&gt;BusinessObject=&lt;/span&gt;&lt;span class="s"&gt;"...OrderBO, Bee.Northwind.Server"&lt;/span&gt;
             &lt;span class="na"&gt;Repository=&lt;/span&gt;&lt;span class="s"&gt;"...OrderRepository, Bee.Northwind.Server"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two types cover one area each. The business object holds this form's business logic, and the repository holds its data access.&lt;/p&gt;

&lt;p&gt;That repository exists for exactly two SQL statements: reading the order's current state as stored in the database, and finding the highest order number this month. The rest of CRUD is still assembled by the framework from the definition.&lt;/p&gt;

&lt;p&gt;Putting those two in the repository rather than in the business object is not layering fastidiousness. A business object would have to name the database it connects to itself, whereas a repository is resolved from the &lt;code&gt;FormSchema&lt;/code&gt; category plus the current session, and lands automatically in the right company's database. Whoever writes it does not need to know how many companies the system has.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. What is inside that business object
&lt;/h2&gt;

&lt;p&gt;The order's business object overrides two methods, and the step before saving does four things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Check that there is at least one detail row&lt;/li&gt;
&lt;li&gt;Sum the detail amounts back into the master total&lt;/li&gt;
&lt;li&gt;Generate the order number&lt;/li&gt;
&lt;li&gt;Check that the status transition is legal&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;All four look like business logic. Run them through the test from &lt;a href="https://dev.to/jeff377/day-2-an-overview-of-definition-driven-architecture-31hi"&gt;An Overview of Definition-Driven Architecture&lt;/a&gt;, would every ERP in the world do this the same way, and only the last one is.&lt;/p&gt;

&lt;p&gt;The first three are general. That a document needs at least one detail row, and that the total equals the sum of the details, is established practice. An order number of a prefix plus year and month plus a sequence is the kind of numbering rule most ERPs turn into a configurable mechanism. All three should converge into mechanisms. They live in the application today because the framework has not absorbed them yet.&lt;/p&gt;

&lt;p&gt;The last one is the business judgment. Status goes from draft to confirmed to shipped, no skipping and no going back, and details lock once confirmed. That rule differs at every company, since some allow quantities to change after confirmation and some have two more intermediate states, so the application should own it.&lt;/p&gt;

&lt;h3&gt;
  
  
  That line has moved before
&lt;/h3&gt;

&lt;p&gt;The business object used to do more than those four. Two batches have already moved out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The amount on each detail row used to be calculated in code. It is now one expression on the field&lt;/li&gt;
&lt;li&gt;Three checks, that customer and product are required and quantity is greater than zero, used to be in code. They are now three rules in the &lt;code&gt;FormSchema&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;FormField&lt;/span&gt; &lt;span class="na"&gt;FieldName=&lt;/span&gt;&lt;span class="s"&gt;"amount"&lt;/span&gt; &lt;span class="na"&gt;DbType=&lt;/span&gt;&lt;span class="s"&gt;"Currency"&lt;/span&gt; &lt;span class="na"&gt;NumberKind=&lt;/span&gt;&lt;span class="s"&gt;"Amount"&lt;/span&gt; &lt;span class="na"&gt;ReadOnly=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;
           &lt;span class="na"&gt;ValueExpression=&lt;/span&gt;&lt;span class="s"&gt;"quantity * unit_price * (1 - discount)"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What was saved by moving them is more than those lines. The expression is recalculated the moment the user changes the quantity or the discount, so the front end does not have to write the same calculation a second time to make the amount update live. One formula, declared once, computed correctly on both ends.&lt;/p&gt;

&lt;p&gt;One of them went further still: who is able to change it also changed. How the amount is calculated now lives in a definition, so adjusting it means editing a definition file rather than going back to the code side. Every time something moves from code into definitions, the people who can change it move one step closer to where the requirements are. What used to need a slot in the development schedule becomes one line of arithmetic that someone who understands the business can read, and edit.&lt;/p&gt;

&lt;p&gt;So the line between what needs code and what does not was never drawn once at the start. It moves forward as the framework's declarative power grows. Of those four items, the first three will be absorbed sooner or later, and only the last will remain.&lt;/p&gt;

&lt;p&gt;What stays in code is partly what belonged there all along, and partly what has simply not been taken over yet. Mixed together they look alike. Separated, you can see which one to absorb next.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. The framework runs the standard flow, the application takes over what it cannot reach
&lt;/h2&gt;

&lt;p&gt;What the framework owns is everything that is the same on every form: query the list, open a record, create, update, delete, pick from a lookup window, validate before saving, and deliver data to the front end. The shape of those does not change with the form. What changes is which fields exist, and the fields are in the definition. Governance is the same story: who may access it, which rows they can see, and who changed what and when.&lt;/p&gt;

&lt;p&gt;That is why those seven forms have no application code.&lt;/p&gt;

&lt;p&gt;At this point a doubt usually surfaces, and it should. If the whole flow is standardized, what happens to flexibility?&lt;/p&gt;

&lt;p&gt;That question matters far more than how much a framework can do. Setting a standard is not hard. What is hard is still having room for exceptions afterwards. Without that room, the first special requirement means carving out the entire flow yourself, and everything saved earlier is handed back. In the case study, the form that runs into this is the order.&lt;/p&gt;

&lt;p&gt;There are two ways to take over:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Mechanism&lt;/th&gt;
&lt;th&gt;Nature&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Inherit and override&lt;/td&gt;
&lt;td&gt;The framework's business object cuts the whole flow into a sequence of overridable steps, being fetch a new record, before save, the save itself, and after save, with delete cut the same way. The application changes only the steps it needs. The order changed two&lt;/td&gt;
&lt;td&gt;Replacement, and there is one implementation per program&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hook in a plugin&lt;/td&gt;
&lt;td&gt;The framework opens four points around save and delete for external logic&lt;/td&gt;
&lt;td&gt;Layering, several can stack, and the customization chain runs after the packaged one without touching the original program&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;What the two have in common is the answer to that doubt: what you take over is one short segment of the flow, not the whole flow. Overriding "before save" does not suddenly make you responsible for assembling SQL or delivering data to the front end.&lt;/p&gt;

&lt;p&gt;Flexibility is not about the framework being able to do everything. It is that you only have to handle the part that differs from how the mechanism does it, or that the mechanism does not support yet. Whether a standard flow and its exceptions can coexist comes down to that granularity. Cut too coarsely and one exception means rewriting the whole thing. Cut finely enough and the standard parts keep working for you.&lt;/p&gt;

&lt;h3&gt;
  
  
  Back to a claim from the first post
&lt;/h3&gt;

&lt;p&gt;The first post, &lt;a href="https://dev.to/jeff377/day-1-why-erp-needs-a-definition-driven-framework-1c2b"&gt;Why ERP Needs a Definition-Driven Framework&lt;/a&gt;, said that across the whole Northwind application, only one of the eight forms needs code written for it. That form is the order, and it needs to take over not because it is more complex, but because it runs into the few things the current mechanisms cannot reach. A case study needs a form like that, or the claim that most things need no code has never been tested.&lt;/p&gt;

&lt;p&gt;And handling them takes overriding the two steps the framework cut out, without leaving the standard flow. That room is necessary. A mechanism must not standardize a flow by taking flexibility away. Many of the special cases in an ERP are not badly written requirements. They are what that company's structure and working habits actually look like, and they do not disappear because a framework set a standard. How deep a takeover can go is cut into several levels, and &lt;a href="https://medium.com/@jeff377/day-30-three-depths-of-development-and-the-big-picture-of-the-framework-68bbe77ceede" rel="noopener noreferrer"&gt;Three Depths of Development and the Big Picture of the Framework&lt;/a&gt; lays all of them out.&lt;/p&gt;

&lt;p&gt;This is the state definition-driven design is aiming at. Not writing no code, but writing it only where a judgment is genuinely required, and taking over only that one segment when you do.&lt;/p&gt;

&lt;p&gt;The next post, &lt;a href="https://medium.com/@jeff377/the-purpose-and-classification-of-definition-files-3c84e980bfb1" rel="noopener noreferrer"&gt;The Purpose and Classification of Definition Files&lt;/a&gt;, moves into the definition layer, starting with an account of all thirteen kinds of definition file.&lt;/p&gt;




&lt;p&gt;📚 Part of the thirty-post series &lt;a href="https://dev.to/jeff377/erp-architects-notes-designing-a-definition-driven-framework-series-index-4ln7"&gt;ERP Architect's Notes: Designing a Definition-Driven Framework&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;📘 Original article on HackMD, in Traditional Chinese:&lt;br&gt;
👉 &lt;a href="https://hackmd.io/@jeff377/day-03-northwind-case" rel="noopener noreferrer"&gt;https://hackmd.io/@jeff377/day-03-northwind-case&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;📢 You are welcome to republish this, please credit the source.&lt;br&gt;
📬 Follow for more engineering notes:&lt;br&gt;
&lt;a href="https://www.facebook.com/profile.php?id=61574839666569" rel="noopener noreferrer"&gt;Facebook&lt;/a&gt; ｜ &lt;a href="https://hackmd.io/@jeff377" rel="noopener noreferrer"&gt;HackMD&lt;/a&gt; ｜ &lt;a href="https://github.com/jeff377" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; ｜ &lt;a href="https://www.nuget.org/profiles/jeff377" rel="noopener noreferrer"&gt;NuGet&lt;/a&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>dotnet</category>
      <category>erp</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Day 2: An Overview of Definition-Driven Architecture</title>
      <dc:creator>Jeff Tsai</dc:creator>
      <pubDate>Mon, 26 Oct 2026 03:11:15 +0000</pubDate>
      <link>https://dev.to/jeff377/day-2-an-overview-of-definition-driven-architecture-31hi</link>
      <guid>https://dev.to/jeff377/day-2-an-overview-of-definition-driven-architecture-31hi</guid>
      <description>&lt;p&gt;The previous post, &lt;a href="https://dev.to/jeff377/day-1-why-erp-needs-a-definition-driven-framework-1c2b"&gt;Why ERP Needs a Definition-Driven Framework&lt;/a&gt;, ended by promising this one would be about which decisions a framework should make on its users' behalf.&lt;/p&gt;

&lt;p&gt;The difference between a framework and a library is usually explained as "you call a library, a framework calls you." That describes the mechanism, not the price. The real price is that once control is inverted, the framework's defaults become your defaults. It decides what data looks like, how a request flows, and where the extension points are. You are free to disagree, and the cost of disagreeing is working around it, which is usually more expensive than not using it at all.&lt;/p&gt;

&lt;p&gt;So the question to ask about a framework is not what it can do. The more things it can do, the fewer decisions it has made for you, and the same decision has to be made again for every single form.&lt;/p&gt;

&lt;p&gt;This post answers the four questions a definition-driven framework has to settle first. Every other design choice is a consequence of these four answers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Which layer holds the single source of truth&lt;/li&gt;
&lt;li&gt;Which layers are derived from it&lt;/li&gt;
&lt;li&gt;What is left to the application&lt;/li&gt;
&lt;li&gt;Where this whole approach does not hold&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of the four can be left blank. If the framework does not answer them, the people using it will answer them individually, and their answers will not agree. This module puts the truth in an entity, that one puts it in the table, a third puts it in a form configuration file on the front end. A good part of the three long-standing problems from the previous post grows out of exactly that.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Which layer holds the single source of truth
&lt;/h2&gt;

&lt;p&gt;First, a distinction that is easy to blur: a Code-First stack is not short of definitions. The API contract is a definition, the migration script is a definition, so is the database's own metadata. The difference is not whether definitions exist. It is which way the arrow points. Those definitions are dumped out as products. Editing them is meaningless, because the next generation run overwrites them.&lt;/p&gt;

&lt;p&gt;So the question is not whether to have definition files. It is where the arrow should point, and there are only three candidates:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Candidate&lt;/th&gt;
&lt;th&gt;Paradigm&lt;/th&gt;
&lt;th&gt;The catch&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Code&lt;/td&gt;
&lt;td&gt;Code-First&lt;/td&gt;
&lt;td&gt;A type system is wide enough to hold any semantics you can think of. The price is that the only reader of the truth is the compiler, and changing the truth means going through compilation, packaging and deployment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The database&lt;/td&gt;
&lt;td&gt;Database-First&lt;/td&gt;
&lt;td&gt;Data is the final fact, but the type system is too narrow&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Definitions&lt;/td&gt;
&lt;td&gt;Definition-Driven&lt;/td&gt;
&lt;td&gt;Has to satisfy two conditions at once&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Where the database is narrow: it knows that &lt;code&gt;total_amount&lt;/code&gt; is a &lt;code&gt;decimal(18,4)&lt;/code&gt;, but not whether that number is an amount, a quantity or an exchange rate. It does not know whether the value should be rounded, how many digits to show, whether it is required, whether it can be edited, or whether it belongs in the query list. Those semantics have nowhere to live, so they end up somewhere else, and the truth has scattered again.&lt;/p&gt;

&lt;h3&gt;
  
  
  For definitions to be the truth, two conditions, neither optional
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Wide enough to carry semantics beyond structure&lt;/li&gt;
&lt;li&gt;They are data, not code, which was the whole subject of the previous post&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In this framework, what a single field can declare falls into six categories:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;What is declared&lt;/th&gt;
&lt;th&gt;Can the database hold it?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Structure&lt;/td&gt;
&lt;td&gt;Field name, data type, length, default value&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Behavior&lt;/td&gt;
&lt;td&gt;Required, read only, visible&lt;/td&gt;
&lt;td&gt;Partly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Presentation&lt;/td&gt;
&lt;td&gt;Caption, control type, display format, width&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Semantics&lt;/td&gt;
&lt;td&gt;What kind of number this is, which field carries the currency, which field carries the unit of measure&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Relationships&lt;/td&gt;
&lt;td&gt;The related form, the field mapping, the display field&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Security&lt;/td&gt;
&lt;td&gt;Data scope roles, sensitive data classification&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Code can hold all six, but only as a stack of attributes, and once they are stacked someone still has to parse them back into data before anything can use them.&lt;/p&gt;

&lt;p&gt;The width of that declaration is deliberate. It has to cover the UI, the database and validation in one pass.&lt;/p&gt;

&lt;h3&gt;
  
  
  A single source of truth is not a claim, it is a dependency direction
&lt;/h3&gt;

&lt;p&gt;Any framework can write "this framework treats X as the single source of truth" in its documentation. To verify it, the dependency direction is enough: the layer holding the truth should depend on nobody, and everybody should depend on it.&lt;/p&gt;

&lt;p&gt;The definition layer package has an extremely short reference list. It does not know a database exists, or HTTP, or a UI. In the other direction, data access, business logic, the API contract, the client, and every front end all reference it.&lt;/p&gt;

&lt;p&gt;That direction buys two things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The definition layer can be processed by tools on its own. A definition editor, a generator, a batch checking script: each needs to reference only this one package, without dragging in the entire database and API stack. The claim in the previous post that a thousand definitions can be swept by one script rests on this.&lt;/li&gt;
&lt;li&gt;AI benefits too. To make it understand a system, feeding it the definitions is more efficient than having it scan the whole code base, and the conclusions are more reliable. The difference is not file size. It is whether relationships are declared or inferred. Ask which forms an order touches, and reading the definitions means reading relationships that were already declared; reading the code means chasing types, chasing queries, chasing configuration, and then inferring intent, and inference gets things wrong.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tokens saved are a side effect. As for the business judgments that definitions do not cover, the code still has to be read.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Which layers are derived from definitions
&lt;/h2&gt;

&lt;p&gt;The previous post used plain language: the UI, the database, CRUD SQL, validation rules. The precise list comes in two kinds. Most of it is generation:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What is generated&lt;/th&gt;
&lt;th&gt;From&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;The single record form layout&lt;/td&gt;
&lt;td&gt;The form structure (master block plus detail block)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The list layout&lt;/td&gt;
&lt;td&gt;The list fields the form structure declares&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The lookup window layout&lt;/td&gt;
&lt;td&gt;The queryable fields the form structure declares&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The physical table structure&lt;/td&gt;
&lt;td&gt;Every table in the form structure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;INSERT / UPDATE / DELETE&lt;/td&gt;
&lt;td&gt;The form structure's field set&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SELECT, including cross table joins&lt;/td&gt;
&lt;td&gt;The form structure's fields and declared relationships&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The structure of the data container that crosses layers&lt;/td&gt;
&lt;td&gt;The form structure's field set&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The other kind is evaluation. Here the framework does not build anything; it takes the rules and expressions declared in the definition and evaluates them. Validation rules are evaluated at save and delete. Computed fields are recalculated the moment the user changes a field they depend on.&lt;/p&gt;

&lt;p&gt;When a requirement converges into a new shared behavior, a matching mechanism grows. The query criteria screen for a report, for example, is essentially a set of fields plus editors, and a layout definition can describe it.&lt;/p&gt;

&lt;p&gt;Grouped by who consumes it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Form structure definition
│
├─ Generates
│   ├─ Three layouts: single record / list / lookup window  -&amp;gt;  screens on every front end
│   ├─ Physical table structure  -&amp;gt;  the database
│   ├─ Two kinds of SQL: write / query, including cross table joins  -&amp;gt;  data access
│   └─ The cross layer data container structure  -&amp;gt;  transport
│
└─ Evaluates
    ├─ Validation rules  -&amp;gt;  at save and delete
    └─ Computed fields  -&amp;gt;  the moment a dependent field changes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Generating code, or generating objects
&lt;/h3&gt;

&lt;p&gt;Growing something out of a definition at build time and at run time produces completely different results:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A build time generator produces code. When the definition changes, you regenerate, recompile and redeploy. Not one step of the delivery path from the previous post has been removed, and everything saved earlier is handed back at the final gate.&lt;/li&gt;
&lt;li&gt;Run time derivation produces objects in memory. Swap the definition, and the next read is the new one. This is exactly where "no compilation, packaging or deployment between changing a definition and having it take effect" holds.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two things are paid for it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Compile time type checking is gone. A misspelled field name is not discovered until run time, and how that debt gets repaid is the subject of &lt;a href="https://medium.com/@jeff377/day-9-data-and-definitions-split-apart-so-forms-need-no-types-of-their-own-5e76d8564d72" rel="noopener noreferrer"&gt;Data and Definitions Split Apart, So Forms Need No Types of Their Own&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Cache invalidation goes from an optimization to a problem that must be solved. Definitions will be cached for performance, so the framework needs a notification mechanism that carries a definition changed on one node out to every other node.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  3. What is left to the application
&lt;/h2&gt;

&lt;p&gt;Where the framework should stop has two rules, one positive and one negative.&lt;/p&gt;

&lt;p&gt;The positive one: any behavior that is standardized and repetitive should converge into a framework mechanism.&lt;/p&gt;

&lt;p&gt;This is not merely about writing fewer lines:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;There is one way to do each thing, and a fix goes in one place, which saves maintenance&lt;/li&gt;
&lt;li&gt;New joiners, consultants and whoever inherits the system learn it once, and understanding one form means understanding all of them, which saves training and is the most commonly underestimated of the two&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The symptoms of losing control listed in the previous post are all invoices issued by the absence of a general mechanism, and they are reissued per person, per year.&lt;/p&gt;

&lt;p&gt;The negative rule is a single test: would every ERP in the world do this the same way? What is the same belongs to the framework. What differs belongs to the application.&lt;/p&gt;

&lt;p&gt;By that line the framework stays out of three areas:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Business judgment: how a status transitions, when shipping must be blocked. Every company differs.&lt;/li&gt;
&lt;li&gt;SQL for reports and batch jobs: multi table joins, aggregation, dynamic criteria, performance tuning. Forcing these into definitions only makes them more complicated.&lt;/li&gt;
&lt;li&gt;The application shell: the login screen, connection settings, navigation menu, windows and theme.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Staying out is not the same as ignoring. Having handed those three over, the framework picks up two obligations it must get right:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A registry that maps a form identifier to the class you wrote&lt;/li&gt;
&lt;li&gt;A few extension points at unambiguous positions, so your logic can hook into the save and delete flows&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;How well those two are done decides directly whether the application is pleasant or awkward to write, and a later chapter takes them apart.&lt;/p&gt;

&lt;p&gt;Handing work over does not reduce complexity either. What definitions can absorb is repetition, not the inherent difficulty of the business. The hard parts have not disappeared. They have been concentrated in one place and given to whoever knows why they are hard.&lt;/p&gt;

&lt;p&gt;The third area is clearest in the case study. The Northwind front end has one UI project shared by every front end, and only four hand written screens: the main window, login, connection settings, and the layer that hosts forms. All four are shell. Not one of the eight business forms needs screen code.&lt;/p&gt;

&lt;p&gt;You can see where the line is drawn at a glance: the hand written part is precisely the part definitions did not take over.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Architectural patterns: three sources, one piece from each
&lt;/h2&gt;

&lt;p&gt;With the first three questions answered, the shape of the framework is fixed. Before the fourth, one description that gets applied to it deserves handling: a hybrid of N-Tier, Clean Architecture and MVVM. "Hybrid" turns into a decorative word very easily, so here is what was taken and what was not.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;th&gt;What was taken&lt;/th&gt;
&lt;th&gt;What was not&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;N-Tier&lt;/td&gt;
&lt;td&gt;The layer boundaries and the cross layer data container. One request travels front end, API, business object, repository, data access infrastructure, database, and each layer knows only its neighbor. The most completely taken of the three&lt;/td&gt;
&lt;td&gt;Nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clean Architecture&lt;/td&gt;
&lt;td&gt;The inward dependency direction from the first section, verifiable by opening the project reference lists&lt;/td&gt;
&lt;td&gt;Its requirement on the domain. The pure approach gives every business concept its own strongly typed domain entity, and modeling a thousand tables one at a time is the scale cost from the previous post itself&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MVVM&lt;/td&gt;
&lt;td&gt;All three roles are present. The model is the cross layer data container, the view is rendered from the layout definition, and the view model is a framework provided &lt;code&gt;DataObject&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Nothing; the only difference is who writes the view model&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That &lt;code&gt;DataObject&lt;/code&gt; carries the full job: wrapping the data container, exposing field reads and writes for screen binding, raising notifications for value changes and unsaved state, managing the start and cancellation of detail row editing, and loading, saving and deleting against the back end. The difference is that it is not hand written per form. Its shape comes from the definition. The price is that the screen binds not to a property like &lt;code&gt;order.CustomerId&lt;/code&gt; but to a field name.&lt;/p&gt;

&lt;p&gt;The cross layer data container is not a custom type either, and a later post is devoted to what that choice costs.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Systems this should not be applied to
&lt;/h2&gt;

&lt;p&gt;Three questions before you commit. If any one of them does not survive, seriously consider not applying this.&lt;/p&gt;

&lt;p&gt;Q1: Are your screens standardized forms?&lt;br&gt;
Definitions can drive layout only because the layout patterns converged, and what converged here is a block of master fields plus a detail block. Dashboards, charts, drag and drop scheduling and maps are not in the pattern. The framework's answer is not "we support those too." It is that this road does not go through definitions, and you write it yourself.&lt;/p&gt;

&lt;p&gt;Q2: Is your request a single document?&lt;br&gt;
The cross layer data container is self describing, and the price is that every response carries the field structure with it. For one order that is worth it. For an event stream at tens of thousands per second it is not.&lt;/p&gt;

&lt;p&gt;Q3: How long does your system have to live?&lt;br&gt;
The cost is concentrated up front and the return does not show for three to five years. A system that ships, closes and gets rewritten wholesale three years later pays the cost and never collects.&lt;/p&gt;

&lt;p&gt;Not many scenarios satisfy all three at once, and internal enterprise management systems happen to hit every one. Finance, purchasing, inventory, sales, HR, customer relations: the screens are almost all standardized forms, the requests are almost all documents, and once it goes live it lives a long time.&lt;/p&gt;

&lt;p&gt;These boundaries are not "the framework has not got there yet." They are places where getting there would make things worse. A framework that can do anything has made no decisions for you, and the decisions are exactly where a framework's value is.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Northwind case study: how much one definition does
&lt;/h2&gt;

&lt;p&gt;The order in the case study has a single form structure definition, and it states:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One master with one detail&lt;/li&gt;
&lt;li&gt;Three cross table relationships on the master, to customer, employee and shipper, each a lookup window that brings one record back&lt;/li&gt;
&lt;li&gt;Each detail row picks a product&lt;/li&gt;
&lt;li&gt;Amounts are not typed by anyone. They are calculated from quantity, unit price and discount&lt;/li&gt;
&lt;li&gt;Three validation rules have to pass before saving&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once that one definition is written, everything in the table from the second section is in place. The tables are created from it, the list and lookup window layouts grow directly out of it, the write and query SQL is assembled by the framework, the data carries its structure with it across layers, and the validation rules are checked at the moment of saving. Not one of those requires additional code.&lt;/p&gt;

&lt;p&gt;The same definition is read by four front ends: desktop, browser, iOS and Android, all sharing one UI project.&lt;/p&gt;




&lt;p&gt;The value of a framework is not how much it can do. It is whether it can tell which decisions belong to it: the thing that is identical a thousand times over is its, and the thing that differs every time is yours. Draw that line crooked and both sides carry the weight. Draw it too conservatively and you redo the same work a thousand times. Draw it too greedily and you spend more effort working around it.&lt;/p&gt;

&lt;p&gt;The next post, &lt;a href="https://medium.com/@jeff377/the-northwind-case-study-eight-forms-and-a-custom-business-object-07a98ba2baa8" rel="noopener noreferrer"&gt;The Northwind Case Study: Eight Forms and a Custom Business Object&lt;/a&gt;, brings the case study on stage.&lt;/p&gt;




&lt;p&gt;📚 Part of the thirty-post series &lt;a href="https://dev.to/jeff377/erp-architects-notes-designing-a-definition-driven-framework-series-index-4ln7"&gt;ERP Architect's Notes: Designing a Definition-Driven Framework&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;📘 Original article on HackMD, in Traditional Chinese:&lt;br&gt;
👉 &lt;a href="https://hackmd.io/@jeff377/day-02-framework-decisions" rel="noopener noreferrer"&gt;https://hackmd.io/@jeff377/day-02-framework-decisions&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;📢 You are welcome to republish this, please credit the source.&lt;br&gt;
📬 Follow for more engineering notes:&lt;br&gt;
&lt;a href="https://www.facebook.com/profile.php?id=61574839666569" rel="noopener noreferrer"&gt;Facebook&lt;/a&gt; ｜ &lt;a href="https://hackmd.io/@jeff377" rel="noopener noreferrer"&gt;HackMD&lt;/a&gt; ｜ &lt;a href="https://github.com/jeff377" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; ｜ &lt;a href="https://www.nuget.org/profiles/jeff377" rel="noopener noreferrer"&gt;NuGet&lt;/a&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>dotnet</category>
      <category>erp</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Day 1: Why ERP Needs a Definition-Driven Framework</title>
      <dc:creator>Jeff Tsai</dc:creator>
      <pubDate>Sun, 25 Oct 2026 05:35:42 +0000</pubDate>
      <link>https://dev.to/jeff377/day-1-why-erp-needs-a-definition-driven-framework-1c2b</link>
      <guid>https://dev.to/jeff377/day-1-why-erp-needs-a-definition-driven-framework-1c2b</guid>
      <description>&lt;p&gt;This is the first post in a thirty-part series, ERP Architect's Notes: Designing a Definition-Driven Framework.&lt;/p&gt;

&lt;p&gt;What follows is not about building one form with some framework. It is about designing a framework that can drive hundreds of forms: which decisions it should make on your behalf, which ones it should leave to you, and why each mechanism ended up shaped the way it is. Every claim is checked against a working application built on the Northwind sample database, but the case study is evidence, not the subject.&lt;/p&gt;

&lt;p&gt;This post covers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The three layers of ERP development cost: a single change, scale, and delivering customization&lt;/li&gt;
&lt;li&gt;The three long-standing problems those layers converge on, and why tooling does not remove them&lt;/li&gt;
&lt;li&gt;The point where the paradigms diverge: is the single source of truth code, or definitions?&lt;/li&gt;
&lt;li&gt;A controlled experiment: how many places does one field touch under each paradigm?&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Who I am
&lt;/h2&gt;

&lt;p&gt;I have spent more than twenty years as a software architect in packaged ERP and HRM products. Packaged software has a very long product cycle, ten or twenty years is normal, and over that span the system is not merely maintained. It has to grow a new face every time the front-end generation turns over. I have been through three of them: desktop, web, and mobile apps. Each time the question was the same. Do we rebuild all several hundred screens?&lt;/p&gt;

&lt;p&gt;On a product line like that, an architectural decision does not get rewritten by someone else three years later. You watch it grow for ten years and then keep maintaining it. I stayed in the same role the whole time. I never moved into managing projects or teams and left the code behind, so I did not miss the consequences of a single one of those decisions.&lt;/p&gt;

&lt;h2&gt;
  
  
  About the framework, and about this series
&lt;/h2&gt;

&lt;p&gt;Some of the ideas I accumulated over those years were built inside real projects. More of them were only ever thought through, because writing all of it alone was never worth the cost. That cost is different now. I used Claude Code to build the ones I never got to, and the result is an open source framework, &lt;a href="https://github.com/jeff377/bee-library" rel="noopener noreferrer"&gt;bee-library&lt;/a&gt;. The Northwind case study in later posts runs on it.&lt;/p&gt;

&lt;p&gt;The case study itself lives in a separate repository, &lt;a href="https://github.com/jeff377/bee-northwind-avalonia" rel="noopener noreferrer"&gt;bee-northwind-avalonia&lt;/a&gt;. The framework repository holds the mechanisms; the case study repository holds only the application's own definition files and code, with the framework pulled in as a package. If you want to know what applying this framework actually requires, which definition files you prepare and what code you still have to write, the case study repository shows it more clearly than the framework does.&lt;/p&gt;

&lt;p&gt;The framework is still moving. Things that deserve to become mechanisms will keep being folded in, interfaces will change with them, and breaking changes will happen. If you want to reference the packages directly today, that has to be part of your calculation. You can also clone the whole thing and shape it into what you need.&lt;/p&gt;

&lt;p&gt;This series is not a manual for it. The framework has exactly one job here: to show that these ideas are not just talk. What the thirty posts discuss is the mechanisms that already exist, what problem each one ran into, how it was solved, what it cost, and why that option rather than another. Implementation details are not walked through line by line. You are entirely free not to use it, and to take only the idea that definitions should be the source, and build it in whatever language and tools you already know.&lt;/p&gt;




&lt;p&gt;The whole series rests on one sentence:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The complexity of an ERP is not in how hard any single feature is. It is in how many times the same fact gets restated, multiplied by how many tables, multiplied again by how many customizations.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  1. The cost of a single change: how many places one field lands in
&lt;/h2&gt;

&lt;p&gt;The requirement: orders need one more field, Required Date. No calculation, no dependent behavior, no effect on existing logic. The user says this is "just one field, it should be easy," and the user is right.&lt;/p&gt;

&lt;p&gt;In a typical Code-First stack, ORM plus web API plus front end, that field lands in thirteen places:&lt;/p&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;Location&lt;/th&gt;
&lt;th&gt;What changes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Entity class&lt;/td&gt;
&lt;td&gt;Add a property&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Type mapping configuration&lt;/td&gt;
&lt;td&gt;Precision, nullability, column name mapping&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Migration&lt;/td&gt;
&lt;td&gt;Generate it, review it, commit it, run it at deployment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Query projections&lt;/td&gt;
&lt;td&gt;The &lt;code&gt;Select&lt;/code&gt; for list queries, the column list for reports&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;DTOs&lt;/td&gt;
&lt;td&gt;Once for the request, once for the response&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Mapper configuration&lt;/td&gt;
&lt;td&gt;Entity to DTO&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Validation&lt;/td&gt;
&lt;td&gt;Required, range, ordering relative to the order date&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;API contract&lt;/td&gt;
&lt;td&gt;The OpenAPI schema, republished to consumers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;Front-end types&lt;/td&gt;
&lt;td&gt;A TypeScript interface or a C# model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;Front-end form&lt;/td&gt;
&lt;td&gt;Control, label, position in the layout&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;Front-end list&lt;/td&gt;
&lt;td&gt;Column, width, sorting&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;Localization resources&lt;/td&gt;
&lt;td&gt;One per language&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;Test data&lt;/td&gt;
&lt;td&gt;Fixtures, seeds, fake data generators&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;To be fair, several of those can be generated. Migrations come from a tool, OpenAPI is produced by scanning assemblies, front-end types can be generated from OpenAPI. The number you actually hand write is closer to five or six.&lt;/p&gt;

&lt;p&gt;But the number you hand write is not the point:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The single fact that "Required Date is a date, it is required, and it shows after the order date" has been restated thirteen times in this stack.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every restatement is a place where the copies can disagree:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Forgot the DTO, and the value reaches the database but never the front end&lt;/li&gt;
&lt;li&gt;Forgot the validation, and users submit it empty&lt;/li&gt;
&lt;li&gt;Forgot the list column, and users ask why they cannot see it on the query screen&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are hard problems. They are counting problems, and each inconsistency has to be found by a test or by a user report.&lt;/p&gt;

&lt;h3&gt;
  
  
  A team boundary runs straight through that table
&lt;/h3&gt;

&lt;p&gt;Items 1 through 8 are back end. Items 9 through 13 are front end. If those are two teams, the line cuts through the middle of the table, and every item that crosses it changes character. It stops being editing and becomes coordination.&lt;/p&gt;

&lt;p&gt;A request to add one field then grows into this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A meeting to agree the contract first, then separate scheduling on each side&lt;/li&gt;
&lt;li&gt;Sequencing: until the back end ships there is nothing for the front end to consume, so starting early means hand rolling a mock&lt;/li&gt;
&lt;li&gt;Release windows: whichever side goes first has to cope with the other not having caught up&lt;/li&gt;
&lt;li&gt;And during integration, the discovery that one side read "required" as a front end block and the other read it as a back end block&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Not one of those activities is writing the field. What they consume is not effort, it is waiting. The user counts the time from asking to shipping; the engineer counts the time actually spent working. So one side asks why changing a single field takes this long, and the other side does not feel it was slacking. Both are right, because the cost is real, it just does not land on the part where anyone is typing.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. The cost of scale: multiply that table by a thousand
&lt;/h2&gt;

&lt;p&gt;A full packaged ERP covers finance, purchasing, inventory, manufacturing, sales and HR. Business tables run into the thousands.&lt;/p&gt;

&lt;p&gt;The founding premise of an ORM is that objects map to tables, so:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;thousands of business tables
  -&amp;gt; thousands of entity classes
  -&amp;gt; plus DTOs, usually more than one: List / Detail / Create / Update
  -&amp;gt; plus mapper configuration
  -&amp;gt; plus repositories
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The overwhelming majority of those classes resemble each other. The entity for &lt;code&gt;Supplier&lt;/code&gt; and the entity for &lt;code&gt;Shipper&lt;/code&gt; differ in field names and field count; structurally they came from the same mold.&lt;/p&gt;

&lt;p&gt;This is not an argument that ORMs are bad. An ORM works well in the setting it was designed for. The problem is quantity. When the same pattern has to be copied a few thousand times, the copying itself becomes the dominant cost.&lt;/p&gt;

&lt;p&gt;Relationships then multiply that quantity again:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;ERP tables are densely connected by nature. An order points at a customer, a salesperson and a shipper; a shipment points at an order, a warehouse and a lot number.&lt;/li&gt;
&lt;li&gt;Every one of those relationships needs someone to decide how it is expressed, how it is loaded, and who maintains it. Each decision is reasonable on its own. Spread a few thousand tables out at once and nobody can hold the whole picture.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Take the thirteen slots from the first section and multiply them by a few thousand, and multiply the team boundary along with them. Cross team coordination is not paid once per table, it is paid once per request, and the number of requests is itself proportional to the number of tables.&lt;/p&gt;

&lt;p&gt;This is not inconvenience. It is loss of control. Anyone who has maintained a large system recognizes the symptoms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Nobody dares touch shared classes, because nobody is sure who depends on them&lt;/li&gt;
&lt;li&gt;New joiners take a long time before they will go near core modules&lt;/li&gt;
&lt;li&gt;The same concept has several different spellings across modules, because different people grew them at different times&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  3. The cost of customization: how do you deliver it once it is done
&lt;/h2&gt;

&lt;p&gt;The first two layers are about how much work a change takes. The third one is more fundamental, and there is no way around it, because:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;An ERP that gets installed and simply used essentially does not exist.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every rollout comes with customization. This client numbers documents differently, that one calculates cost differently, this department wants two extra fields, that report needs a different grouping. The only variable is the path the customization travels down.&lt;/p&gt;

&lt;p&gt;Under Code-First, all thirteen of those places are code, and code has exactly one delivery path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;edit -&amp;gt; compile -&amp;gt; test -&amp;gt; package -&amp;gt; deploy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same path hurts in three different ways depending on the deployment shape:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Deployment shape&lt;/th&gt;
&lt;th&gt;Where customization hurts&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A single organization's internal system&lt;/td&gt;
&lt;td&gt;The simplest case: schedule one maintenance window. But changing a decimal scale from 2 to 4 still means a full build, test and deploy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Packaged software sold to many customers, each with their own deployment&lt;/td&gt;
&lt;td&gt;Only two options. Put it in the trunk behind a switch, and the switches accumulate until nobody dares delete any of them. Or branch, and every branch evolves on its own while every fix in the trunk has to be carried across one at a time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-tenant SaaS on shared assemblies&lt;/td&gt;
&lt;td&gt;Tenant A changes one formula, and that means coordinating a maintenance window across all tenants and regression testing every tenant's functionality. If any one tenant breaks, the whole batch rolls back&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three shapes, one conclusion:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The size of the requirement and the cost of delivering it have come completely apart.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Whether the requirement is changing a decimal scale from 2 to 4 or reworking an entire module, it travels the same delivery path and carries the same deployment risk. The result is that small requirements have to be scheduled into the release rhythm too, and the engineering team is forced to treat every change as a significant event.&lt;/p&gt;

&lt;p&gt;The root of that decoupling is that customization was expressed as code. The deployment shape only decides how much it hurts. It does not decide whether it hurts.&lt;/p&gt;

&lt;p&gt;This layer is what definition-driven design is actually for:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Definitions are data, not code. Changing data requires neither recompilation nor redeployment of assemblies.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  4. Three problems that never got solved
&lt;/h2&gt;

&lt;p&gt;The costs from the first three sections converge into these:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Long-standing problem&lt;/th&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Specifications scattered across three layers&lt;/td&gt;
&lt;td&gt;The same field is defined once in the UI, once in the DTO and once in a database migration, and they drift apart easily&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Business logic scattered&lt;/td&gt;
&lt;td&gt;Different modules are maintained by different engineers, styles diverge, and the same work gets done more than once&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Customization that resists standardization&lt;/td&gt;
&lt;td&gt;Custom logic cannot flow back into the standard product, and accumulates as technical debt nobody can govern&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For the past twenty years the usual answer has been more tooling: code generators, project templates, smarter mappers, more automatic migrations. Those tools genuinely made each restatement cheaper.&lt;/p&gt;

&lt;p&gt;What they did not change is the structure in which one fact gets restated thirteen times. They only made restating it faster.&lt;/p&gt;

&lt;p&gt;For the third layer, customization cost, tooling helps even less. Generated code is still code, and still has to be compiled and deployed. The reason the third problem has been open for twenty years is right here.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Where the paradigms diverge
&lt;/h2&gt;

&lt;p&gt;The problem is not the tooling. It is the paradigm. There is only one question to answer:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Is the single source of truth in this system the code, or the definitions?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ul&gt;
&lt;li&gt;Code-First: the entity class is the source. The database is derived from it through migrations, the API contract is derived from it through OpenAPI, and front-end types are derived from that. Definition files are a by-product.&lt;/li&gt;
&lt;li&gt;Definition-Driven: a structural description is the source. The UI, the database, the CRUD SQL and the validation rules are all derived from it. Code appears only where a real business judgment has to be made.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The difference is not that a file extension changed from &lt;code&gt;.cs&lt;/code&gt; to &lt;code&gt;.xml&lt;/code&gt;. It is the nature of the source:&lt;/p&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;Code is the source&lt;/th&gt;
&lt;th&gt;Definitions are the source&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;How a thousand tables are represented&lt;/td&gt;
&lt;td&gt;A thousand classes&lt;/td&gt;
&lt;td&gt;A thousand definition records&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What you can bring to bear on them&lt;/td&gt;
&lt;td&gt;Compilers, IDEs, refactoring tools&lt;/td&gt;
&lt;td&gt;All of that, plus batch scripts, generators, programmatic checks, and AI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The delivery path for a change&lt;/td&gt;
&lt;td&gt;Compile, package, deploy&lt;/td&gt;
&lt;td&gt;Swap in a different definition&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;How customization is represented&lt;/td&gt;
&lt;td&gt;Branches or switches scattered through source&lt;/td&gt;
&lt;td&gt;A layer of data stacked on top of the standard definition&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;How the front end learns which fields exist&lt;/td&gt;
&lt;td&gt;A second, hand-written set of types kept in sync by contract and by people&lt;/td&gt;
&lt;td&gt;Fetching the same definition at run time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What a new front-end generation costs&lt;/td&gt;
&lt;td&gt;Rebuilding every screen&lt;/td&gt;
&lt;td&gt;Writing one new rendering layer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Who can change it&lt;/td&gt;
&lt;td&gt;Developers&lt;/td&gt;
&lt;td&gt;Developers, consultants, tools, and even users&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two of those rows deserve more than a line:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A thousand classes and a thousand definition records are the same count, but managing them is not the same order of magnitude. A thousand structurally identical XML files can be swept by one script that checks whether every amount field has the right decimal scale. That takes minutes. Doing the equivalent check across a thousand classes is considerably harder.&lt;/li&gt;
&lt;li&gt;Generational cost is the one I feel most strongly. Front-end technology turns over roughly every ten years. If screens are written by hand one at a time, a generation change means rebuilding all of them. If screens grow out of definitions, a generation change means writing one new rendering layer. The more forms you have, the wider the gap.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is also the reason layout deserves to be its own layer of definition. Structure is stable and follows the business; layout is volatile and follows the front-end generation. Once they are separated, what gets rewritten at generation change is the rendering layer, not the thousands of structural definitions.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. A controlled experiment: how many places for the same field
&lt;/h2&gt;

&lt;p&gt;Back to adding Required Date to orders. I counted it in this series' case study application, and the answer is three definition edits and zero lines of code.&lt;/p&gt;

&lt;p&gt;Table structure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;DbField&lt;/span&gt; &lt;span class="na"&gt;FieldName=&lt;/span&gt;&lt;span class="s"&gt;"required_date"&lt;/span&gt; &lt;span class="na"&gt;Caption=&lt;/span&gt;&lt;span class="s"&gt;"Required Date"&lt;/span&gt; &lt;span class="na"&gt;DbType=&lt;/span&gt;&lt;span class="s"&gt;"Date"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Form structure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;FormField&lt;/span&gt; &lt;span class="na"&gt;FieldName=&lt;/span&gt;&lt;span class="s"&gt;"required_date"&lt;/span&gt; &lt;span class="na"&gt;Caption=&lt;/span&gt;&lt;span class="s"&gt;"Required Date"&lt;/span&gt; &lt;span class="na"&gt;DbType=&lt;/span&gt;&lt;span class="s"&gt;"Date"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Layout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;LayoutField&lt;/span&gt; &lt;span class="na"&gt;FieldName=&lt;/span&gt;&lt;span class="s"&gt;"required_date"&lt;/span&gt; &lt;span class="na"&gt;Caption=&lt;/span&gt;&lt;span class="s"&gt;"Required Date"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What follows automatically after that: the database column, the form control, the list column, the SQL for insert, update and delete, and transport across layers.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No entity class to keep in sync, because the SQL is assembled from the definition at run time&lt;/li&gt;
&lt;li&gt;No DTO or mapper to keep in sync, because the data crossing layers carries its own structural description&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The count moves by one in either direction depending on the situation, but the count is not the point. The point is that all three places are definition data.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happened to the team boundary
&lt;/h3&gt;

&lt;p&gt;The front end no longer declares the field a second time. It asks the back end for that definition at run time and renders accordingly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;front end starts -&amp;gt; asks the back end for the definition -&amp;gt; grows controls, list columns and validation from it
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;The contract does not have to be negotiated, because the contract is that definition. There is no second copy that could disagree with it.&lt;/li&gt;
&lt;li&gt;There is nothing to wait for. The back end changes the definition, and the front end gets the new one the next time it asks.&lt;/li&gt;
&lt;li&gt;The coordination, scheduling, sequencing and release windows from the first section mostly just disappear.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What is left is half of the first step: what this field is called in business terms, where it goes, and whether it is required. Somebody was always going to decide that. It is a business judgment, not communication overhead.&lt;/p&gt;

&lt;p&gt;The boundary has not vanished, though. It moved.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The front end still needs a rendering layer that knows how to read definitions, and somebody has to build it. The difference is that it gets built once, and after that every form and every field is free.&lt;/li&gt;
&lt;li&gt;The team boundary drops from "every field" down to "the rendering layer," and the frequency of conversation changes from "every request" to "whenever framework capability changes."&lt;/li&gt;
&lt;li&gt;Anything that genuinely cannot be declared as a definition still has to be discussed. A rule about when a document may not be shipped still needs someone to decide who blocks it, at which layer, and what the message says. No architecture escapes that. What definition-driven design can eliminate is structural restatement, not semantic negotiation.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  After the change lands
&lt;/h3&gt;

&lt;p&gt;Two practical problems remain, and the framework has to absorb both:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The physical database does not have that column yet. The framework compares the definition against the actual structure and adds the column, so nobody hand writes a migration. A later post takes this apart.&lt;/li&gt;
&lt;li&gt;The system is running, so how does it know the definition changed? The node that edited the definition has to be able to notify the others so their cached copies are invalidated. &lt;a href="https://medium.com/@jeff377/day-12-cross-node-invalidation-for-definition-and-data-caches-dbef9c7f4b82" rel="noopener noreferrer"&gt;Cross-Node Invalidation for Definition and Data Caches&lt;/a&gt; covers that.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The point is one sentence: between changing a definition and having it take effect there is no compilation, no packaging and no assembly deployment.&lt;/p&gt;




&lt;h2&gt;
  
  
  7. A map of the thirty posts
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Chapter&lt;/th&gt;
&lt;th&gt;Theme&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;One (posts 1 to 3)&lt;/td&gt;
&lt;td&gt;Why Definition-Driven&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Two (posts 4 to 8)&lt;/td&gt;
&lt;td&gt;Definition Layer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Three (posts 9 to 12)&lt;/td&gt;
&lt;td&gt;Data Access and Caching&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Four (posts 13 to 18)&lt;/td&gt;
&lt;td&gt;Business Logic and API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Five (posts 19 to 21)&lt;/td&gt;
&lt;td&gt;Multi-Tenancy and Customization&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Six (posts 22 to 25)&lt;/td&gt;
&lt;td&gt;Transport, Security and Audit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Seven (posts 26 to 28)&lt;/td&gt;
&lt;td&gt;Data Semantics&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Eight (posts 29 to 30)&lt;/td&gt;
&lt;td&gt;Case Review and The Big Picture&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every post is written roughly the same way. First what the problem requires you to weigh, then how the framework is designed and why it was designed that way, and where a case study can verify it, a closing section with the actual Northwind definitions.&lt;/p&gt;

&lt;p&gt;Two things to be clear about up front:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The subject is long term cost, not development speed. In the short term Code-First wins almost every time: mature tooling, a complete ecosystem, a fast start. The cost of definition-driven design is concentrated at the beginning, and the return does not show until year three or year five. If your system ships, closes, and gets rewritten wholesale three years later, this series is of limited value to you. It is written for people who know they will be maintaining something for a long time.&lt;/li&gt;
&lt;li&gt;Trade-offs and costs will be written as plainly as benefits. The value of a framework is not in what it claims to do. It is in knowing precisely what it does not do, and why. Highly dynamic UIs, dashboards, reports and anything that is not form-shaped will only hurt more if you force them into this model.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  A thread to pick up later
&lt;/h2&gt;

&lt;p&gt;The next post, &lt;a href="https://medium.com/@jeff377/an-overview-of-definition-driven-architecture-a4eb489c73f1" rel="noopener noreferrer"&gt;An Overview of Definition-Driven Architecture&lt;/a&gt;, is about which decisions a framework should make on its users' behalf. The one after that, &lt;a href="https://medium.com/@jeff377/the-northwind-case-study-eight-forms-and-a-custom-business-object-07a98ba2baa8" rel="noopener noreferrer"&gt;The Northwind Case Study: Eight Forms and a Custom Business Object&lt;/a&gt;, brings the case study on stage.&lt;/p&gt;

&lt;p&gt;The case study builds a complete application out of Northwind: eight forms, master-detail, three cross-table relationships, validation, all of it. Exactly one of those forms needs code written for it. What is inside that one, and which places we deliberately chose not to express as definitions, gets reconciled item by item in &lt;a href="https://medium.com/@jeff377/day-29-which-part-definitions-the-framework-and-application-code-each-own-6d3269aa8cf5" rel="noopener noreferrer"&gt;Which Part Definitions, the Framework and Application Code Each Own&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The enemy of ERP is not complexity. It is restatement. And the reason tooling cannot eliminate restatement is that restatement is a product of the paradigm. As long as code is the source, the same fact is condemned to be said many times over.&lt;/p&gt;




&lt;p&gt;📚 Part of the thirty-post series &lt;a href="https://dev.to/jeff377/erp-architects-notes-designing-a-definition-driven-framework-series-index-4ln7"&gt;ERP Architect's Notes: Designing a Definition-Driven Framework&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;📘 Original article on HackMD, in Traditional Chinese:&lt;br&gt;
👉 &lt;a href="https://hackmd.io/@jeff377/day-01-why-definition-driven" rel="noopener noreferrer"&gt;https://hackmd.io/@jeff377/day-01-why-definition-driven&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;📢 You are welcome to republish this, please credit the source.&lt;br&gt;
📬 Follow for more engineering notes:&lt;br&gt;
&lt;a href="https://www.facebook.com/profile.php?id=61574839666569" rel="noopener noreferrer"&gt;Facebook&lt;/a&gt; ｜ &lt;a href="https://hackmd.io/@jeff377" rel="noopener noreferrer"&gt;HackMD&lt;/a&gt; ｜ &lt;a href="https://github.com/jeff377" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; ｜ &lt;a href="https://www.nuget.org/profiles/jeff377" rel="noopener noreferrer"&gt;NuGet&lt;/a&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>dotnet</category>
      <category>erp</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>A JSON-RPC Library With No Method Registration: How Does It Decide What Can Be Called?</title>
      <dc:creator>Jeff Tsai</dc:creator>
      <pubDate>Tue, 13 Oct 2026 12:09:53 +0000</pubDate>
      <link>https://dev.to/jeff377/a-json-rpc-library-with-no-method-registration-how-does-it-decide-what-can-be-called-2dcn</link>
      <guid>https://dev.to/jeff377/a-json-rpc-library-with-no-method-registration-how-does-it-decide-what-can-be-called-2dcn</guid>
      <description>&lt;p&gt;JSON-RPC 2.0 is a small specification: the client sends &lt;code&gt;{"jsonrpc":"2.0","method":"...","params":{...},"id":1}&lt;/code&gt;, and the server answers with a &lt;code&gt;result&lt;/code&gt; or an &lt;code&gt;error&lt;/code&gt;. REST makes you decide on routes, HTTP verbs and status codes; JSON-RPC only cares about which method to call and with what parameters. For an API where you own both the front end and the back end, that is pleasant to work with.&lt;/p&gt;

&lt;p&gt;Polhem.JsonRpc is a set of .NET packages for JSON-RPC 2.0: a transport-independent server, an ASP.NET Core endpoint and a client, built on System.Text.Json alone. It started as the API layer of the &lt;a href="https://github.com/polhem-dev/polhem" rel="noopener noreferrer"&gt;Polhem framework&lt;/a&gt;. I have now extracted it into standalone packages that do not depend on the framework, so other projects can reference them directly. The source is on &lt;a href="https://github.com/polhem-dev/polhem-jsonrpc" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; under the MIT license, and the packages are on &lt;a href="https://www.nuget.org/packages?q=Polhem.JsonRpc" rel="noopener noreferrer"&gt;NuGet&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  1️⃣ What it covers
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Full JSON-RPC 2.0: ordinary calls, notifications (calls that expect no response), and batches (several calls in one message). An id can be a string, an integer or &lt;code&gt;null&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A transport-independent server: the dispatcher (the part that finds and runs the method) knows nothing about HTTP. The ASP.NET Core endpoint is a separate package.&lt;/li&gt;
&lt;li&gt;A client: an HTTP transport, batches, notifications, and interceptors that can rewrite the parameters and result of each call.&lt;/li&gt;
&lt;li&gt;Optional payload encryption: the parameters and result go into an envelope that can be compressed and encrypted, and replayed calls can be rejected.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It is split into several NuGet packages, so an application references only what it needs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Package&lt;/th&gt;
&lt;th&gt;Use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.nuget.org/packages/Polhem.JsonRpc" rel="noopener noreferrer"&gt;Polhem.JsonRpc&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Message types, error codes and the transport abstraction shared by the server and the client&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.nuget.org/packages/Polhem.JsonRpc.Server" rel="noopener noreferrer"&gt;Polhem.JsonRpc.Server&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;The dispatcher: method resolution, parameter binding and filters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.nuget.org/packages/Polhem.JsonRpc.AspNetCore" rel="noopener noreferrer"&gt;Polhem.JsonRpc.AspNetCore&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;The ASP.NET Core endpoint, &lt;code&gt;MapJsonRpc&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.nuget.org/packages/Polhem.JsonRpc.Client" rel="noopener noreferrer"&gt;Polhem.JsonRpc.Client&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;The client, &lt;code&gt;JsonRpcConnector&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.nuget.org/packages/Polhem.JsonRpc.Payload" rel="noopener noreferrer"&gt;Polhem.JsonRpc.Payload&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Optional: the payload envelope, compression, encryption and replay protection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.nuget.org/packages/Polhem.JsonRpc.Payload.Client" rel="noopener noreferrer"&gt;Polhem.JsonRpc.Payload.Client&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Optional: a client that seals the parameters and opens the result of every call&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.nuget.org/packages/Polhem.JsonRpc.Payload.Server" rel="noopener noreferrer"&gt;Polhem.JsonRpc.Payload.Server&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Optional: opens the envelope and checks for replays on the server&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The server references &lt;code&gt;Polhem.JsonRpc.AspNetCore&lt;/code&gt;, and the client references only &lt;code&gt;Polhem.JsonRpc.Client&lt;/code&gt;. The split keeps a client, whether a desktop app or a mobile app, from pulling in ASP.NET Core and the reflection-based dispatcher.&lt;/p&gt;

&lt;p&gt;Native AOT compiles an app to native code at publish time, so nothing can rely on reflection at run time; iOS and WebAssembly both need it. The shared package, the client and the payload packages are marked AOT-compatible, and CI publishes a test program with Native AOT and runs it. The server package finds methods by reflection, so it does not support AOT. The target framework is &lt;code&gt;net10.0&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  2️⃣ Getting started
&lt;/h2&gt;

&lt;h3&gt;
  
  
  ASP.NET Core server (Minimal API)
&lt;/h3&gt;

&lt;p&gt;The server uses ASP.NET Core Minimal APIs (endpoints mapped directly in &lt;code&gt;Program.cs&lt;/code&gt; with &lt;code&gt;MapXxx&lt;/code&gt;, without MVC controllers). &lt;code&gt;MapJsonRpc&lt;/code&gt; creates a POST endpoint at the given path, and every JSON-RPC call comes in through it. &lt;code&gt;AddControllers&lt;/code&gt; is not needed.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Program.cs&lt;/code&gt; needs three things: register the object factory, register the JSON-RPC services, and map the endpoint.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Polhem.JsonRpc.AspNetCore&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Polhem.JsonRpc.Server&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;WebApplication&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddSingleton&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IJsonRpcObjectFactory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;AppObjectFactory&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddJsonRpcServer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapJsonRpc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/api"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RunAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;AddJsonRpcServer&lt;/code&gt; takes two sets of options. The first configures the server itself, such as filters, the method policy, the batch limit and serializer options. The second configures the HTTP endpoint, such as the request body size limit and which HTTP status code an error returns. Leave them out and the defaults apply.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddJsonRpcServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MaxBatchSize&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;50&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Filters&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ApiKeyFilter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// see section 5&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;http&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MaxRequestBodySize&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;1024&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="m"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;MapJsonRpc&lt;/code&gt; returns the same thing as any other Minimal API endpoint, so authorization, CORS and rate limiting chain on the usual way, for example &lt;code&gt;app.MapJsonRpc("/api").RequireAuthorization();&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The object factory and the class that handles the calls:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AppObjectFactory&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IJsonRpcObjectFactory&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;object&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;CreateObject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;progId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;JsonRpcRequestContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;progId&lt;/span&gt; &lt;span class="k"&gt;switch&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="s"&gt;"Calculator"&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Calculator&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;null&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;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Calculator&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;AddResponse&lt;/span&gt; &lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AddRequest&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Sum&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;A&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;B&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;h3&gt;
  
  
  Client
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;HttpClient&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;BaseAddress&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Uri&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"http://localhost:5080/api"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;rpc&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;JsonRpcConnector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;HttpTransport&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;added&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;rpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;InvokeAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AddResponse&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"Calculator.Add"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;AddRequest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;A&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;B&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is enough to call &lt;code&gt;Calculator.Add&lt;/code&gt;. Note that the server has no &lt;code&gt;AddMethod&lt;/code&gt; or similar registration, and &lt;code&gt;Calculator&lt;/code&gt; carries no attribute.&lt;/p&gt;




&lt;h2&gt;
  
  
  3️⃣ With no registration, which methods can be called?
&lt;/h2&gt;

&lt;p&gt;Skipping registration is convenient, but it raises a worry: if one day I add a public method to the class for internal use, can it be called from the network too? When the server receives &lt;code&gt;Calculator.Add&lt;/code&gt;, it runs the following checks in order.&lt;/p&gt;

&lt;p&gt;Name format. The method name must be &lt;code&gt;ProgId.Action&lt;/code&gt;. A ProgId (program identifier) may contain letters, digits, underscores and hyphens; an action letters, digits and underscores; each at most 64 characters. A malformed name is rejected before any lookup.&lt;/p&gt;

&lt;p&gt;The application decides the object. The ProgId is passed to your implementation of &lt;code&gt;IJsonRpcObjectFactory.CreateObject&lt;/code&gt;, which decides which object to create; returning &lt;code&gt;null&lt;/code&gt; means the ProgId is unknown. The packages keep no registry of names, so which ProgIds exist is entirely up to the factory. After the call, the object is passed to &lt;code&gt;ReleaseObjectAsync&lt;/code&gt; to be released.&lt;/p&gt;

&lt;p&gt;Method signature. An action maps only to a public, non-generic instance method with exactly one parameter. Static methods, property accessors and methods declared by &lt;code&gt;object&lt;/code&gt; do not count. If two or more methods with the same name qualify, the server does not pick one; it treats the method as not found.&lt;/p&gt;

&lt;p&gt;Naming convention. The parameter type must be named &lt;code&gt;{Action}Request&lt;/code&gt;, and the return type (or the result type of &lt;code&gt;Task&amp;lt;T&amp;gt;&lt;/code&gt; or &lt;code&gt;ValueTask&amp;lt;T&amp;gt;&lt;/code&gt;) must be named &lt;code&gt;{Action}Response&lt;/code&gt;. &lt;code&gt;Add&lt;/code&gt; takes an &lt;code&gt;AddRequest&lt;/code&gt; and returns an &lt;code&gt;AddResponse&lt;/code&gt;. This is the default method policy; any public method that does not follow it is treated as nonexistent and answered with &lt;code&gt;-32601 Method not found&lt;/code&gt;. An internal helper method that does not follow this naming cannot be called from outside.&lt;/p&gt;

&lt;p&gt;The rule can be replaced through &lt;code&gt;JsonRpcServerOptions.MethodPolicy&lt;/code&gt;. The Polhem framework replaces it with one that reads its own access-control attribute.&lt;/p&gt;

&lt;p&gt;Policy before filters. The method policy runs before every filter, so filters such as authorization checks or decryption only ever see calls that are allowed to run.&lt;/p&gt;

&lt;p&gt;Only after these checks are the &lt;code&gt;params&lt;/code&gt; bound. &lt;code&gt;params&lt;/code&gt; must be a JSON object, deserialized into the request class with camelCase names by default; missing &lt;code&gt;params&lt;/code&gt; or an array (positional parameters) are answered with &lt;code&gt;-32602 Invalid params&lt;/code&gt;. To return a custom error, a method throws &lt;code&gt;JsonRpcErrorException&lt;/code&gt;; any other exception is answered with &lt;code&gt;-32603 Internal error&lt;/code&gt;, and the exception message is not sent to the client.&lt;/p&gt;




&lt;h2&gt;
  
  
  4️⃣ The client
&lt;/h2&gt;

&lt;p&gt;An error returned by the server becomes a &lt;code&gt;JsonRpcErrorException&lt;/code&gt;, which carries the error code and message:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;rpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;InvokeAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;DivideResponse&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"Calculator.Divide"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;DivideRequest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Dividend&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Divisor&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;JsonRpcErrorException&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"Error &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Code&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&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;Notifications and batches:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The server runs it but does not respond.&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;rpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NotifyAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Calculator.Log"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;LogRequest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Message&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Hello"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Two calls sent in the same message.&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateBatch&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;first&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AddResponse&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"Calculator.Add"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;AddRequest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;A&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;B&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;second&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AddResponse&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"Calculator.Add"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;AddRequest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;A&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;B&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SendAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;Console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"&lt;/span&gt;&lt;span class="p"&gt;{(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;)!.&lt;/span&gt;&lt;span class="n"&gt;Sum&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="p"&gt;{(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;second&lt;/span&gt;&lt;span class="p"&gt;)!.&lt;/span&gt;&lt;span class="n"&gt;Sum&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&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 request and response classes usually live in a project shared by both sides, which is how the samples in the repo are arranged.&lt;/p&gt;




&lt;h2&gt;
  
  
  5️⃣ Extension points: filters and interceptors
&lt;/h2&gt;

&lt;p&gt;A server filter wraps every call. It can reject the call, rewrite the parameters before the method runs, and rewrite the result afterwards. For example, checking an API key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ApiKeyFilter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;expectedKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IJsonRpcFilter&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;ValueTask&lt;/span&gt; &lt;span class="nf"&gt;InvokeAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;JsonRpcRequestContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;JsonRpcFilterDelegate&lt;/span&gt; &lt;span class="n"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Transport&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryGetValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"X-Api-Key"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;out&lt;/span&gt; &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&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;CryptographicOperations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FixedTimeEquals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;Encoding&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UTF8&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;Encoding&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UTF8&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expectedKey&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="nf"&gt;JsonRpcErrorException&lt;/span&gt;&lt;span class="p"&gt;(-&lt;/span&gt;&lt;span class="m"&gt;32001&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Unauthorized"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&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="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddJsonRpcServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Filters&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ApiKeyFilter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To add an HTTP header on the client, use &lt;code&gt;HttpClient&lt;/code&gt;'s own &lt;code&gt;DelegatingHandler&lt;/code&gt;; the packages add no layer of their own. To rewrite the parameters and result of every call, add an &lt;code&gt;IJsonRpcClientInterceptor&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The Polhem framework plugs its sessions and authorization checks into these extension points (the object factory, the method policy and filters). None of that logic is in the packages themselves.&lt;/p&gt;




&lt;h2&gt;
  
  
  6️⃣ Encrypted payloads
&lt;/h2&gt;

&lt;p&gt;HTTPS protects the connection. If the parameters and results also need end-to-end protection, or a call must not run a second time when it is resent, add the optional payload packages.&lt;/p&gt;

&lt;p&gt;They put &lt;code&gt;params&lt;/code&gt; and &lt;code&gt;result&lt;/code&gt; into an envelope with three formats: plain JSON, encoded (serialized, then gzip-compressed), and encrypted (AES-256-CBC with HMAC-SHA256, encrypt-then-MAC). A frame carrying a timestamp and a sequence number can also be put in front of the content, so the server can reject expired or repeated requests; that is the replay protection.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Server: the application's policy decides the keys and which methods must be encrypted or replay-protected.&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddJsonRpcServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UsePayload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payloadOptions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;MyPayloadPolicy&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;

&lt;span class="c1"&gt;// Client: called the same way as an ordinary call; each call seals its parameters and opens its result.&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;rpc&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;PayloadConnector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;connector&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;PayloadProcessor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payloadOptions&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;PayloadConnectorOptions&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;KeyProvider&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;sessionKey&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;added&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;rpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;InvokeAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AddResponse&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"Calculator.Add"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few more security details:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The HMAC is compared in constant time and verified before anything is decrypted.&lt;/li&gt;
&lt;li&gt;The HMAC covers the method name and the direction, so an intercepted call cannot be redirected to another method, and a response cannot be sent back as a request.&lt;/li&gt;
&lt;li&gt;A method can require a minimum format; a request in a lower format is rejected before any key is asked for.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;How keys are negotiated (exchanged after sign-in, provisioned in advance, and so on) is outside the packages and left to the application.&lt;/p&gt;




&lt;h2&gt;
  
  
  7️⃣ How it differs from common libraries
&lt;/h2&gt;

&lt;p&gt;There are quite a few JSON-RPC libraries for .NET; here are two common ones for comparison. The version in each column header is the latest stable release on NuGet as of October 2026, and the details come from that release's documentation and NuGet dependency list. Later releases may differ.&lt;/p&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;StreamJsonRpc 2.25.29&lt;/th&gt;
&lt;th&gt;Tochka.JsonRpc 7.4.1&lt;/th&gt;
&lt;th&gt;Polhem.JsonRpc 1.2.0&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;JSON library&lt;/td&gt;
&lt;td&gt;Newtonsoft.Json by default, switchable to System.Text.Json; the package still depends on Newtonsoft.Json and MessagePack&lt;/td&gt;
&lt;td&gt;System.Text.Json&lt;/td&gt;
&lt;td&gt;System.Text.Json, with no other dependencies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transport model&lt;/td&gt;
&lt;td&gt;Full duplex over a Stream, WebSocket or Pipe&lt;/td&gt;
&lt;td&gt;HTTP on ASP.NET Core MVC&lt;/td&gt;
&lt;td&gt;HTTP request and response on ASP.NET Core Minimal APIs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Client&lt;/td&gt;
&lt;td&gt;Yes, with proxies generated from interfaces&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;How methods are registered&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;AddLocalRpcTarget&lt;/code&gt; adds a target object whose public methods become callable&lt;/td&gt;
&lt;td&gt;Controllers that inherit &lt;code&gt;JsonRpcControllerBase&lt;/code&gt;; each action is a method&lt;/td&gt;
&lt;td&gt;No registration; found by &lt;code&gt;ProgId.Action&lt;/code&gt; and the naming convention&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Native AOT&lt;/td&gt;
&lt;td&gt;Partially supported, within the limits its documentation lists&lt;/td&gt;
&lt;td&gt;Tied to MVC, not supported&lt;/td&gt;
&lt;td&gt;The shared package, the client and the payload packages support it; the server does not&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Good fit&lt;/td&gt;
&lt;td&gt;Two-way communication, such as Visual Studio extensions or LSP&lt;/td&gt;
&lt;td&gt;You already use MVC and want to keep writing controllers&lt;/td&gt;
&lt;td&gt;An API where you own both ends, possibly with desktop or mobile clients&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;StreamJsonRpc is the most complete, with cancellation, progress reporting and server-to-client calls, but it is designed for streams and full duplex; turning it into an HTTP request/response API means building the endpoint yourself.&lt;/p&gt;

&lt;p&gt;Tochka.JsonRpc is the closest in scope to Polhem.JsonRpc. It builds on MVC controllers, so MVC filters and model binding work as they are, at the cost of sharing MVC's lack of AOT support.&lt;/p&gt;

&lt;p&gt;Polhem.JsonRpc goes in a different direction: it trades a fixed call shape for "no registration" and "an API that can evolve", as the next section explains.&lt;/p&gt;




&lt;h2&gt;
  
  
  8️⃣ Design trade-offs
&lt;/h2&gt;

&lt;p&gt;The fixed call shape is there so the API can evolve. The &lt;code&gt;ProgId.Action&lt;/code&gt; name, one request class and one response class are a deliberate restriction. When a member is added to a request or response class, a member an older front end does not send binds to its default on the server, and a result member an older front end does not know is simply skipped when read. Old and new front ends call the same method, with no &lt;code&gt;v1&lt;/code&gt; and &lt;code&gt;v2&lt;/code&gt; endpoints to maintain. Positional parameters cannot do this: add, remove or reorder one parameter and every caller has to change.&lt;/p&gt;

&lt;p&gt;Defaults follow the specification. The internal error code is &lt;code&gt;-32603&lt;/code&gt;, a response carries only &lt;code&gt;jsonrpc&lt;/code&gt;, &lt;code&gt;result&lt;/code&gt; or &lt;code&gt;error&lt;/code&gt;, and &lt;code&gt;id&lt;/code&gt;, and &lt;code&gt;id&lt;/code&gt; is always written, as &lt;code&gt;null&lt;/code&gt; when it cannot be determined. The Polhem framework's original response format differed from the specification in places; when the packages were extracted, it was the framework that changed to follow the specification, not the packages that bent to the old format.&lt;/p&gt;

&lt;p&gt;The reasoning behind each decision is recorded as ADRs (Architecture Decision Records) in the repo's &lt;a href="https://github.com/polhem-dev/polhem-jsonrpc/tree/main/maintainers/adr" rel="noopener noreferrer"&gt;&lt;code&gt;maintainers/adr/&lt;/code&gt;&lt;/a&gt;, and the security design is summarized in &lt;a href="https://github.com/polhem-dev/polhem-jsonrpc/blob/main/docs/security.md" rel="noopener noreferrer"&gt;&lt;code&gt;docs/security.md&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  ✅ Wrapping up
&lt;/h2&gt;

&lt;p&gt;Install the server and client packages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet add package Polhem.JsonRpc.AspNetCore
dotnet add package Polhem.JsonRpc.Client
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The repo includes &lt;a href="https://github.com/polhem-dev/polhem-jsonrpc/tree/main/samples" rel="noopener noreferrer"&gt;samples&lt;/a&gt;: an ASP.NET Core Minimal API server, a console client (ordinary calls, error handling, notifications and batches), and a server and client using encrypted payloads.&lt;/p&gt;

&lt;p&gt;These packages do convention-based JSON-RPC, not general-purpose JSON-RPC. Protocols whose method names and parameters are defined by someone else, such as the Language Server Protocol, MCP or an Ethereum node's API (names like &lt;code&gt;textDocument/didOpen&lt;/code&gt; and &lt;code&gt;eth_call&lt;/code&gt; with positional parameters), cannot be implemented with them; StreamJsonRpc or the MCP C# SDK fit those better. Nor do they offer a full-duplex connection where the server calls the client: one call is one HTTP request and its response.&lt;/p&gt;

&lt;p&gt;If you own both ends of your API, want JSON-RPC that relies on System.Text.Json alone, or have clients that run as desktop or mobile apps, give it a try. Feedback, issues and pull requests are welcome.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.jsonrpc.org/specification" rel="noopener noreferrer"&gt;JSON-RPC 2.0 Specification&lt;/a&gt;: the full JSON-RPC 2.0 specification&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/microsoft/vs-streamjsonrpc" rel="noopener noreferrer"&gt;StreamJsonRpc&lt;/a&gt;: the choice when you need full-duplex streams&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/tochka-public/Tochka.JsonRpc" rel="noopener noreferrer"&gt;Tochka.JsonRpc&lt;/a&gt;: JSON-RPC built on ASP.NET Core MVC controllers&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://learn.microsoft.com/dotnet/core/deploying/native-aot/" rel="noopener noreferrer"&gt;Native AOT deployment&lt;/a&gt;: the limits and compatibility of .NET Native AOT&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;📘 Original article on HackMD, in Traditional Chinese:&lt;br&gt;
👉 &lt;a href="https://hackmd.io/@jeff377/polhem-jsonrpc" rel="noopener noreferrer"&gt;https://hackmd.io/@jeff377/polhem-jsonrpc&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;📢 You are welcome to republish this, please credit the source.&lt;br&gt;
📬 Follow for more engineering notes:&lt;br&gt;
&lt;a href="https://www.facebook.com/profile.php?id=61574839666569" rel="noopener noreferrer"&gt;Facebook&lt;/a&gt; ｜ &lt;a href="https://hackmd.io/@jeff377" rel="noopener noreferrer"&gt;HackMD&lt;/a&gt; ｜ &lt;a href="https://github.com/jeff377" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; ｜ &lt;a href="https://www.nuget.org/profiles/jeff377" rel="noopener noreferrer"&gt;NuGet&lt;/a&gt;&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>aspnetcore</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
